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 通过
This commit is contained in:
Simon
2026-09-09 00:30:37 +08:00
parent 273aee2772
commit 4fa2bb748e
10 changed files with 412 additions and 1 deletions
+69
View File
@@ -0,0 +1,69 @@
"""因子组合 API(M7.2b):/api/composites CRUD。
组合 = 可复用因子权重集;计算时方向取因子注册表,落库冗余快照。
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from app.api.deps import CompositeRepoDep, DbSession
from app.application.services.job_executor import new_id
from app.domain.entities.composite import CompositeComponent, CompositeDefinition
from app.quant.factors import FactorError, get_factor
router = APIRouter(prefix="/composites", tags=["composites"])
def _fill_direction(definition: CompositeDefinition) -> CompositeDefinition:
"""以因子注册表元数据补齐/校正组件 direction(登记但不可计算的因子报错)。"""
out = []
for c in definition.components:
try:
defn, _fn = get_factor(c.name)
except FactorError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
out.append(CompositeComponent(name=c.name, weight=c.weight, direction=defn.direction))
return definition.model_copy(update={"components": out})
@router.post("", summary="保存因子组合", response_model=CompositeDefinition)
def create_composite(
definition: CompositeDefinition,
composite_repo: CompositeRepoDep,
session: DbSession,
) -> CompositeDefinition:
prepared = _fill_direction(definition.model_copy(update={"id": ""}))
try:
saved = composite_repo.save(
prepared.model_copy(update={"id": new_id("CF")})
)
session.commit()
return saved
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("", summary="因子组合列表", response_model=list[CompositeDefinition])
def list_composites(composite_repo: CompositeRepoDep) -> list[CompositeDefinition]:
return composite_repo.list()
@router.get("/{composite_id}", summary="读取因子组合", response_model=CompositeDefinition)
def get_composite(composite_id: str, composite_repo: CompositeRepoDep) -> CompositeDefinition:
row = composite_repo.get(composite_id)
if row is None:
raise HTTPException(status_code=404, detail=f"组合 {composite_id} 不存在")
return row
@router.delete("/{composite_id}", summary="删除因子组合")
def delete_composite(
composite_id: str,
composite_repo: CompositeRepoDep,
session: DbSession,
) -> dict:
if not composite_repo.delete(composite_id):
raise HTTPException(status_code=404, detail=f"组合 {composite_id} 不存在")
session.commit()
return {"deleted": composite_id}
+9
View File
@@ -11,6 +11,7 @@ from fastapi import Depends
from sqlalchemy.orm import Session
from app.application.services.selection_service import SelectionService
from app.domain.repositories.composite import CompositeRepository
from app.domain.repositories.factor import FactorRepository
from app.domain.repositories.jobs import ExperimentRepository, JobRepository
from app.domain.repositories.market import (
@@ -19,6 +20,9 @@ from app.domain.repositories.market import (
StockRepository,
)
from app.domain.repositories.selection import SelectionRepository
from app.infrastructure.persistence.sqlalchemy.repositories.composite_impl import (
SqlAlchemyCompositeRepository,
)
from app.infrastructure.persistence.sqlalchemy.repositories.factor_impl import (
SqlAlchemyFactorRepository,
)
@@ -78,6 +82,10 @@ def _factor_repo_factory(session: DbSession) -> FactorRepository:
return SqlAlchemyFactorRepository(session)
def _composite_repo_factory(session: DbSession) -> CompositeRepository:
return SqlAlchemyCompositeRepository(session)
StockRepoDep = Annotated[StockRepository, Depends(_stock_repo_factory)]
DailyRepoDep = Annotated[DailyBarRepository, Depends(_daily_repo_factory)]
EngineDep = Annotated[QuantEngine, Depends(_engine_factory)]
@@ -85,6 +93,7 @@ ResearchServiceDep = Annotated[ResearchService, Depends(_service_factory)]
SelectionServiceDep = Annotated[SelectionService, Depends(_selection_service_factory)]
SelectionRepoDep = Annotated[SelectionRepository, Depends(_selection_repo_factory)]
FactorRepoDep = Annotated[FactorRepository, Depends(_factor_repo_factory)]
CompositeRepoDep = Annotated[CompositeRepository, Depends(_composite_repo_factory)]
def _job_repo_factory(session: DbSession):
+12 -1
View File
@@ -8,12 +8,23 @@ from __future__ import annotations
from fastapi import APIRouter
from app.api import agent, experiments, factors, health, jobs, research, selections, stocks
from app.api import (
agent,
composites,
experiments,
factors,
health,
jobs,
research,
selections,
stocks,
)
api_router = APIRouter()
api_router.include_router(health.router)
api_router.include_router(stocks.router)
api_router.include_router(factors.router)
api_router.include_router(composites.router)
api_router.include_router(research.router)
api_router.include_router(selections.router)
api_router.include_router(jobs.router)