Files
sanguo_vnpy_v2/tests/api/test_hypothesis_decompose.py
T
claude_dev f740999848 fix(factor): P4-2 终审修复波 I-1/I-2+M-1/M-2/M-3——building 二次分解纯增量注册+护栏 arity 表/容器拦截/零数据列+回写终态可辨+prompt 在库名单+max_tokens 提额 #84 [vps] [no-doc]
- I-1 routes_pipeline: decompose 端点 building 态卡片二次分解不再走
  update_hypothesis_state(D3 矩阵无自迁移边 raise ValueError→500 半成功);
  building 态=纯增量注册语义,注册照常落 yaml
- I-2 factor_guard: _OPERATOR_ARITY 35 算子必需位置参数表(逐个对 vnpy
  ts/cs/math/ta_function+cs_neutralize 实现核实)→缺参/多参 bad_arity 拦截;
  Tuple/List/Set 入 _BLOCKED_NODES;表达式零非函数位 Name→no_data_column
- M-1 version_registry: writeback_card_death 卡片已终态(ValueError)单独
  stderr 注记可辨,与真失败分流
- M-2 hypothesis_decompose: build_decompose_messages 加 existing_names
  关键字参,system 模板注入已占用名字清单(截 120 防爆 prompt),初代+
  feedback 轮均带
- M-3: 两处 chat_json max_tokens 2000→4000(8 因子候选+justification 截断)
- 测试: guard 缺参/多参/元组/纯常量/合法不误拦 3 案+building 二次分解
  200 双因子在库+在库名单渲染(初代+feedback 轮)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-02 09:19:12 +08:00

106 lines
4.4 KiB
Python

"""反馈循环(C3):违规只重生成失败者+max_rounds 上限+通过者跨轮保有."""
import asyncio
import pytest
from sanguo_api import hypothesis_decompose as hd
from sanguo_factor.factor_guard import Violation
CARD = {"title": "高管增持后动量延续", "logic": "If 高管增持,则信息优势延续",
"expectedSign": "positive", "falsifiable": "若 60 日超额<=0 则证伪",
"dataNeeds": ["bars_daily"], "id": "hyp-1", "state": "queued"}
GOOD = {"name": "llm_mom20", "expression": "cs_rank(ts_mean(close, 20))",
"justification": "20 日均值动量承载延续假设"}
BAD_COL = {"name": "llm_bad", "expression": "cs_rank(pledge_pct)",
"justification": "j"}
class FakeClient:
def __init__(self, queue):
self.queue, self.calls = list(queue), []
async def chat_json(self, messages, **k):
self.calls.append(messages)
out = self.queue.pop(0)
if isinstance(out, Exception):
raise out
return out
def test_normalize_candidates_shape():
out = hd.normalize_candidates({"factors": [GOOD]})
assert out == [GOOD]
with pytest.raises(ValueError):
hd.normalize_candidates({"factors": []}) # 0 条
with pytest.raises(ValueError):
hd.normalize_candidates({"factors": [dict(GOOD, name="")]})
with pytest.raises(ValueError):
hd.normalize_candidates([GOOD]) # 顶层非 object
def test_messages_carry_whitelist_and_card():
msgs = hd.build_decompose_messages(CARD)
system = msgs[0]["content"]
assert "cs_rank" in system and "ts_mean" in system # 算子白名单
assert "close" in system and "roe_ttm" in system # 列白名单
assert CARD["title"] in msgs[1]["content"]
def test_loop_first_round_all_pass():
c = FakeClient([{"factors": [GOOD]}])
r = asyncio.run(hd.run_decompose(c, CARD, existing_names=set(),
library_exprs={}))
assert r["rounds"] == 1 and len(r["passed"]) == 1 and r["failed"] == []
assert len(c.calls) == 1
def test_loop_regenerates_only_failures():
c = FakeClient([{"factors": [GOOD, BAD_COL]}, {"factors": [GOOD]}])
r = asyncio.run(hd.run_decompose(c, CARD, existing_names=set(),
library_exprs={}))
assert r["rounds"] == 2
assert [p["name"] for p in r["passed"]] == ["llm_mom20"]
assert r["failed"] == []
# 第二轮 prompt 只带失败者反馈
fb = c.calls[1][1]["content"]
assert "llm_bad" in fb and "pledge_pct" in fb and "llm_mom20" not in fb
def test_loop_stops_at_max_rounds():
c = FakeClient([{"factors": [BAD_COL]}] * 3)
r = asyncio.run(hd.run_decompose(c, CARD, existing_names=set(),
library_exprs={}))
assert r["rounds"] == 3 and r["passed"] == []
assert r["failed"][0]["violations"][0]["code"] == "unknown_column"
assert len(c.calls) == 3 # 上限即停(C3)
def test_loop_name_claimed_by_passed():
# 轮1: GOOD 过门+BAD 违规;轮2 重生成者抢了 GOOD 已占有的名字→name_taken;
# 轮3 达上限停,passed 只剩轮1 的 GOOD
bad = {"name": "llm_bad", "expression": "cs_rank(pledge_pct)",
"justification": "j"}
stealer = {"name": "llm_mom20", "expression": "cs_rank(ts_std(close, 20))",
"justification": "j"}
c = FakeClient([{"factors": [GOOD, bad]}, {"factors": [stealer]},
{"factors": [stealer]}])
r = asyncio.run(hd.run_decompose(c, CARD, existing_names=set(),
library_exprs={}))
assert r["rounds"] == 3
assert [p["name"] for p in r["passed"]] == ["llm_mom20"]
codes = [v["code"] for v in r["failed"][0]["violations"]]
assert "name_taken" in codes
def test_messages_carry_taken_names():
msgs = hd.build_decompose_messages(CARD, existing_names={"fund_roe_ttm"})
assert "已占用名字" in msgs[0]["content"]
assert "fund_roe_ttm" in msgs[0]["content"]
# 反馈轮同带(M-2:run_decompose feedback 轮也传 existing_names)
fb = hd.build_decompose_messages(
CARD, feedback=[{"name": "llm_bad", "expression": "cs_rank(pledge_pct)",
"violations": [Violation("unknown_column", "列 pledge_pct 不在底座可用清单")]}],
existing_names={"fund_roe_ttm"})
assert "fund_roe_ttm" in fb[0]["content"]