138 lines
6.0 KiB
Python
138 lines
6.0 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))",
|
|
"description": "20 日收盘均价动量,量度股价短期延续性",
|
|
"justification": "20 日均值动量承载延续假设"}
|
|
BAD_COL = {"name": "llm_bad", "expression": "cs_rank(pledge_pct)",
|
|
"description": "质押比例因子", "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)",
|
|
"description": "质押比例因子", "justification": "j"}
|
|
stealer = {"name": "llm_mom20", "expression": "cs_rank(ts_std(close, 20))",
|
|
"description": "抢名因子", "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_loop_batch_internal_same_expression_different_name_fails():
|
|
"""P2-7 批内防换皮: 同批「同构换皮、异名」候选不得双双过门——
|
|
对照集须随本批 passed 的 expression 增量并入(曾只收名字)."""
|
|
twin = {"name": "llm_twin", "expression": GOOD["expression"],
|
|
"description": "换皮因子", "justification": "j"}
|
|
c = FakeClient([{"factors": [GOOD, twin]}, {"factors": [twin]},
|
|
{"factors": [twin]}])
|
|
r = asyncio.run(hd.run_decompose(c, CARD, existing_names=set(),
|
|
library_exprs={}))
|
|
# 换皮者被 dup_exact 拦下,重生成三轮仍原样复述→只有原身过门
|
|
assert [p["name"] for p in r["passed"]] == ["llm_mom20"]
|
|
codes = [v["code"] for v in r["failed"][0]["violations"]]
|
|
assert "dup_exact" 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"]
|
|
|
|
|
|
def test_normalize_candidates_requires_description():
|
|
"""缺 description=同缺 justification 纪律:raise 走反馈循环重生成."""
|
|
import copy
|
|
no_desc = copy.deepcopy(GOOD)
|
|
del no_desc["description"]
|
|
with pytest.raises(ValueError):
|
|
hd.normalize_candidates({"factors": [no_desc]})
|
|
|
|
|
|
def test_normalize_candidates_cleans_description():
|
|
"""description 压空白+截 120 字(justification 同款清洗纪律)."""
|
|
raw = dict(GOOD, description=" 多空格\n与换行 " + "长" * 200)
|
|
out = hd.normalize_candidates({"factors": [raw]})
|
|
assert out[0]["description"] == "多空格 与换行 " + "长" * 112
|