Files
sanguo_vnpy_v2/tests/api/test_routes_pipeline_attribution.py
claude_dev 55bcde798b
CI/CD / test (push) Successful in 37s
CI/CD / nas-deploy (push) Successful in 7s
CI/CD / nas-verify (push) Successful in 8s
feat(api): 控制台可读化波——合成层族切换器+归因页整页重构(用户令:多策略就绪+人类可读) [vps]
①合成层多族就绪: /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>
2026-09-27 18:43:21 +08:00

164 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""_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