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>
164 lines
7.5 KiB
Python
164 lines
7.5 KiB
Python
"""_latest_factor_contribution 聚合适配单测(控制台归因页崩修复, 09-27).
|
||
|
||
契约: attribution_<end>.json 的 factorContribution 月度长表
|
||
{factor, month, contribution_ret, ...}(每因子×月一行, 含 market/size/
|
||
industry/specific 三桶殿后行) → 前端 P0 短表 {factorId, contributionPct}
|
||
(AttributionResearch.vue toFixed(1) 契约). 聚合口径=近 12 月
|
||
Σ contribution_ret×100; 不足 12 月退化全窗 Σ.
|
||
"""
|
||
import json
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
|
||
from sanguo_api.routes_pipeline import (
|
||
_aggregate_monthly_contribution,
|
||
_latest_attribution_meta,
|
||
_latest_factor_contribution,
|
||
)
|
||
|
||
|
||
def _row(factor, month, ret):
|
||
return {"factor": factor, "month": month, "contribution_ret": ret,
|
||
"cum_ret": 0.0, "exposure_avg": 0.5}
|
||
|
||
|
||
@pytest.fixture()
|
||
def attr_dir(tmp_path, monkeypatch):
|
||
monkeypatch.setenv("SANGUO_ATTRIBUTION_DIR", str(tmp_path))
|
||
return tmp_path
|
||
|
||
|
||
class TestAggregateMonthlyContribution:
|
||
def test_rolling_12m_sum_times_100(self):
|
||
# 14 个月: 首 2 月落窗外的贡献不得进聚合
|
||
rows = ([_row("alpha16", f"2025-{m:02d}", 0.001) for m in (8, 9)]
|
||
+ [_row("alpha16", m, 0.002) for m in
|
||
("2025-10", "2025-11", "2025-12", "2026-01", "2026-02",
|
||
"2026-03", "2026-04", "2026-05", "2026-06", "2026-07",
|
||
"2026-08", "2026-09")])
|
||
out = _aggregate_monthly_contribution(rows)
|
||
assert out == [{"factorId": "alpha16",
|
||
"contributionPct": pytest.approx(0.002 * 12 * 100)}]
|
||
|
||
def test_buckets_on_board_and_sorted_desc(self):
|
||
rows = ([_row("klow", "2026-09", 0.01)]
|
||
+ [_row("market", "2026-09", 0.02)]
|
||
+ [_row("specific", "2026-09", -0.015)])
|
||
out = _aggregate_monthly_contribution(rows)
|
||
assert [r["factorId"] for r in out] == ["market", "klow", "specific"]
|
||
assert out[0]["contributionPct"] == pytest.approx(2.0)
|
||
assert out[2]["contributionPct"] == pytest.approx(-1.5)
|
||
|
||
def test_short_table_falls_back_full_window(self):
|
||
# 挑战者件形态: 仅 8 个月(2026-02..09) → 全窗 Σ
|
||
rows = [_row("composite", m, 0.001) for m in
|
||
("2026-02", "2026-03", "2026-04", "2026-05",
|
||
"2026-06", "2026-07", "2026-08", "2026-09")]
|
||
out = _aggregate_monthly_contribution(rows)
|
||
assert out[0]["contributionPct"] == pytest.approx(0.8)
|
||
|
||
def test_dirty_rows_skipped_and_empty_ok(self):
|
||
rows = [_row("alpha16", "2026-09", 0.01),
|
||
{"factor": "bad", "month": "2026-09"}, # 缺 ret
|
||
{"factor": 42, "month": "2026-09", "contribution_ret": 9.9},
|
||
{"month": "2026-09", "contribution_ret": 1.0}] # 缺 factor
|
||
out = _aggregate_monthly_contribution(rows)
|
||
assert out == [{"factorId": "alpha16", "contributionPct": 1.0}]
|
||
assert _aggregate_monthly_contribution([]) == []
|
||
|
||
|
||
class TestLatestFactorContribution:
|
||
def test_reads_latest_and_aggregates(self, attr_dir):
|
||
older = {"factorContribution": [_row("alpha16", "2026-08", 0.9)]}
|
||
newer = {"factorContribution": [_row("klow", "2026-09", 0.01)]}
|
||
for name, doc in (("attribution_2026-08-31.json", older),
|
||
("attribution_2026-09-23.json", newer)):
|
||
with open(attr_dir / name, "w", encoding="utf-8") as f:
|
||
json.dump(doc, f)
|
||
out = _latest_factor_contribution()
|
||
# 文件名降序取最新(2026-09-23), 聚合后为短表
|
||
assert out == [{"factorId": "klow", "contributionPct": 1.0}]
|
||
|
||
def test_frontend_contract_types(self, attr_dir):
|
||
"""每行 factorId=str / contributionPct=数字——前端 toFixed 不炸."""
|
||
rows = ([_row(f, "2026-09", 0.001) for f in
|
||
("alpha12", "market", "size", "industry", "specific")]
|
||
+ [_row("wvma_20", "2026-09", -0.002)])
|
||
with open(attr_dir / "attribution_2026-09-23.json", "w",
|
||
encoding="utf-8") as f:
|
||
json.dump({"factorContribution": rows}, f)
|
||
for r in _latest_factor_contribution():
|
||
assert isinstance(r["factorId"], str)
|
||
assert isinstance(r["contributionPct"], (int, float))
|
||
f"{r['contributionPct']:+.1f}" # 前端同款格式化不抛
|
||
|
||
def test_fail_soft_empty_and_bad_json(self, attr_dir):
|
||
assert _latest_factor_contribution() == [] # 空目录
|
||
(attr_dir / "attribution_2026-09-23.json").write_text(
|
||
"{not json", encoding="utf-8")
|
||
assert _latest_factor_contribution() == [] # 坏 JSON
|
||
|
||
def test_challenger_variant_reads_subdir(self, attr_dir):
|
||
"""variant=challenger 读 challenger/ 子目录(件名带因子名也 glob 到)."""
|
||
(attr_dir / "attribution_2026-09-23.json").write_text(json.dumps(
|
||
{"as_of": "2026-09-23",
|
||
"factorContribution": [_row("klow", "2026-09", 0.01)]}),
|
||
encoding="utf-8")
|
||
ch = attr_dir / "challenger"
|
||
ch.mkdir()
|
||
(ch / "attribution_2026-09-23_composite_quant12_szneul.json").write_text(
|
||
json.dumps({"as_of": "2026-09-23",
|
||
"factorContribution": [_row("kup", "2026-09", 0.02)]}),
|
||
encoding="utf-8")
|
||
assert _latest_factor_contribution() == [
|
||
{"factorId": "klow", "contributionPct": 1.0}] # 缺省在位者
|
||
assert _latest_factor_contribution("challenger") == [
|
||
{"factorId": "kup", "contributionPct": 2.0}]
|
||
|
||
def test_meta_returns_as_of_and_window(self, attr_dir):
|
||
"""端点顺手回传归因件元信息(前端口径条显示数据截至日)."""
|
||
(attr_dir / "attribution_2026-09-23.json").write_text(json.dumps(
|
||
{"as_of": "2026-09-23", "window": {"start": "2021-07-01",
|
||
"end": "2026-09-23"},
|
||
"factorContribution": [_row("klow", "2026-09", 0.01)]}),
|
||
encoding="utf-8")
|
||
meta = _latest_attribution_meta()
|
||
assert meta["asOf"] == "2026-09-23"
|
||
assert meta["windowEnd"] == "2026-09-23"
|
||
assert meta["months"] >= 1
|
||
|
||
|
||
class TestEndpointVariant:
|
||
@pytest.fixture()
|
||
def api_client(self, tmp_path):
|
||
from sanguo_api.app import create_app
|
||
from sanguo_api.auth import set_jwt_config, create_token
|
||
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
|
||
|
||
def test_variant_param_and_meta(self, api_client, attr_dir):
|
||
(attr_dir / "attribution_2026-09-23.json").write_text(json.dumps(
|
||
{"as_of": "2026-09-23",
|
||
"factorContribution": [_row("klow", "2026-09", 0.01)]}),
|
||
encoding="utf-8")
|
||
ch = attr_dir / "challenger"
|
||
ch.mkdir()
|
||
(ch / "attribution_2026-09-23_composite_quant12_szneul.json").write_text(
|
||
json.dumps({"as_of": "2026-09-23",
|
||
"factorContribution": [_row("kup", "2026-09", 0.02)]}),
|
||
encoding="utf-8")
|
||
r = api_client.get("/api/v1/pipeline/attribution?variant=challenger")
|
||
assert r.status_code == 200
|
||
doc = r.json()
|
||
assert doc["factorContribution"] == [
|
||
{"factorId": "kup", "contributionPct": 2.0}]
|
||
assert doc["attributionMeta"]["asOf"] == "2026-09-23"
|
||
|
||
def test_bad_variant_422(self, api_client, attr_dir):
|
||
r = api_client.get("/api/v1/pipeline/attribution?variant=nope")
|
||
assert r.status_code == 422
|