feat(portfolios): persist and validate daemon assignment on create/update
POST /v1/portfolios now forwards a non-null managed_by_daemon_id into create persistence, and both create and PATCH apply the same daemon validation contract (UUID format -> 422, missing/foreign -> 404, inactive -> 409, admin target-owner basis). Daemon validation locks the daemon row FOR SHARE inside the portfolio write transaction so validation and write form one atomic decision boundary. Adds domain errors, repository protocol/signature updates, API/PostgreSQL tests (POST->GET persistence, no partial state on failure, assignment vs deactivation race), and contract/data-model documentation.
This commit is contained in:
@@ -15,6 +15,7 @@ from datetime import date, datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
|
||||
from app.core.auth import AuthContext, Permission, require_read, require_trade_write
|
||||
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError
|
||||
from app.domain.models import Portfolio, PortfolioComment
|
||||
from app.domain.pnl import calculate_current_wac, calculate_realized_pnl
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
@@ -67,6 +68,18 @@ def _require_write_token(auth: AuthContext) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _require_valid_daemon_uuid(daemon_id: str | None) -> None:
|
||||
if daemon_id is None:
|
||||
return
|
||||
try:
|
||||
uuid.UUID(daemon_id)
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"managed_by_daemon_id는 UUID 형식이어야 합니다: {daemon_id}",
|
||||
)
|
||||
|
||||
|
||||
def _portfolio_to_item(
|
||||
portfolio: Portfolio,
|
||||
*,
|
||||
@@ -167,6 +180,8 @@ def create_portfolio_v1(
|
||||
if strategy_set.status != "active":
|
||||
raise StrategyInactiveError(f"strategy is inactive: {body.strategy_id}")
|
||||
|
||||
_require_valid_daemon_uuid(body.managed_by_daemon_id)
|
||||
|
||||
portfolio = repo.create_portfolio(
|
||||
str(uuid.uuid4()),
|
||||
owner_user_id=owner_user_id,
|
||||
@@ -180,6 +195,7 @@ def create_portfolio_v1(
|
||||
profit_buffer=body.profit_buffer,
|
||||
active_strategy_id=strategy_set.id,
|
||||
avabile_strategies=[strategy_set.id],
|
||||
managed_by_daemon_id=body.managed_by_daemon_id,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -187,6 +203,10 @@ def create_portfolio_v1(
|
||||
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 DaemonNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
except DaemonInactiveError 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:
|
||||
@@ -230,6 +250,7 @@ def update_portfolio_v1(
|
||||
raise StrategyInactiveError(f"strategy is inactive: {strategy_id}")
|
||||
fields["active_strategy_id"] = strategy_set.id
|
||||
fields["avabile_strategies"] = [strategy_set.id]
|
||||
_require_valid_daemon_uuid(fields.get("managed_by_daemon_id"))
|
||||
repo.update_portfolio(portfolio_id, **fields)
|
||||
|
||||
portfolio = repo.get(portfolio_id)
|
||||
@@ -239,6 +260,10 @@ def update_portfolio_v1(
|
||||
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 DaemonNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
except DaemonInactiveError 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:
|
||||
|
||||
Reference in New Issue
Block a user