fix(factor): codex review 修批——expression_match UnaryOp/keywords 编码+递归兜底签名+func名不独立计数+limit/爆栈防护;monthly_review 当月点带 ic_mean+非法 month 剔出逐年 [nas]
This commit is contained in:
@@ -13,10 +13,10 @@ _COMMUTATIVE = (ast.Add, ast.Mult)
|
|||||||
|
|
||||||
|
|
||||||
def _is_meta(node: ast.AST) -> bool:
|
def _is_meta(node: ast.AST) -> bool:
|
||||||
"""元数据子节点:expr_context(Load/Store)与算子(Div/Mult...)——
|
"""元数据子节点:expr_context(Load/Store)与算子(Div/Mult/USub...)——
|
||||||
前者是 Name 的语境标记,后者的类型已编码进 BinOp 的 key.
|
前者是 Name 的语境标记,后者的类型已编码进 BinOp/UnaryOp 的 key.
|
||||||
不跳过则 ? 兜底签名全局撞车+虚增计数(Name 算 2/整树多 1)."""
|
不跳过则 ? 兜底签名全局撞车+虚增计数(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:
|
def _node_size(node: ast.AST) -> int:
|
||||||
@@ -25,28 +25,46 @@ def _node_size(node: ast.AST) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _key(node: ast.AST) -> str:
|
def _key(node: ast.AST) -> str:
|
||||||
"""规范化结构签名:交换律 binop 左右子树排序后拼接."""
|
"""规范化结构签名:交换律 binop 左右子树排序后拼接.
|
||||||
|
|
||||||
|
兜底分支递归编码全部非元数据子节点——同类型未知节点只有子树也
|
||||||
|
同构才撞签,不再 `?TypeName` 全局等价(codex review CRITICAL).
|
||||||
|
"""
|
||||||
if isinstance(node, ast.BinOp) and isinstance(node.op, _COMMUTATIVE):
|
if isinstance(node, ast.BinOp) and isinstance(node.op, _COMMUTATIVE):
|
||||||
lk, rk = _key(node.left), _key(node.right)
|
lk, rk = _key(node.left), _key(node.right)
|
||||||
a, b = sorted((lk, rk))
|
a, b = sorted((lk, rk))
|
||||||
return f"({a}|{b}|{type(node.op).__name__})"
|
return f"({a}|{b}|{type(node.op).__name__})"
|
||||||
if isinstance(node, ast.BinOp):
|
if isinstance(node, ast.BinOp):
|
||||||
return f"({_key(node.left)}>{_key(node.right)}|{type(node.op).__name__})"
|
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):
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
||||||
args = ",".join(_key(a) for a in node.args)
|
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):
|
if isinstance(node, ast.Name):
|
||||||
return f"#{node.id}"
|
return f"#{node.id}"
|
||||||
if isinstance(node, ast.Constant):
|
if isinstance(node, ast.Constant):
|
||||||
return f"#{node.value!r}"
|
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]:
|
def _all_subtree_keys(node: ast.AST) -> dict[str, int]:
|
||||||
"""子树签名→节点数(同签名取最大)."""
|
"""子树签名→节点数(同签名取最大)."""
|
||||||
|
skip = _call_func_ids(node)
|
||||||
out: dict[str, int] = {}
|
out: dict[str, int] = {}
|
||||||
for sub in ast.walk(node):
|
for sub in ast.walk(node):
|
||||||
if _is_meta(sub):
|
if _is_meta(sub) or id(sub) in skip:
|
||||||
continue
|
continue
|
||||||
k = _key(sub)
|
k = _key(sub)
|
||||||
out[k] = max(out.get(k, 0), _node_size(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:
|
def similarity(expr_a: str, expr_b: str) -> float | None:
|
||||||
"""最大公共子树节点数 / 较小表达式节点数;任一解析失败=None.
|
"""最大公共子树节点数 / 较小表达式节点数;解析失败或深树爆栈=None.
|
||||||
|
|
||||||
取 .body 剥掉 Expression 包装节点——否则其签名落 ? 兜底桶,
|
取 .body 剥掉 Expression 包装节点——否则其签名落 ? 兜底桶,
|
||||||
任意两表达式的根都会撞签(子树=全树,相似度恒 1.0).
|
任意两表达式的根都会撞签(子树=全树,相似度恒 1.0).
|
||||||
|
RecursionError 防护:factor_guard _DEPTH_MAX=12 挡真实注册深树,
|
||||||
|
此处捕爆栈如实 None,不 raise(codex review LOW).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
ta = ast.parse(expr_a, mode="eval").body
|
ta = ast.parse(expr_a, mode="eval").body
|
||||||
tb = ast.parse(expr_b, 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
|
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],
|
def top_similar(expression: str, candidates: dict[str, str],
|
||||||
floor: float = 0.6) -> list[dict]:
|
floor: float = 0.6, limit: int = 5) -> list[dict]:
|
||||||
"""对候选池按相似度排序,过滤低于 floor 的(提示非硬拒)."""
|
"""对候选池按相似度排序,过滤低于 floor 的(提示非硬拒).
|
||||||
|
|
||||||
|
limit=5 上限:注册路径 registry 每条只存前 5 提示,防大池刷屏
|
||||||
|
(codex review MEDIUM).
|
||||||
|
"""
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
for name, expr in candidates.items():
|
for name, expr in candidates.items():
|
||||||
if not expression or not expr:
|
if not expression or not expr:
|
||||||
continue
|
continue
|
||||||
r = similarity(expression, expr)
|
try:
|
||||||
|
r = similarity(expression, expr)
|
||||||
|
except RecursionError:
|
||||||
|
continue
|
||||||
if r is not None and r >= floor:
|
if r is not None and r >= floor:
|
||||||
out.append({"name": name, "ratio": r})
|
out.append({"name": name, "ratio": r})
|
||||||
out.sort(key=lambda h: h["ratio"], reverse=True)
|
out.sort(key=lambda h: h["ratio"], reverse=True)
|
||||||
return out
|
return out[:limit]
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -88,7 +89,9 @@ def factor_stats(points: list[dict]) -> dict:
|
|||||||
by_year: dict[str, list[float]] = {}
|
by_year: dict[str, list[float]] = {}
|
||||||
for p in points:
|
for p in points:
|
||||||
m, v = str(p.get("month") or ""), p.get("ic_mean")
|
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)
|
by_year.setdefault(m[:4], []).append(v)
|
||||||
return {"icAll": round(mean_ic, 6), "tAll": t_all,
|
return {"icAll": round(mean_ic, 6), "tAll": t_all,
|
||||||
"positiveRatio": round(sum(1 for v in ics if v > 0) / n, 4),
|
"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():
|
for name, entry in registry["factors"].items():
|
||||||
cur = current_points.get(name) or {}
|
cur = current_points.get(name) or {}
|
||||||
hist = list(history_points.get(name, []))
|
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:
|
if cur.get("t") is not None:
|
||||||
hist = [p for p in hist if p["month"] != month_key] + [point]
|
hist = [p for p in hist if p["month"] != month_key] + [point]
|
||||||
monthly_points[name] = sorted(hist, key=lambda p: p["month"])
|
monthly_points[name] = sorted(hist, key=lambda p: p["month"])
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ def test_identical_and_commutative():
|
|||||||
|
|
||||||
|
|
||||||
def test_partial_common_subtree():
|
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",
|
r = similarity("ts_mean(close, 5) / volume",
|
||||||
"ts_mean(close, 5) * turnover")
|
"ts_mean(close, 5) * turnover")
|
||||||
assert r is not None and 0.6 < r < 1.0
|
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)
|
assert all(h["ratio"] >= 0.6 for h in hits)
|
||||||
# 空表达式/坏 candidates 不炸
|
# 空表达式/坏 candidates 不炸
|
||||||
assert top_similar("close", {"fa_x": ""}) == []
|
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}) == []
|
||||||
|
|||||||
@@ -105,8 +105,9 @@ def test_build_report_verdict_and_collective(eval_db, registry_yaml):
|
|||||||
assert rep["collective"]["is_collective_decay"] is False
|
assert rep["collective"]["is_collective_decay"] is False
|
||||||
# 观察面: s01_buzz 无批记录 → red
|
# 观察面: s01_buzz 无批记录 → red
|
||||||
assert rep["observations"]["s01_buzz"]["status"] == "red"
|
assert rep["observations"]["s01_buzz"]["status"] == "red"
|
||||||
# 当月点已存入 monthly_points(下月续算原料)
|
# 当月点已存入 monthly_points(下月续算原料;codex review 后带 ic_mean)
|
||||||
assert rep["monthly_points"]["pledge_net_chg"][-1] == {"month": "2026-09", "t": 1.2}
|
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):
|
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)
|
s = factor_stats(dirty)
|
||||||
assert s["icAll"] == 0.1
|
assert s["icAll"] == 0.1
|
||||||
assert s["byYear"] == {} # 空 month 不进逐年
|
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,如实
|
||||||
|
|||||||
Reference in New Issue
Block a user