"""v1 portfolio CRUD and tag endpoints (REQ-005, REQ-025, REQ-026, REQ-027, REQ-028).""" from __future__ import annotations import json import logging import uuid from fastapi import APIRouter, Depends, HTTPException, Query, Response, status from app.core.auth import AuthContext, Permission, require_read, require_trade_write from app.domain.models import Portfolio, PortfolioComment from app.repositories.factory import get_portfolio_repository_from_settings from app.schemas.v1 import ( LedgerTradeRequest, LedgerTradeResponse, PortfolioCommentItem, PortfolioCommentListResponse, PortfolioCommentRequest, PortfolioCommentUpdateRequest, PortfolioCreateRequest, PortfolioItem, PortfolioListResponse, PortfolioTagListResponse, PortfolioUpdateRequest, TagApplyRequest, TagApplyResponse, TagItem, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/v1/portfolios", tags=["v1"]) def _get_repo(settings=None): from app.core.config import get_settings return get_portfolio_repository_from_settings(settings or get_settings()) def _close_repo(repo) -> None: if callable(getattr(type(repo), "close", None)): repo.close() def _require_write_token(auth: AuthContext) -> None: if auth.token_type in ("service", "client"): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"{auth.token_type} token cannot create or modify portfolios", ) def _portfolio_to_item(portfolio: Portfolio) -> PortfolioItem: return PortfolioItem( portfolio_id=portfolio.portfolio_id, asset_id=portfolio.asset_id, symbol=portfolio.symbol, display_name=portfolio.display_name, owner_user_id=portfolio.owner_user_id, broker=portfolio.broker, status=portfolio.status, name=portfolio.name, cash_balance=portfolio.cash_balance, asset_quantity=portfolio.asset_quantity, price=portfolio.price, profit_buffer=portfolio.profit_buffer, tag_ids=[str(t) for t in (portfolio.tag_ids or [])], managed_by_daemon_id=portfolio.managed_by_daemon_id, created_at=portfolio.created_at, updated_at=portfolio.updated_at, ) def _comment_to_item(comment: PortfolioComment) -> PortfolioCommentItem: return PortfolioCommentItem( comment_id=comment.id, portfolio_id=comment.portfolio_id, content=comment.content, created_at=comment.created_at, updated_at=comment.updated_at, ) def _get_portfolio_or_404(auth: AuthContext, portfolio_id: str, repo) -> Portfolio: try: portfolio = repo.get(portfolio_id) except ValueError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}") if auth.permission != Permission.ADMIN and portfolio.owner_user_id != auth.user_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch") return portfolio # --------------------------------------------------------------------------- # POST /v1/portfolios — REQ-025 # --------------------------------------------------------------------------- @router.post("", response_model=PortfolioItem, status_code=status.HTTP_201_CREATED) def create_portfolio_v1( body: PortfolioCreateRequest, auth: AuthContext = Depends(require_read), ) -> PortfolioItem: _require_write_token(auth) owner_user_id: str = ( body.user_id if (auth.permission == Permission.ADMIN and body.user_id) else auth.user_id ) or "system" repo = _get_repo() try: asset = repo.get_asset_by_id(body.asset_id) if asset is None or asset.status != "active": raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"asset not found or inactive: {body.asset_id}", ) name = body.name or f"{body.broker}-{body.asset_id[:8]}" strategy_set = repo.get_or_create_strategy_set( body.strategy_name or "target_value", json.dumps(body.strategy_params or {}, sort_keys=True, ensure_ascii=True), ) portfolio = repo.create_portfolio( str(uuid.uuid4()), owner_user_id=owner_user_id, broker=body.broker, status="enabled", name=name, asset_id=body.asset_id, cash_balance=body.cash_balance, asset_quantity=body.asset_quantity, asset_price=body.asset_price, profit_buffer=body.profit_buffer, active_strategy_id=strategy_set.id, avabile_strategies=[strategy_set.id], ) except HTTPException: raise except ValueError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc finally: _close_repo(repo) return _portfolio_to_item(portfolio) # --------------------------------------------------------------------------- # PATCH /v1/portfolios/{portfolio_id} — REQ-026 # --------------------------------------------------------------------------- @router.patch("/{portfolio_id}", response_model=PortfolioItem) def update_portfolio_v1( portfolio_id: str, body: PortfolioUpdateRequest, auth: AuthContext = Depends(require_read), ) -> PortfolioItem: _require_write_token(auth) repo = _get_repo() try: portfolio = _get_portfolio_or_404(auth, portfolio_id, repo) if portfolio.status == "disabled": non_status_fields = {k for k, v in body.model_dump(exclude_none=True).items() if k != "status"} if non_status_fields: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio: only status restoration is allowed", ) fields = body.model_dump(exclude_none=True) if fields: if "strategy_name" in fields or "strategy_params" in fields: strategy_set = repo.get_or_create_strategy_set( fields.pop("strategy_name", None) or "target_value", json.dumps(fields.pop("strategy_params", None) or {}, sort_keys=True, ensure_ascii=True), ) fields["active_strategy_id"] = strategy_set.id fields["avabile_strategies"] = [strategy_set.id] else: fields.pop("strategy_name", None) fields.pop("strategy_params", None) repo.update_portfolio(portfolio_id, **fields) portfolio = repo.get(portfolio_id) except HTTPException: raise except ValueError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc finally: _close_repo(repo) return _portfolio_to_item(portfolio) # --------------------------------------------------------------------------- # DELETE /v1/portfolios/{portfolio_id} — REQ-027 # --------------------------------------------------------------------------- @router.delete("/{portfolio_id}", response_model=PortfolioItem) def delete_portfolio_v1( portfolio_id: str, auth: AuthContext = Depends(require_read), ) -> PortfolioItem: _require_write_token(auth) repo = _get_repo() try: _get_portfolio_or_404(auth, portfolio_id, repo) portfolio = repo.soft_delete_portfolio(portfolio_id) except HTTPException: raise except ValueError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc finally: _close_repo(repo) return _portfolio_to_item(portfolio) # --------------------------------------------------------------------------- # GET /v1/portfolios — REQ-028 # --------------------------------------------------------------------------- @router.get("", response_model=PortfolioListResponse) def list_portfolios_v1( market: str | None = Query(default=None), include_disabled: bool = Query(default=False), user_id: str | None = Query(default=None), auth: AuthContext = Depends(require_read), ) -> PortfolioListResponse: if auth.token_type == "client": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="client token cannot list portfolios") status_filter: str | None = None if include_disabled else "enabled" repo = _get_repo() try: if auth.token_type == "service": if auth.daemon_id is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="service token without daemon_id cannot list portfolios", ) portfolios = repo.list_portfolios( managed_by_daemon_id=auth.daemon_id, market=market, status=status_filter, ) else: if auth.permission == Permission.ADMIN and user_id is not None: owner = user_id else: owner = auth.user_id portfolios = repo.list_portfolios( owner_user_id=owner, market=market, status=status_filter, ) except HTTPException: raise finally: _close_repo(repo) items = [_portfolio_to_item(p) for p in portfolios] return PortfolioListResponse(portfolios=items, total=len(items)) @router.post("/{portfolio_id}/tags", response_model=TagApplyResponse) def apply_portfolio_tag( portfolio_id: str, body: TagApplyRequest, auth: AuthContext = Depends(require_trade_write), ) -> TagApplyResponse: if auth.token_source != "db": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required") repo = _get_repo() try: try: portfolio = repo.get(portfolio_id) except ValueError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}") is_admin = auth.permission == Permission.ADMIN is_owner = portfolio.owner_user_id == auth.user_id if not is_admin and not is_owner: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch") _require_write_token(auth) if portfolio.status == "disabled": raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio") tag = repo.get_tag_by_id(body.tag_id) if tag is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"태그를 찾을 수 없습니다: {body.tag_id}") if not tag.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="비활성화된 태그는 적용할 수 없습니다") if not is_admin and not tag.is_admin_tag and tag.created_by_user_id != portfolio.owner_user_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="태그 적용 정책을 위반했습니다") repo.append_portfolio_tag_id(portfolio_id, body.tag_id) except HTTPException: raise except ValueError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc finally: _close_repo(repo) return TagApplyResponse(tag_id=body.tag_id, status="applied") @router.delete("/{portfolio_id}/tags/{tag_id}", response_model=TagApplyResponse) def remove_portfolio_tag( portfolio_id: str, tag_id: str, auth: AuthContext = Depends(require_trade_write), ) -> TagApplyResponse: if auth.token_source != "db": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="DB token required") repo = _get_repo() try: try: portfolio = repo.get(portfolio_id) except ValueError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}") is_admin = auth.permission == Permission.ADMIN is_owner = portfolio.owner_user_id == auth.user_id if not is_admin and not is_owner: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Portfolio ownership mismatch") _require_write_token(auth) if portfolio.status == "disabled": raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio") repo.remove_portfolio_tag_id(portfolio_id, tag_id) except HTTPException: raise except ValueError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc finally: _close_repo(repo) return TagApplyResponse(tag_id=tag_id, status="removed") # --------------------------------------------------------------------------- # GET /v1/portfolios/{portfolio_id}/tags — REQ-030 # --------------------------------------------------------------------------- @router.get("/{portfolio_id}/tags", response_model=PortfolioTagListResponse) def get_portfolio_tags( portfolio_id: str, auth: AuthContext = Depends(require_read), ) -> PortfolioTagListResponse: if auth.token_type in ("service", "client"): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"{auth.token_type} token cannot access portfolio tags", ) repo = _get_repo() try: portfolio = _get_portfolio_or_404(auth, portfolio_id, repo) tags_dict = repo.get_tags_by_ids([str(t) for t in (portfolio.tag_ids or [])]) tag_items = [ TagItem( tag_id=t.tag_id, name=t.name, is_admin_tag=t.is_admin_tag, is_active=t.is_active, created_at=t.created_at, ) for t in tags_dict.values() ] except HTTPException: raise finally: _close_repo(repo) return PortfolioTagListResponse(tags=tag_items, total=len(tag_items)) # --------------------------------------------------------------------------- # POST /v1/portfolios/{portfolio_id}/comments — REQ-031 # --------------------------------------------------------------------------- @router.post("/{portfolio_id}/comments", response_model=PortfolioCommentItem, status_code=status.HTTP_201_CREATED) def create_portfolio_comment( portfolio_id: str, body: PortfolioCommentRequest, auth: AuthContext = Depends(require_read), ) -> PortfolioCommentItem: _require_write_token(auth) repo = _get_repo() try: portfolio = _get_portfolio_or_404(auth, portfolio_id, repo) if portfolio.status == "disabled": raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio") comment = repo.create_comment(portfolio_id, content=body.content) except HTTPException: raise except ValueError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc finally: _close_repo(repo) return _comment_to_item(comment) # --------------------------------------------------------------------------- # GET /v1/portfolios/{portfolio_id}/comments — REQ-031 # --------------------------------------------------------------------------- @router.get("/{portfolio_id}/comments", response_model=PortfolioCommentListResponse) def list_portfolio_comments( portfolio_id: str, auth: AuthContext = Depends(require_read), ) -> PortfolioCommentListResponse: if auth.token_type in ("service", "client"): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"{auth.token_type} token cannot access portfolio comments", ) repo = _get_repo() try: _get_portfolio_or_404(auth, portfolio_id, repo) comments = repo.list_comments(portfolio_id) except HTTPException: raise finally: _close_repo(repo) items = [_comment_to_item(c) for c in comments] return PortfolioCommentListResponse(comments=items, total=len(items)) # --------------------------------------------------------------------------- # PATCH /v1/portfolios/{portfolio_id}/comments/{comment_id} — REQ-031 # --------------------------------------------------------------------------- @router.patch("/{portfolio_id}/comments/{comment_id}", response_model=PortfolioCommentItem) def update_portfolio_comment( portfolio_id: str, comment_id: str, body: PortfolioCommentUpdateRequest, auth: AuthContext = Depends(require_read), ) -> PortfolioCommentItem: _require_write_token(auth) repo = _get_repo() try: portfolio = _get_portfolio_or_404(auth, portfolio_id, repo) if portfolio.status == "disabled": raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio") comment = repo.update_comment(portfolio_id, comment_id, content=body.content) except HTTPException: raise except ValueError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"댓글을 찾을 수 없습니다: {comment_id}") finally: _close_repo(repo) return _comment_to_item(comment) # --------------------------------------------------------------------------- # DELETE /v1/portfolios/{portfolio_id}/comments/{comment_id} — REQ-031 # --------------------------------------------------------------------------- @router.delete("/{portfolio_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_portfolio_comment( portfolio_id: str, comment_id: str, auth: AuthContext = Depends(require_read), ) -> None: _require_write_token(auth) repo = _get_repo() try: portfolio = _get_portfolio_or_404(auth, portfolio_id, repo) if portfolio.status == "disabled": raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio") repo.delete_comment(portfolio_id, comment_id) except HTTPException: raise except ValueError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"댓글을 찾을 수 없습니다: {comment_id}") finally: _close_repo(repo) # --------------------------------------------------------------------------- # POST /v1/portfolios/{portfolio_id}/ledger/trades — REQ-029 # --------------------------------------------------------------------------- def _require_service_token(auth: AuthContext = Depends(require_trade_write)) -> AuthContext: if auth.token_source != "db" or auth.token_type != "service": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Service token required", ) if auth.daemon_id is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="daemon_id not assigned to this token", ) return auth def _payloads_match(existing, body: LedgerTradeRequest) -> bool: return ( existing.side == body.side and abs(existing.quantity - float(body.quantity)) < 1e-9 and abs(existing.price - float(body.price)) < 1e-9 and abs(existing.fee_amount - float(body.fee_amount)) < 1e-9 and abs(existing.tax_amount - float(body.tax_amount)) < 1e-9 ) @router.post( "/{portfolio_id}/ledger/trades", response_model=LedgerTradeResponse, status_code=status.HTTP_201_CREATED, ) def record_ledger_trade_v1( portfolio_id: str, body: LedgerTradeRequest, response: Response, auth: AuthContext = Depends(_require_service_token), ) -> LedgerTradeResponse: repo = _get_repo() try: try: portfolio = repo.get(portfolio_id) except Exception: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}", ) if portfolio.status == "disabled": raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="disabled portfolio") if portfolio.managed_by_daemon_id is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Asset is not assigned to any daemon", ) if portfolio.managed_by_daemon_id != auth.daemon_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Token daemon does not match asset daemon assignment", ) trade, created = repo.insert_ledger_trade( portfolio_id=portfolio_id, order_id=body.order_id, execution_id=body.execution_id, side=body.side, quantity=float(body.quantity), price=float(body.price), executed_at=body.executed_at, fee_amount=float(body.fee_amount), tax_amount=float(body.tax_amount), ) except HTTPException: raise finally: _close_repo(repo) if not created: if not _payloads_match(trade, body): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Duplicate key with different payload", ) response.status_code = status.HTTP_200_OK trade_status = "duplicate" else: trade_status = "new" return LedgerTradeResponse( ledger_entry_id=trade.ledger_entry_id, portfolio_id=trade.portfolio_id, order_id=trade.order_id, execution_id=trade.execution_id, side=trade.side, quantity=trade.quantity, price=trade.price, fee_amount=trade.fee_amount, tax_amount=trade.tax_amount, executed_at=trade.executed_at, recorded_at=trade.recorded_at, status=trade_status, )