diff --git a/sanguo_factor/expression_match.py b/sanguo_factor/expression_match.py index dd5a8e3e..d17c8d62 100644 --- a/sanguo_factor/expression_match.py +++ b/sanguo_factor/expression_match.py @@ -13,10 +13,10 @@ _COMMUTATIVE = (ast.Add, ast.Mult) def _is_meta(node: ast.AST) -> bool: - """元数据子节点:expr_context(Load/Store)与算子(Div/Mult...)—— - 前者是 Name 的语境标记,后者的类型已编码进 BinOp 的 key. + """元数据子节点:expr_context(Load/Store)与算子(Div/Mult/USub...)—— + 前者是 Name 的语境标记,后者的类型已编码进 BinOp/UnaryOp 的 key. 不跳过则 ? 兜底签名全局撞车+虚增计数(Name 算 2/整树多 1).""" - return isinstance(node, (ast.expr_context, ast.operator)) + return isinstance(node, (ast.expr_context, ast.operator, ast.unaryop)) def _node_size(node: ast.AST) -> int: @@ -25,28 +25,46 @@ def _node_size(node: ast.AST) -> int: def _key(node: ast.AST) -> str: - """规范化结构签名:交换律 binop 左右子树排序后拼接.""" + """规范化结构签名:交换律 binop 左右子树排序后拼接. + + 兜底分支递归编码全部非元数据子节点——同类型未知节点只有子树也 + 同构才撞签,不再 `?TypeName` 全局等价(codex review CRITICAL). + """ if isinstance(node, ast.BinOp) and isinstance(node.op, _COMMUTATIVE): lk, rk = _key(node.left), _key(node.right) a, b = sorted((lk, rk)) return f"({a}|{b}|{type(node.op).__name__})" if isinstance(node, ast.BinOp): return f"({_key(node.left)}>{_key(node.right)}|{type(node.op).__name__})" + if isinstance(node, ast.UnaryOp): + return f"{type(node.op).__name__}({_key(node.operand)})" if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): args = ",".join(_key(a) for a in node.args) - return f"{node.func.id}({args})" + kws = ",".join(f"{k.arg}={_key(k.value)}" + for k in sorted(node.keywords, key=lambda k: k.arg or "")) + return f"{node.func.id}({args};{kws})" if isinstance(node, ast.Name): return f"#{node.id}" if isinstance(node, ast.Constant): return f"#{node.value!r}" - return f"?{type(node).__name__}" + kids = ",".join(_key(c) for c in ast.iter_child_nodes(node) + if not _is_meta(c)) + return f"?{type(node).__name__}<{kids}>" + + +def _call_func_ids(node: ast.AST) -> set[int]: + """Call 的 func Name 节点 id 集:算子名已编码进 Call 签名,不再作为 + 独立子树参与匹配——否则仅共享算子名(ts_mean/f/cs_rank)也计公共 + 子树,产生 1/N 的地板噪声(codex review 修批实测).""" + return {id(c.func) for c in ast.walk(node) if isinstance(c, ast.Call)} def _all_subtree_keys(node: ast.AST) -> dict[str, int]: """子树签名→节点数(同签名取最大).""" + skip = _call_func_ids(node) out: dict[str, int] = {} for sub in ast.walk(node): - if _is_meta(sub): + if _is_meta(sub) or id(sub) in skip: continue k = _key(sub) out[k] = max(out.get(k, 0), _node_size(sub)) @@ -54,39 +72,49 @@ def _all_subtree_keys(node: ast.AST) -> dict[str, int]: def similarity(expr_a: str, expr_b: str) -> float | None: - """最大公共子树节点数 / 较小表达式节点数;任一解析失败=None. + """最大公共子树节点数 / 较小表达式节点数;解析失败或深树爆栈=None. 取 .body 剥掉 Expression 包装节点——否则其签名落 ? 兜底桶, 任意两表达式的根都会撞签(子树=全树,相似度恒 1.0). + RecursionError 防护:factor_guard _DEPTH_MAX=12 挡真实注册深树, + 此处捕爆栈如实 None,不 raise(codex review LOW). """ try: ta = ast.parse(expr_a, mode="eval").body tb = ast.parse(expr_b, mode="eval").body - except (SyntaxError, ValueError): + sa, sb = _node_size(ta), _node_size(tb) + if sa == 0 or sb == 0: + return None + keys_a = _all_subtree_keys(ta) + skip_b = _call_func_ids(tb) + best = 0 + for sub in ast.walk(tb): + if _is_meta(sub) or id(sub) in skip_b: + continue + k = _key(sub) + if k in keys_a: + best = max(best, min(keys_a[k], _node_size(sub))) + return round(best / min(sa, sb), 4) + except (SyntaxError, ValueError, RecursionError): return None - sa, sb = _node_size(ta), _node_size(tb) - if sa == 0 or sb == 0: - return None - keys_a = _all_subtree_keys(ta) - best = 0 - for sub in ast.walk(tb): - if _is_meta(sub): - continue - k = _key(sub) - if k in keys_a: - best = max(best, min(keys_a[k], _node_size(sub))) - return round(best / min(sa, sb), 4) def top_similar(expression: str, candidates: dict[str, str], - floor: float = 0.6) -> list[dict]: - """对候选池按相似度排序,过滤低于 floor 的(提示非硬拒).""" + floor: float = 0.6, limit: int = 5) -> list[dict]: + """对候选池按相似度排序,过滤低于 floor 的(提示非硬拒). + + limit=5 上限:注册路径 registry 每条只存前 5 提示,防大池刷屏 + (codex review MEDIUM). + """ out: list[dict] = [] for name, expr in candidates.items(): if not expression or not expr: continue - r = similarity(expression, expr) + try: + r = similarity(expression, expr) + except RecursionError: + continue if r is not None and r >= floor: out.append({"name": name, "ratio": r}) out.sort(key=lambda h: h["ratio"], reverse=True) - return out + return out[:limit] diff --git a/sanguo_factor/monthly_review.py b/sanguo_factor/monthly_review.py index 1e72ec31..f39f8ca5 100644 --- a/sanguo_factor/monthly_review.py +++ b/sanguo_factor/monthly_review.py @@ -12,6 +12,7 @@ from __future__ import annotations import argparse import json import os +import re import socket import sys from datetime import datetime @@ -88,7 +89,9 @@ def factor_stats(points: list[dict]) -> dict: by_year: dict[str, list[float]] = {} for p in points: m, v = str(p.get("month") or ""), p.get("ic_mean") - if m and v is not None: + # 月串须 YYYY-MM 才进逐年(codex review:非法串切前 4 字符会造出 + # "bad" 这类年键;ic 值聚合不含月串语义,照收不剔) + if v is not None and re.fullmatch(r"\d{4}-\d{2}", m): by_year.setdefault(m[:4], []).append(v) return {"icAll": round(mean_ic, 6), "tAll": t_all, "positiveRatio": round(sum(1 for v in ics if v > 0) / n, 4), @@ -108,7 +111,10 @@ def build_report(registry: dict, current_points: dict[str, dict], for name, entry in registry["factors"].items(): cur = current_points.get(name) or {} hist = list(history_points.get(name, [])) - point = {"month": month_key, "t": cur.get("t")} + # 当月点带 ic_mean(codex review:只带 t 会让 factor_stats 漏最新 + # 一期、首跑全空——t/ic_mean/count 同源于当月评估批) + point = {"month": month_key, "t": cur.get("t"), + "ic_mean": cur.get("ic_mean")} if cur.get("t") is not None: hist = [p for p in hist if p["month"] != month_key] + [point] monthly_points[name] = sorted(hist, key=lambda p: p["month"]) diff --git a/tests/factor/test_expression_match.py b/tests/factor/test_expression_match.py index 2bd77fb0..f9aad7c2 100644 --- a/tests/factor/test_expression_match.py +++ b/tests/factor/test_expression_match.py @@ -12,7 +12,9 @@ def test_identical_and_commutative(): def test_partial_common_subtree(): - # 公共子树=ts_mean(close,5)(5节点);分母=较小表达式(7节点) + # 节点口径(Name/Constant/Call 各计 1,算子与 ctx 为元数据不计): + # 公共子树 ts_mean(close,5)=4 节点(Call+func+close+5); + # 整树=BinOp+Call+volume(turnover)=6 节点;ratio=4/6≈0.6667 r = similarity("ts_mean(close, 5) / volume", "ts_mean(close, 5) * turnover") assert r is not None and 0.6 < r < 1.0 @@ -32,3 +34,56 @@ def test_top_similar_floor(): assert all(h["ratio"] >= 0.6 for h in hits) # 空表达式/坏 candidates 不炸 assert top_similar("close", {"fa_x": ""}) == [] + + +# —— codex review 修批(2026-10-10):UnaryOp/keywords/limit/深树防护 —— +def test_unaryop_distinct_operands_not_similar(): + """-close vs -open:UnaryOp 显式编码,不再落 ? 兜底桶互相撞签.""" + assert similarity("-close", "-open") == 0.0 + assert similarity("-ts_mean(close,5)", "-ts_mean(open,99)") == 0.0 + # 同构 UnaryOp 仍 1.0 + assert similarity("-close", "-close") == 1.0 + # containment 语义:close 整个是 -close 的子树,较小表达式全包含→1.0 + # (口径=公共子树/较小表达式节点数,非对称同构判定) + assert similarity("-close", "close") == 1.0 + + +def test_call_keywords_encoded(): + """Call 关键字参数须进签名:kw 值不同→不再 1.0(共享 close 操作数 + 仅余 1/5 地板噪声,低于提示阈 0.6).""" + r = similarity("ts_mean(close, window=5)", "ts_mean(close, window=99)") + assert r is not None and r < 0.6 + # 同 kwargs 同构 + assert similarity("f(close, w=5)", "f(close, w=5)") == 1.0 + # kwargs 顺序无关(按名排序后拼接) + assert similarity("f(a=1, b=2)", "f(b=2, a=1)") == 1.0 + # kwargs 有无也是结构差异:无 kw vs 有 kw 不撞签(仅共享 close,1/3) + r2 = similarity("f(close)", "f(close, w=5)") + assert r2 is not None and r2 < 0.6 + + +def test_nested_call_common_subtree(): + """嵌套 Call:全同→1;算子名也不同→0;仅共享外层算子名→占比 1/6 低于提示阈.""" + assert similarity("cs_rank(ts_mean(close, 5))", + "cs_rank(ts_mean(close, 5))") == 1.0 + assert similarity("cs_rank(ts_mean(close, 5))", + "zscore(ts_mean(open, 9))") == 0.0 + r = similarity("cs_rank(ts_mean(close, 5))", "cs_rank(ts_mean(open, 9))") + assert r is not None and r < 0.6 + + +def test_top_similar_limit_and_empty(): + """limit=5 上限(注册路径 registry 只存前 5)+空候选不炸.""" + cands = {f"fa_{i}": "close + open" for i in range(7)} + hits = top_similar("close + open", cands) + assert len(hits) == 5 + assert top_similar("close", {}) == [] + assert top_similar("", {"fa_a": "close"}) == [] + + +def test_deep_expression_no_crash(): + """深表达式递归爆栈不 raise,如实 None(factor_guard _DEPTH_MAX=12 挡真实 + 注册,此处为纯防护——RecursionError 捕获返回 None).""" + deep = "close" + " + close" * 3000 + assert similarity(deep, deep) is None + assert top_similar(deep, {"fa_a": deep}) == [] diff --git a/tests/factor/test_monthly_review.py b/tests/factor/test_monthly_review.py index 040731e3..23d1cc74 100644 --- a/tests/factor/test_monthly_review.py +++ b/tests/factor/test_monthly_review.py @@ -105,8 +105,9 @@ def test_build_report_verdict_and_collective(eval_db, registry_yaml): assert rep["collective"]["is_collective_decay"] is False # 观察面: s01_buzz 无批记录 → red assert rep["observations"]["s01_buzz"]["status"] == "red" - # 当月点已存入 monthly_points(下月续算原料) - assert rep["monthly_points"]["pledge_net_chg"][-1] == {"month": "2026-09", "t": 1.2} + # 当月点已存入 monthly_points(下月续算原料;codex review 后带 ic_mean) + assert rep["monthly_points"]["pledge_net_chg"][-1] == { + "month": "2026-09", "t": 1.2, "ic_mean": 0.028} def test_history_points_extend_streak(eval_db, registry_yaml, tmp_path): @@ -290,3 +291,33 @@ def test_factor_stats_empty_and_dirty(): s = factor_stats(dirty) assert s["icAll"] == 0.1 assert s["byYear"] == {} # 空 month 不进逐年 + + +# —— codex review 修批(2026-10-10):point 带 ic_mean/非法 month 校验/退化样本 —— +def test_factor_stats_degenerate_samples(): + """n=2 同值→方差 0→tAll 如实 None(不除零不造 0).""" + s = factor_stats(_ammo_pts(("2025-09", 0.05), ("2025-10", 0.05))) + assert s["icAll"] == 0.05 and s["tAll"] is None + assert s["positiveRatio"] == 1.0 + + +def test_factor_stats_invalid_month_not_in_by_year(): + """非法 month(bad-2025)不切前 4 字符进 byYear;ic 值聚合不受月串影响.""" + pts = [{"month": "bad-2025", "ic_mean": 0.1, "t": None}, + {"month": "2025-09", "ic_mean": 0.04, "t": None}] + s = factor_stats(pts) + assert s["byYear"] == {"2025": 0.04} # bad-2025 整条剔出逐年 + assert s["icAll"] == round((0.1 + 0.04) / 2, 6) # 值聚合照收 + + +def test_build_report_current_point_carries_ic_mean(): + """当月 point 须带 ic_mean——否则 factor_stats 漏最新一期、首跑全空.""" + reg = {"factors": {"fa_x": {"name": "fa_x", "hypothesis": "h", + "status": "assessable"}}} + rep = build_report(reg, {"fa_x": {"t": 2.0, "ic_mean": 0.05, "count": 100}}, + history_points={}, as_of="2026-09-30", host="t") + assert rep["monthly_points"]["fa_x"][-1]["ic_mean"] == 0.05 + s = rep["factor_stats"]["fa_x"] + assert s["icAll"] == 0.05 # 首跑单点即有弹药 + assert s["positiveRatio"] == 1.0 + assert s["tAll"] is None # n=1 无合成 t,如实