fix(api): codex review 修批——factors 列表 icFullT 从月报 factor_stats.tAll 接真(单次读取)+decompose similarity 仅建条落盘(已存在幂等重注册不重写) [vps]
This commit is contained in:
@@ -437,6 +437,13 @@ def factors() -> dict:
|
|||||||
reg = vr.load_registry(ensure_runtime_registry(
|
reg = vr.load_registry(ensure_runtime_registry(
|
||||||
_factor_registry_path(), os.path.join("config", "factor_registry.yaml")))
|
_factor_registry_path(), os.path.join("config", "factor_registry.yaml")))
|
||||||
points, last_eval = _eval_latest_points()
|
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]] = []
|
items: list[dict[str, Any]] = []
|
||||||
for name, e in reg["factors"].items():
|
for name, e in reg["factors"].items():
|
||||||
if e.get("status") == "graveyard":
|
if e.get("status") == "graveyard":
|
||||||
@@ -453,7 +460,7 @@ def factors() -> dict:
|
|||||||
"hypothesis": e.get("hypothesis"),
|
"hypothesis": e.get("hypothesis"),
|
||||||
"description": e.get("description") or "",
|
"description": e.get("description") or "",
|
||||||
"icRecentT": t, # 最近批(12M 滚动窗)即近窗 t
|
"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,
|
"promotedAtT": e.get("promotion_t") or None,
|
||||||
"similarity": e.get("similarity"), # 疑似换皮提示(注册时落)
|
"similarity": e.get("similarity"), # 疑似换皮提示(注册时落)
|
||||||
"decayMonths": 0,
|
"decayMonths": 0,
|
||||||
@@ -1169,6 +1176,9 @@ async def _decompose_worker(hyp_id: str, config, job_id: str | None = None) -> d
|
|||||||
today = date.today().isoformat()
|
today = date.today().isoformat()
|
||||||
for cand in result["passed"]:
|
for cand in result["passed"]:
|
||||||
src = derive_source(cand["expression"]) # 已过门,必为 str
|
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,
|
vr.upsert_factor(reg, cand["name"], hypothesis=hyp_id,
|
||||||
status="incubating", origin="decomposer",
|
status="incubating", origin="decomposer",
|
||||||
description=cand.get("description"))
|
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 存
|
# AST 防换皮提示(spec §4.2 2026-10-10):对既有池比对,≥0.6 存
|
||||||
# entry.similarity——硬门(dup_subtree)外的部分同构仍可注册,
|
# entry.similarity——硬门(dup_subtree)外的部分同构仍可注册,
|
||||||
# 提示非硬拒,判定人做.池=注册表条目当前表达式(versions[-1]).
|
# 提示非硬拒,判定人做.池=注册表条目当前表达式(versions[-1]).
|
||||||
from sanguo_factor import expression_match as em
|
if is_new:
|
||||||
_cands = {n: str(((v.get("versions") or [{}])[-1]
|
from sanguo_factor import expression_match as em
|
||||||
.get("params") or {}).get("expression") or "")
|
_cands = {n: str(((v.get("versions") or [{}])[-1]
|
||||||
for n, v in reg["factors"].items()
|
.get("params") or {}).get("expression") or "")
|
||||||
if n != cand["name"]}
|
for n, v in reg["factors"].items()
|
||||||
_sim = em.top_similar(str(cand["expression"]), _cands)
|
if n != cand["name"]}
|
||||||
if _sim:
|
_sim = em.top_similar(str(cand["expression"]), _cands)
|
||||||
reg["factors"][cand["name"]]["similarity"] = _sim
|
if _sim:
|
||||||
|
reg["factors"][cand["name"]]["similarity"] = _sim
|
||||||
registered.append({"name": cand["name"], "source": src,
|
registered.append({"name": cand["name"], "source": src,
|
||||||
"expression": cand["expression"],
|
"expression": cand["expression"],
|
||||||
"description": cand.get("description", ""),
|
"description": cand.get("description", ""),
|
||||||
|
|||||||
@@ -155,3 +155,34 @@ def test_factor_detail_ic_stats(client, tmp_path, monkeypatch):
|
|||||||
monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(empty))
|
monkeypatch.setenv("SANGUO_FACTOR_MONTHLY_DIR", str(empty))
|
||||||
r2 = client.get(f"/api/v1/pipeline/factors/{name}/detail")
|
r2 = client.get(f"/api/v1/pipeline/factors/{name}/detail")
|
||||||
assert r2.status_code == 200 and r2.json()["factor"]["icStats"] is None
|
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
|
||||||
|
|||||||
@@ -295,6 +295,46 @@ class TestDecompose:
|
|||||||
from sanguo_api import routes_pipeline as rp
|
from sanguo_api import routes_pipeline as rp
|
||||||
assert isinstance(rp._DECOMPOSE_LOCK, type(threading.Lock()))
|
assert isinstance(rp._DECOMPOSE_LOCK, type(threading.Lock()))
|
||||||
assert isinstance(rp._GRADUATE_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,
|
def test_decompose_register_similarity_hint(self, client, fake_llm,
|
||||||
tmp_path, monkeypatch):
|
tmp_path, monkeypatch):
|
||||||
"""AST 防换皮提示(spec §4.2 2026-10-10):新因子注册时对既有池比对,
|
"""AST 防换皮提示(spec §4.2 2026-10-10):新因子注册时对既有池比对,
|
||||||
|
|||||||
Reference in New Issue
Block a user