- 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)
443 lines
15 KiB
Python
443 lines
15 KiB
Python
"""Portfolio API routes."""
|
|
|
|
from typing import NoReturn
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status
|
|
|
|
from app.core.auth import AuthContext, Permission, authorize_request
|
|
from app.core.config import Settings, get_settings
|
|
from app.core.errors import FeatureNotReadyError
|
|
from app.repositories.factory import get_portfolio_repository_from_settings
|
|
from app.schemas.portfolio import (
|
|
BuyRequest,
|
|
CommentCreateRequest,
|
|
CommentResponse,
|
|
CommentUpdateRequest,
|
|
CreatePortfolioRequest,
|
|
InvestRequest,
|
|
PortfolioDetailResponse,
|
|
PortfolioListItem,
|
|
PortfolioTagMutationRequest,
|
|
PortfolioTagResponse,
|
|
ResetPortfolioRequest,
|
|
SellRequest,
|
|
TransferRequest,
|
|
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) -> 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:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=detail) from exc
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) from exc
|
|
|
|
|
|
def _get_service(settings: Settings) -> tuple[PortfolioService, object]:
|
|
repository = get_portfolio_repository_from_settings(settings)
|
|
return PortfolioService(repository), repository
|
|
|
|
|
|
def _close_repository(repository: object) -> None:
|
|
if callable(getattr(type(repository), "close", None)):
|
|
repository.close()
|
|
|
|
|
|
def _ownership_required(settings: Settings) -> bool:
|
|
return settings.db_enabled
|
|
|
|
|
|
def _raise_with_deprecation_header(exc: Exception) -> NoReturn:
|
|
if isinstance(exc, HTTPException):
|
|
headers = dict(exc.headers or {})
|
|
headers["Deprecation"] = "true"
|
|
exc.headers = headers
|
|
raise exc
|
|
if isinstance(exc, FeatureNotReadyError):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
|
detail=str(exc) or "Feature not implemented yet",
|
|
headers={"Deprecation": "true"},
|
|
) from exc
|
|
raise exc
|
|
|
|
|
|
def _require_owner_context(auth_context: AuthContext) -> str:
|
|
if auth_context.user_id is None:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Ownership context is required")
|
|
return auth_context.user_id
|
|
|
|
|
|
def _ensure_portfolio_owned(repository: object, portfolio_id: str, auth_context: AuthContext) -> None:
|
|
if auth_context.permission == Permission.ADMIN:
|
|
return
|
|
owner_user_id = _require_owner_context(auth_context)
|
|
try:
|
|
portfolio = repository.get(portfolio_id)
|
|
except ValueError as exc:
|
|
_raise_portfolio_error(exc)
|
|
if portfolio.owner_user_id != owner_user_id:
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch")
|
|
|
|
|
|
@router.get("", response_model=list[PortfolioListItem])
|
|
def list_portfolios(
|
|
request: Request,
|
|
broker: str | None = None,
|
|
status_filter: str | None = None,
|
|
include_tags: list[str] | None = Query(default=None, alias="includeTags"),
|
|
exclude_tags: list[str] | None = Query(default=None, alias="excludeTags"),
|
|
match_mode: str | None = Query(default=None, alias="matchMode"),
|
|
settings: Settings = Depends(get_settings),
|
|
) -> list[PortfolioListItem]:
|
|
auth_context = authorize_request(Permission.READ, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
owner_user_id: str | None = None
|
|
if _ownership_required(settings) and auth_context.permission != Permission.ADMIN:
|
|
owner_user_id = _require_owner_context(auth_context)
|
|
return service.list_portfolios(
|
|
broker=broker,
|
|
status=status_filter,
|
|
owner_user_id=owner_user_id,
|
|
include_tags=include_tags,
|
|
exclude_tags=exclude_tags,
|
|
match_mode=match_mode,
|
|
)
|
|
except ValueError as exc:
|
|
_raise_portfolio_error(exc)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.get("/{portfolio_id}", response_model=PortfolioDetailResponse)
|
|
def get_portfolio(
|
|
portfolio_id: str,
|
|
request: Request,
|
|
*,
|
|
recent_trades: int = 5,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> PortfolioDetailResponse:
|
|
auth_context = authorize_request(Permission.READ, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
if _ownership_required(settings):
|
|
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
|
|
return service.get_portfolio_status(portfolio_id=portfolio_id, recent_trades=recent_trades)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=PortfolioDetailResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def create_portfolio(
|
|
payload: CreatePortfolioRequest,
|
|
request: Request,
|
|
*,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> PortfolioDetailResponse:
|
|
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
owner_user_id = auth_context.user_id or "system"
|
|
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)
|
|
|
|
|
|
@router.patch("/{portfolio_id}", response_model=PortfolioDetailResponse)
|
|
def update_portfolio(
|
|
portfolio_id: str,
|
|
payload: UpdatePortfolioRequest,
|
|
request: Request,
|
|
*,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> PortfolioDetailResponse:
|
|
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
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)
|
|
|
|
|
|
@router.post("/{portfolio_id}/buy", response_model=PortfolioDetailResponse, deprecated=True)
|
|
def buy_portfolio_asset(
|
|
portfolio_id: str,
|
|
payload: BuyRequest,
|
|
request: Request,
|
|
response: Response,
|
|
*,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> PortfolioDetailResponse:
|
|
response.headers["Deprecation"] = "true"
|
|
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
if _ownership_required(settings):
|
|
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
|
|
return service.buy(portfolio_id, payload)
|
|
except Exception as exc:
|
|
_raise_with_deprecation_header(exc)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.post("/{portfolio_id}/sell", response_model=PortfolioDetailResponse, deprecated=True)
|
|
def sell_portfolio_asset(
|
|
portfolio_id: str,
|
|
payload: SellRequest,
|
|
request: Request,
|
|
response: Response,
|
|
*,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> PortfolioDetailResponse:
|
|
response.headers["Deprecation"] = "true"
|
|
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
if _ownership_required(settings):
|
|
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
|
|
return service.sell(portfolio_id, payload)
|
|
except Exception as exc:
|
|
_raise_with_deprecation_header(exc)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.post("/{portfolio_id}/invest", response_model=PortfolioDetailResponse)
|
|
def invest_buffer(
|
|
portfolio_id: str,
|
|
payload: InvestRequest,
|
|
request: Request,
|
|
*,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> PortfolioDetailResponse:
|
|
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
if _ownership_required(settings):
|
|
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
|
|
return service.invest(portfolio_id, payload)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.post("/{portfolio_id}/transfer", response_model=PortfolioDetailResponse)
|
|
def transfer_buffer(
|
|
portfolio_id: str,
|
|
payload: TransferRequest,
|
|
request: Request,
|
|
*,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> PortfolioDetailResponse:
|
|
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
if _ownership_required(settings):
|
|
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
|
|
return service.transfer(portfolio_id, payload)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.post("/{portfolio_id}/reset", response_model=PortfolioDetailResponse)
|
|
def reset_portfolio(
|
|
portfolio_id: str,
|
|
payload: ResetPortfolioRequest,
|
|
request: Request,
|
|
*,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> PortfolioDetailResponse:
|
|
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
if _ownership_required(settings):
|
|
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
|
|
return service.reset(portfolio_id, payload)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.delete("/{portfolio_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_portfolio(
|
|
portfolio_id: str,
|
|
request: Request,
|
|
*,
|
|
keep_ledger: bool = False,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> Response:
|
|
authorize_request(Permission.ADMIN, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
service.delete(portfolio_id, keep_ledger=keep_ledger)
|
|
finally:
|
|
_close_repository(repository)
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@router.post(
|
|
"/{portfolio_id}/comments",
|
|
response_model=CommentResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def create_comment(
|
|
portfolio_id: str,
|
|
payload: CommentCreateRequest,
|
|
request: Request,
|
|
*,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> CommentResponse:
|
|
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
if _ownership_required(settings):
|
|
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
|
|
return service.create_comment(portfolio_id, payload)
|
|
except ValueError as exc:
|
|
_raise_portfolio_error(exc)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.get("/{portfolio_id}/comments", response_model=list[CommentResponse])
|
|
def list_comments(
|
|
portfolio_id: str,
|
|
request: Request,
|
|
*,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> list[CommentResponse]:
|
|
auth_context = authorize_request(Permission.READ, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
if _ownership_required(settings):
|
|
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
|
|
return service.list_comments(portfolio_id)
|
|
except ValueError as exc:
|
|
_raise_portfolio_error(exc)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.patch("/{portfolio_id}/comments/{comment_id}", response_model=CommentResponse)
|
|
def update_comment(
|
|
portfolio_id: str,
|
|
comment_id: str,
|
|
payload: CommentUpdateRequest,
|
|
request: Request,
|
|
*,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> CommentResponse:
|
|
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
if _ownership_required(settings):
|
|
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
|
|
return service.update_comment(portfolio_id, comment_id, payload)
|
|
except ValueError as exc:
|
|
_raise_portfolio_error(exc)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.delete("/{portfolio_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_comment(
|
|
portfolio_id: str,
|
|
comment_id: str,
|
|
request: Request,
|
|
*,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> Response:
|
|
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
if _ownership_required(settings):
|
|
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
|
|
service.delete_comment(portfolio_id, comment_id)
|
|
except ValueError as exc:
|
|
_raise_portfolio_error(exc)
|
|
finally:
|
|
_close_repository(repository)
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@router.post("/{portfolio_id}/tags", response_model=PortfolioTagResponse, status_code=status.HTTP_201_CREATED)
|
|
def add_tag(
|
|
portfolio_id: str,
|
|
payload: PortfolioTagMutationRequest,
|
|
request: Request,
|
|
*,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> PortfolioTagResponse:
|
|
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
if _ownership_required(settings):
|
|
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
|
|
return service.add_tag(portfolio_id, payload)
|
|
except ValueError as exc:
|
|
_raise_portfolio_error(exc)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.get("/{portfolio_id}/tags", response_model=list[PortfolioTagResponse])
|
|
def list_tags(
|
|
portfolio_id: str,
|
|
request: Request,
|
|
*,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> list[PortfolioTagResponse]:
|
|
auth_context = authorize_request(Permission.READ, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
if _ownership_required(settings):
|
|
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
|
|
return service.list_tags(portfolio_id)
|
|
except ValueError as exc:
|
|
_raise_portfolio_error(exc)
|
|
finally:
|
|
_close_repository(repository)
|
|
|
|
|
|
@router.delete("/{portfolio_id}/tags", status_code=status.HTTP_204_NO_CONTENT)
|
|
def remove_tag(
|
|
portfolio_id: str,
|
|
request: Request,
|
|
payload: PortfolioTagMutationRequest = Body(...),
|
|
*,
|
|
settings: Settings = Depends(get_settings),
|
|
) -> Response:
|
|
auth_context = authorize_request(Permission.TRADE_WRITE, request, settings)
|
|
service, repository = _get_service(settings)
|
|
try:
|
|
if _ownership_required(settings):
|
|
_ensure_portfolio_owned(repository, portfolio_id, auth_context)
|
|
service.remove_tag(portfolio_id, payload)
|
|
except ValueError as exc:
|
|
_raise_portfolio_error(exc)
|
|
finally:
|
|
_close_repository(repository)
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|