55bcde798b
①合成层多族就绪: /pipeline/composite 加 ?family= 选族+families 清单
(前端下拉切换器)——以策略为锚,新策略钉住新合成层上位时登记新树,
无策略消费的单代族不进树; 未知族 fail-soft items=[]+清单照常。
前端: 族下拉+族说明+「新族自动出现」提示+空态+版本角色徽标
(在位者/挑战者)+TE 未算人话文案; sub 明示「页面上没有也不该有一键
换因子(决议 A)」。
②归因页整页人类可读重构(用户令: 按策略归因颗粒度讲清楚):
- ?variant=incumbent|challenger 切在位者/挑战者归因件(challenger=
生效位 challenger/ 子目录)+attributionMeta(asOf/windowEnd/months)
- 前端: variant 切换条+口径条(数值含义+数据截至+覆盖月数+更新节律);
右块分组=「收益结构·风格四桶」(market/size/industry/specific 带
tooltip 人话解释)与「选股因子贡献·12 源」(组级说明=暴露×因子收益
=贡献,长期负贡献=降权/退役候选); 合计行(恒等式加总≈同窗组合总
收益); 空态=「归因批未产出(首次 10-04)非故障」; 左块加偏差含义
说明+IS 金额行脚注; 条宽 min(…,100)% 防溢出。
- 测试: tests/api 202→208(families/未知族/challenger 子目录/meta/
variant 422); 前端 pipeline+mock 34 过(分组+合计+空态+variant 重拉+
族切换/空态); vue-tsc 过; spec §4.7 端点节同步(档随码走)。
Co-Authored-By: Claude Code <noreply@anthropic.com>
135 lines
4.9 KiB
Python
135 lines
4.9 KiB
Python
"""GET /pipeline/composite 真端点单测(P1 接真, 09-27; 前端告别 mock).
|
|
|
|
契约=前端 CompositeVersion {version, releasedAt, note,
|
|
members[{factorId,weight}], shadowCompare?{versus,week,totalWeeks,
|
|
trackingError,verdict}}; 影子对照读挑战者 szneul_eq_reb63 周报
|
|
(weeks_counted/te_annual), 09-28 首跑前无周报 → 如是 0/4 周+TE null
|
|
+「进行中」(不造数).
|
|
"""
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from sanguo_api.app import create_app
|
|
from sanguo_api.auth import set_jwt_config, create_token
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(tmp_path):
|
|
# jwt secret 每测试现设现签(全量套件中其它测试会改全局 secret)
|
|
set_jwt_config(secret="test", expire_minutes=60)
|
|
app = create_app(db_path=str(tmp_path / "t.db"), file_dir=None)
|
|
c = TestClient(app)
|
|
c.headers.update({"Authorization": f"Bearer {create_token('admin')}"})
|
|
return c
|
|
|
|
|
|
@pytest.fixture()
|
|
def no_shadow_weekly(monkeypatch):
|
|
"""无周报(09-28 首跑前形态)."""
|
|
from sanguo_portfolio import pipeline_store
|
|
monkeypatch.setattr(pipeline_store, "latest_weekly", lambda *a, **kw: None)
|
|
|
|
|
|
@pytest.fixture()
|
|
def env(no_shadow_weekly):
|
|
return None # 真档案真源表, 无需打桩
|
|
|
|
|
|
def _get(client):
|
|
r = client.get("/api/v1/pipeline/composite")
|
|
assert r.status_code == 200
|
|
return r.json()
|
|
|
|
|
|
def test_family_and_two_quant12_nodes(client, env):
|
|
doc = _get(client)
|
|
assert doc["family"] == "composite_quant12"
|
|
assert [v["version"] for v in doc["items"]] == ["quant12_v1", "quant12_v2a"]
|
|
|
|
|
|
def test_v1_equal_weight_12_sources(client, env):
|
|
v1 = _get(client)["items"][0]
|
|
assert len(v1["members"]) == 12
|
|
assert {m["factorId"] for m in v1["members"]} >= {"alpha16", "klow", "vma_60"}
|
|
for m in v1["members"]:
|
|
assert m["weight"] == pytest.approx(1 / 12, abs=1e-3)
|
|
assert isinstance(v1["releasedAt"], str) and v1["releasedAt"]
|
|
assert isinstance(v1["note"], str) and v1["note"]
|
|
|
|
|
|
def test_v2a_profile_weights_not_equal(client, env):
|
|
"""v2a=L1 档案 resolved 权重(ICIR 加权, 与 v1 等权可区分)且和=1."""
|
|
v2a = _get(client)["items"][1]
|
|
ws = {m["factorId"]: m["weight"] for m in v2a["members"]}
|
|
assert len(ws) == 12
|
|
assert sum(ws.values()) == pytest.approx(1.0, abs=1e-3)
|
|
assert len(set(round(w, 4) for w in ws.values())) > 3 # 非等权
|
|
assert ws["alpha16"] == pytest.approx(0.1435, abs=5e-4) # 档案真值锚
|
|
|
|
|
|
def test_shadow_compare_before_first_run(client, env):
|
|
"""09-28 首跑前: 无周报 → 0/4 周 + TE null + 进行中(挂挑战者节点)."""
|
|
doc = _get(client)
|
|
assert doc["items"][0]["shadowCompare"] == {
|
|
"versus": "quant12_v2a", "week": 0, "totalWeeks": 4,
|
|
"trackingError": None, "verdict": "进行中"}
|
|
assert "shadowCompare" not in doc["items"][1] # 在位者无对照
|
|
|
|
|
|
def test_shadow_compare_progressing_and_verdicts(client, no_shadow_weekly,
|
|
monkeypatch):
|
|
from types import SimpleNamespace
|
|
|
|
from sanguo_portfolio import pipeline_store
|
|
|
|
def fake_latest(path, sid, **kw):
|
|
assert sid == "szneul_eq_reb63"
|
|
return fake_latest.row
|
|
|
|
monkeypatch.setattr(pipeline_store, "get_effective_thresholds",
|
|
lambda *a, **kw: SimpleNamespace(
|
|
shadow_te_annual_max=0.02))
|
|
for weeks, te, want in ((2, 0.012, "进行中"), (4, 0.008, "达标"),
|
|
(4, 0.05, "超阈")):
|
|
fake_latest.row = {"weeks_counted": weeks, "te_annual": te}
|
|
monkeypatch.setattr(pipeline_store, "latest_weekly", fake_latest)
|
|
sc = _get(client)["items"][0]["shadowCompare"]
|
|
assert sc["week"] == weeks
|
|
assert sc["trackingError"] == te
|
|
assert sc["verdict"] == want
|
|
|
|
|
|
def test_fail_soft_when_factor_side_broken(client, no_shadow_weekly,
|
|
monkeypatch):
|
|
"""因子侧 import/档案坏 → fail-soft items=[](看板不 500, warning 留痕)."""
|
|
import builtins
|
|
|
|
real_import = builtins.__import__
|
|
|
|
def broken_import(name, *a, **kw):
|
|
if name.startswith("sanguo_factor"):
|
|
raise ImportError("factor side broken")
|
|
return real_import(name, *a, **kw)
|
|
|
|
monkeypatch.setattr(builtins, "__import__", broken_import)
|
|
doc = _get(client)
|
|
assert doc["items"] == []
|
|
assert doc["family"] == "composite_quant12"
|
|
|
|
|
|
def test_families_listed_for_switcher(client, env):
|
|
"""多族就绪: families 清单(前端切换器)+缺省返回首棵活树."""
|
|
doc = _get(client)
|
|
fams = doc["families"]
|
|
assert fams and fams[0]["key"] == "composite_quant12"
|
|
assert fams[0]["label"] and fams[0]["note"]
|
|
|
|
|
|
def test_unknown_family_fail_soft(client, env):
|
|
r = client.get("/api/v1/pipeline/composite?family=composite_nope")
|
|
assert r.status_code == 200
|
|
doc = r.json()
|
|
assert doc["family"] == "composite_nope"
|
|
assert doc["items"] == []
|
|
assert doc["families"] # 清单照常(切换器不空)
|