- 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 通过
126 lines
4.6 KiB
Python
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
|