Add ProofLine criteria documentation and Strategy Catalog v1 implementation
- 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)
This commit is contained in:
+10
-1
@@ -25,12 +25,17 @@ from app.schemas.portfolio import (
|
||||
UpdatePortfolioRequest,
|
||||
)
|
||||
from app.services.portfolio_service import PortfolioService
|
||||
from app.services.strategy_set_service import StrategyInactiveError, StrategyNotFoundError
|
||||
|
||||
router = APIRouter(prefix="/portfolios", tags=["portfolios"])
|
||||
|
||||
|
||||
def _raise_portfolio_error(exc: ValueError) -> None:
|
||||
def _raise_portfolio_error(exc: ValueError) -> NoReturn:
|
||||
detail = str(exc)
|
||||
if isinstance(exc, StrategyNotFoundError):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc
|
||||
if isinstance(exc, StrategyInactiveError):
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) from exc
|
||||
if "이미 연결된 태그입니다" in detail:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) from exc
|
||||
if "찾을 수 없습니다" in detail:
|
||||
@@ -151,6 +156,8 @@ def create_portfolio(
|
||||
if _ownership_required(settings):
|
||||
owner_user_id = _require_owner_context(auth_context)
|
||||
return service.create_portfolio(payload, owner_user_id=owner_user_id)
|
||||
except ValueError as exc:
|
||||
_raise_portfolio_error(exc)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
|
||||
@@ -169,6 +176,8 @@ def update_portfolio(
|
||||
if _ownership_required(settings):
|
||||
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
|
||||
return service.update_portfolio(portfolio_id, payload)
|
||||
except ValueError as exc:
|
||||
_raise_portfolio_error(exc)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
"""Strategy Set API routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Response, status
|
||||
|
||||
from app.core.auth import AuthContext, require_read, require_trade_write
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.portfolio import CreateStrategySetRequest, StrategySetResponse, UpdateStrategySetRequest
|
||||
from app.services.strategy_set_service import StrategySetService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/strategy-sets", tags=["strategy-sets"])
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@router.get("", response_model=list[StrategySetResponse])
|
||||
def list_strategy_sets(
|
||||
_auth: AuthContext = Depends(require_read),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> list[StrategySetResponse]:
|
||||
"""List all strategy sets."""
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
strategy_sets = service.list()
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
return [
|
||||
StrategySetResponse(
|
||||
id=ss.id,
|
||||
name=ss.name,
|
||||
config=ss.config,
|
||||
nick=ss.nick,
|
||||
created_at=ss.created_at,
|
||||
)
|
||||
for ss in strategy_sets
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=StrategySetResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_strategy_set(
|
||||
payload: CreateStrategySetRequest,
|
||||
_auth: AuthContext = Depends(require_trade_write),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> StrategySetResponse:
|
||||
"""Create a new strategy set (or return existing if (name, config) pair already exists)."""
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
strategy_set = service.create(payload)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
return StrategySetResponse(
|
||||
id=strategy_set.id,
|
||||
name=strategy_set.name,
|
||||
config=strategy_set.config,
|
||||
nick=strategy_set.nick,
|
||||
created_at=strategy_set.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{strategy_set_id}", response_model=StrategySetResponse)
|
||||
def update_strategy_set(
|
||||
strategy_set_id: str,
|
||||
payload: UpdateStrategySetRequest,
|
||||
_auth: AuthContext = Depends(require_trade_write),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> StrategySetResponse:
|
||||
"""Update a strategy set's name and/or config.
|
||||
|
||||
Note: Since strategy sets are identified by (name, config) uniqueness,
|
||||
updating may result in a new strategy set ID if name or config changes.
|
||||
"""
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
strategy_set = service.update(strategy_set_id, payload)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
return StrategySetResponse(
|
||||
id=strategy_set.id,
|
||||
name=strategy_set.name,
|
||||
config=strategy_set.config,
|
||||
nick=strategy_set.nick,
|
||||
created_at=strategy_set.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{strategy_set_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_strategy_set(
|
||||
strategy_set_id: str,
|
||||
_auth: AuthContext = Depends(require_trade_write),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> Response:
|
||||
"""Delete a strategy set.
|
||||
|
||||
Returns 204 No Content on success.
|
||||
Returns 409 Conflict if the strategy set is in use by any portfolio.
|
||||
"""
|
||||
try:
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
service.delete(strategy_set_id)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
except ValueError as e:
|
||||
# If strategy set is in use, return 409 Conflict
|
||||
raise ValueError(str(e)) from e
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
+22
-13
@@ -8,7 +8,6 @@ REQ-043: GET /v1/portfolios/{id}/holdings/cost-basis — current holding WAC cos
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
@@ -44,6 +43,7 @@ from app.schemas.v1 import (
|
||||
TradeHistoryItem,
|
||||
TradeHistoryResponse,
|
||||
)
|
||||
from app.services.strategy_set_service import StrategyInactiveError, StrategyNotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/v1/portfolios", tags=["v1"])
|
||||
@@ -96,6 +96,7 @@ def _portfolio_to_item(
|
||||
active_strategy_id=portfolio.active_strategy_id,
|
||||
strategy_name=portfolio.strategy_name,
|
||||
strategy_params=portfolio.strategy_params,
|
||||
strategy_revision=portfolio.strategy_revision,
|
||||
markets=portfolio.markets,
|
||||
)
|
||||
|
||||
@@ -160,10 +161,11 @@ def create_portfolio_v1(
|
||||
)
|
||||
|
||||
name = body.name or f"{body.broker}-{body.asset_id[:8]}"
|
||||
strategy_set = repo.get_or_create_strategy_set(
|
||||
body.strategy_name or "target_value",
|
||||
json.dumps(body.strategy_params or {}, sort_keys=True, ensure_ascii=True),
|
||||
)
|
||||
strategy_set = repo.get_strategy_set(body.strategy_id)
|
||||
if strategy_set is None:
|
||||
raise StrategyNotFoundError(f"strategy not found: {body.strategy_id}")
|
||||
if strategy_set.status != "active":
|
||||
raise StrategyInactiveError(f"strategy is inactive: {body.strategy_id}")
|
||||
|
||||
portfolio = repo.create_portfolio(
|
||||
str(uuid.uuid4()),
|
||||
@@ -181,6 +183,10 @@ def create_portfolio_v1(
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except StrategyNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
except StrategyInactiveError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
finally:
|
||||
@@ -215,21 +221,24 @@ def update_portfolio_v1(
|
||||
|
||||
fields = body.model_dump(exclude_none=True)
|
||||
if fields:
|
||||
if "strategy_name" in fields or "strategy_params" in fields:
|
||||
strategy_set = repo.get_or_create_strategy_set(
|
||||
fields.pop("strategy_name", None) or "target_value",
|
||||
json.dumps(fields.pop("strategy_params", None) or {}, sort_keys=True, ensure_ascii=True),
|
||||
)
|
||||
if "strategy_id" in fields:
|
||||
strategy_id = str(fields.pop("strategy_id"))
|
||||
strategy_set = repo.get_strategy_set(strategy_id)
|
||||
if strategy_set is None:
|
||||
raise StrategyNotFoundError(f"strategy not found: {strategy_id}")
|
||||
if strategy_set.status != "active":
|
||||
raise StrategyInactiveError(f"strategy is inactive: {strategy_id}")
|
||||
fields["active_strategy_id"] = strategy_set.id
|
||||
fields["avabile_strategies"] = [strategy_set.id]
|
||||
else:
|
||||
fields.pop("strategy_name", None)
|
||||
fields.pop("strategy_params", None)
|
||||
repo.update_portfolio(portfolio_id, **fields)
|
||||
|
||||
portfolio = repo.get(portfolio_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except StrategyNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
except StrategyInactiveError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user