- Add acceptance criteria documentation (AC-0013 through AC-0023) - Add Line-0003 discovery documentation - Implement Strategy Catalog v1 with new database migration - Update portfolio API endpoints and schemas - Refactor strategy and portfolio services - Add strategy management CLI commands - Update domain models with strategy catalog support - Add comprehensive tests for v1 strategies - Update design documentation (data model, interfaces, runtime flows)
187 lines
6.0 KiB
Python
187 lines
6.0 KiB
Python
"""Versioned strategy catalog APIs."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
|
|
|
from app.core.auth import AuthContext, require_admin, require_read
|
|
from app.core.config import Settings, get_settings
|
|
from app.domain.models import StrategySet
|
|
from app.repositories.factory import get_portfolio_repository_from_settings
|
|
from app.schemas.portfolio import CreateStrategySetRequest, UpdateStrategySetRequest
|
|
from app.schemas.v1 import StrategyCreateRequest, StrategyResponse, StrategyUpdateRequest
|
|
from app.services.strategy_set_service import (
|
|
StrategyConflictError,
|
|
StrategyInactiveError,
|
|
StrategyInUseError,
|
|
StrategyNotFoundError,
|
|
StrategySetService,
|
|
)
|
|
|
|
router = APIRouter(tags=["v1-strategies"])
|
|
|
|
|
|
def _get_service(settings: Settings) -> tuple[StrategySetService, object]:
|
|
repository = get_portfolio_repository_from_settings(settings)
|
|
return StrategySetService(repository), repository
|
|
|
|
|
|
def _close_repository(repository: object) -> None:
|
|
if callable(getattr(type(repository), "close", None)):
|
|
repository.close()
|
|
|
|
|
|
def _canonical_config(config: dict[str, object]) -> str:
|
|
return json.dumps(config, sort_keys=True, ensure_ascii=True, separators=(",", ":"))
|
|
|
|
|
|
def _response(strategy: StrategySet) -> StrategyResponse:
|
|
try:
|
|
parsed = json.loads(strategy.config) if strategy.config else {}
|
|
except (TypeError, ValueError, json.JSONDecodeError):
|
|
parsed = {}
|
|
return StrategyResponse(
|
|
strategy_id=strategy.id,
|
|
name=strategy.name,
|
|
config=parsed if isinstance(parsed, dict) else {},
|
|
status=strategy.status,
|
|
revision=strategy.revision,
|
|
created_at=strategy.created_at,
|
|
updated_at=strategy.updated_at,
|
|
)
|
|
|
|
|
|
def _raise_strategy_error(exc: ValueError) -> None:
|
|
if isinstance(exc, StrategyNotFoundError):
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
|
if isinstance(exc, (StrategyInactiveError, StrategyInUseError, StrategyConflictError)):
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
|
raise exc
|
|
|
|
|
|
@router.get("/v1/strategies", response_model=list[StrategyResponse])
|
|
def list_active_strategies(
|
|
_auth: AuthContext = Depends(require_read),
|
|
settings: Settings = Depends(get_settings),
|
|
) -> list[StrategyResponse]:
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
return [_response(item) for item in service.list(include_inactive=False)]
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.get("/v1/strategies/{strategy_id}", response_model=StrategyResponse)
|
|
def get_active_strategy(
|
|
strategy_id: str,
|
|
_auth: AuthContext = Depends(require_read),
|
|
settings: Settings = Depends(get_settings),
|
|
) -> StrategyResponse:
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
try:
|
|
return _response(service.get(strategy_id, active_only=True))
|
|
except ValueError as exc:
|
|
_raise_strategy_error(exc)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.get("/v1/admin/strategies", response_model=list[StrategyResponse])
|
|
def list_admin_strategies(
|
|
_auth: AuthContext = Depends(require_admin),
|
|
settings: Settings = Depends(get_settings),
|
|
) -> list[StrategyResponse]:
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
return [_response(item) for item in service.list(include_inactive=True)]
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.get("/v1/admin/strategies/{strategy_id}", response_model=StrategyResponse)
|
|
def get_admin_strategy(
|
|
strategy_id: str,
|
|
_auth: AuthContext = Depends(require_admin),
|
|
settings: Settings = Depends(get_settings),
|
|
) -> StrategyResponse:
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
try:
|
|
return _response(service.get(strategy_id))
|
|
except ValueError as exc:
|
|
_raise_strategy_error(exc)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.post(
|
|
"/v1/admin/strategies",
|
|
response_model=StrategyResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def create_admin_strategy(
|
|
body: StrategyCreateRequest,
|
|
_auth: AuthContext = Depends(require_admin),
|
|
settings: Settings = Depends(get_settings),
|
|
) -> StrategyResponse:
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
strategy = service.create(
|
|
CreateStrategySetRequest(
|
|
name=body.name,
|
|
config=_canonical_config(body.config),
|
|
status=body.status,
|
|
)
|
|
)
|
|
return _response(strategy)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.patch("/v1/admin/strategies/{strategy_id}", response_model=StrategyResponse)
|
|
def update_admin_strategy(
|
|
strategy_id: str,
|
|
body: StrategyUpdateRequest,
|
|
_auth: AuthContext = Depends(require_admin),
|
|
settings: Settings = Depends(get_settings),
|
|
) -> StrategyResponse:
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
try:
|
|
strategy = service.update(
|
|
strategy_id,
|
|
UpdateStrategySetRequest(
|
|
name=body.name,
|
|
config=_canonical_config(body.config) if body.config is not None else None,
|
|
status=body.status,
|
|
),
|
|
)
|
|
return _response(strategy)
|
|
except ValueError as exc:
|
|
_raise_strategy_error(exc)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.delete(
|
|
"/v1/admin/strategies/{strategy_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
)
|
|
def delete_admin_strategy(
|
|
strategy_id: str,
|
|
_auth: AuthContext = Depends(require_admin),
|
|
settings: Settings = Depends(get_settings),
|
|
) -> Response:
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
try:
|
|
service.delete(strategy_id)
|
|
except ValueError as exc:
|
|
_raise_strategy_error(exc)
|
|
finally:
|
|
_close_repository(repository)
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|