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:
2026-08-15 12:56:53 +09:00
parent bb3bb5d3f0
commit e2102b6f2b
10 changed files with 861 additions and 7 deletions
+25
View File
@@ -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: