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
+37 -3
View File
@@ -13,7 +13,7 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.errors import StrategyInactiveError, StrategyNotFoundError
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError, StrategyInactiveError, StrategyNotFoundError
from app.domain.models import Asset, AuthToken, BrokerCredential, Daemon, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, Tag, TokenPrincipal, User
logger = logging.getLogger(__name__)
@@ -159,6 +159,26 @@ class PostgresPortfolioRepository:
if row[0] != "active":
raise StrategyInactiveError(f"strategy is inactive: {strategy_id}")
@staticmethod
def _lock_active_daemon(cur: psycopg.Cursor, daemon_id: str, owner_user_id: str) -> None:
"""Serialize new portfolio assignments with daemon deactivation/owner transfer.
Locks the daemon row FOR SHARE inside the portfolio write transaction and
enforces existence, same-owner, and active status so validation and write
form a single atomic decision boundary.
"""
cur.execute(
"SELECT owner_user_id, is_active FROM daemons WHERE id = %s FOR SHARE",
(daemon_id,),
)
row = cur.fetchone()
if row is None:
raise DaemonNotFoundError(f"daemon not found: {daemon_id}")
if row[0] != owner_user_id:
raise DaemonNotFoundError(f"daemon not found: {daemon_id}")
if not row[1]:
raise DaemonInactiveError(f"daemon is inactive: {daemon_id}")
def ensure_schema(self) -> None:
conn = self._connect()
with conn.cursor() as cur:
@@ -1128,12 +1148,15 @@ class PostgresPortfolioRepository:
profit_buffer: float,
active_strategy_id: str | None,
avabile_strategies: list[str] | None,
managed_by_daemon_id: str | None = None,
) -> Portfolio:
self.ensure_schema()
conn = self._connect()
with conn.transaction(), conn.cursor() as cur:
if active_strategy_id is not None:
self._lock_active_strategy(cur, active_strategy_id)
if managed_by_daemon_id is not None:
self._lock_active_daemon(cur, managed_by_daemon_id, owner_user_id)
cur.execute("SELECT 1 FROM portfolios WHERE portfolio_id = %s", (portfolio_id,))
if cur.fetchone() is not None:
raise ValueError(f"이미 존재하는 포트폴리오입니다: {portfolio_id}")
@@ -1142,8 +1165,8 @@ class PostgresPortfolioRepository:
INSERT INTO portfolios (
portfolio_id, broker, status, name, asset_id,
owner_user_id, cash_balance, asset_quantity, asset_price, profit_buffer,
active_strategy_id, avabile_strategies
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
active_strategy_id, avabile_strategies, managed_by_daemon_id
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
portfolio_id,
@@ -1158,6 +1181,7 @@ class PostgresPortfolioRepository:
float(profit_buffer),
active_strategy_id,
avabile_strategies,
managed_by_daemon_id,
),
)
portfolio = self.get(portfolio_id)
@@ -1195,6 +1219,16 @@ class PostgresPortfolioRepository:
strategy_id = fields.get("active_strategy_id")
if isinstance(strategy_id, str):
self._lock_active_strategy(cur, strategy_id)
daemon_id = fields.get("managed_by_daemon_id")
if isinstance(daemon_id, str):
cur.execute(
"SELECT owner_user_id FROM portfolios WHERE portfolio_id = %s",
(portfolio_id,),
)
owner_row = cur.fetchone()
if owner_row is None:
raise ValueError(f"portfolio를 찾을 수 없습니다: {portfolio_id}")
self._lock_active_daemon(cur, daemon_id, owner_row[0])
cur.execute(
f"""
UPDATE portfolios