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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user