Files
qlib/backend/tests/test_composites_api.py
T
Simon 4fa2bb748e feat(composite): M7.2b 因子组合落库 + /api/composites CRUD
- factor_composite 表(migration c3e9a0d1f4b5,MySQL 已应用;name 唯一)
- CompositeDefinition/Component 实体 + CompositeRepository Protocol + SQLAlchemy 实现
- /api/composites:POST(注册表自动填充组件 direction;未注册因子 400)、GET 列表/详情、DELETE
- tests/test_composites_api.py(repo CRUD/同名拒绝/删除、API 方向填充/404/400);全量 pytest 通过
2026-09-09 00:30:37 +08:00

126 lines
4.6 KiB
Python

"""M7.2b 因子组合测试:repo CRUD(幂等/去重)+ /api/composites(含方向填充/404/删除)。"""
from __future__ import annotations
import pytest
from app.api import deps
from app.domain.entities.composite import CompositeComponent, CompositeDefinition
from app.infrastructure.persistence.sqlalchemy.base import Base
from app.infrastructure.persistence.sqlalchemy.repositories.composite_impl import (
SqlAlchemyCompositeRepository,
)
from app.main import app
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
@pytest.fixture()
def session(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'c.db'}", future=True)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, expire_on_commit=False)
with Session() as s:
yield s
def _comp(name="质量动量", **kw) -> CompositeDefinition:
base = dict(
name=name,
description="测试组合",
components=[
CompositeComponent(name="momentum_60", weight=0.7, direction="higher_is_better"),
CompositeComponent(name="volatility_60", weight=0.3, direction="lower_is_better"),
],
)
base.update(kw)
return CompositeDefinition(**base)
class TestCompositeRepository:
def test_save_get_list(self, session) -> None:
repo = SqlAlchemyCompositeRepository(session)
saved = repo.save(_comp().model_copy(update={"id": "CF-TEST-1"}))
session.commit()
assert saved.id == "CF-TEST-1"
got = repo.get("CF-TEST-1")
assert got is not None and got.name == "质量动量"
assert len(got.components) == 2
assert repo.list()[0].components[0].direction == "higher_is_better"
def test_duplicate_name_rejected(self, session) -> None:
repo = SqlAlchemyCompositeRepository(session)
repo.save(_comp().model_copy(update={"id": "CF-A"}))
session.commit()
with pytest.raises(ValueError):
repo.save(_comp().model_copy(update={"id": "CF-B"})) # 同名不同 id
def test_delete(self, session) -> None:
repo = SqlAlchemyCompositeRepository(session)
repo.save(_comp().model_copy(update={"id": "CF-X"}))
session.commit()
assert repo.delete("CF-X") is True
session.commit()
assert repo.get("CF-X") is None
assert repo.delete("CF-X") is False
@pytest.fixture()
def client(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'api.db'}", future=True)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, expire_on_commit=False)
def _session_override():
with Session() as s:
yield s
app.dependency_overrides[deps.get_session] = _session_override
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
class TestCompositesApi:
def test_crud(self, client) -> None:
body = {
"name": "质量动量组合",
"description": "动量+低波",
"components": [
{"name": "momentum_60", "weight": 0.7},
{"name": "volatility_60", "weight": 0.3},
],
}
created = client.post("/api/composites", json=body)
assert created.status_code == 200
cid = created.json()["id"]
assert cid.startswith("CF-")
# 方向由注册表自动填充
dirs = {c["name"]: c["direction"] for c in created.json()["components"]}
assert dirs == {"momentum_60": "higher_is_better", "volatility_60": "lower_is_better"}
rows = client.get("/api/composites").json()
assert len(rows) == 1
detail = client.get(f"/api/composites/{cid}").json()
assert detail["name"] == "质量动量组合"
assert client.delete(f"/api/composites/{cid}").status_code == 200
assert client.get(f"/api/composites/{cid}").status_code == 404
assert client.delete(f"/api/composites/{cid}").status_code == 404
def test_unknown_factor_rejected(self, client) -> None:
resp = client.post(
"/api/composites",
json={
"name": "坏组合",
"components": [{"name": "no_such_factor", "weight": 1}],
},
)
assert resp.status_code == 400
assert "no_such_factor" in resp.json()["detail"]
def test_duplicate_name_400(self, client) -> None:
body = {"name": "同名", "components": [{"name": "momentum_60", "weight": 1}]}
assert client.post("/api/composites", json=body).status_code == 200
assert client.post("/api/composites", json=body).status_code == 400