"""v1 portfolio CRUD, tag, trade history and PnL endpoints. REQ-005, REQ-025~031: Portfolio CRUD, tags, comments. REQ-039: GET /v1/portfolios/{id}/trades — trade history with date filter. REQ-040: GET /v1/portfolios/{id}/pnl/realized — WAC realized PnL. REQ-043: GET /v1/portfolios/{id}/holdings/cost-basis — current holding WAC cost basis. """ from __future__ import annotations import logging import uuid 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.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 from app.schemas.v1 import ( LedgerTradeRequest, LedgerTradeResponse, ManualLedgerHistoryResponse, ManualLedgerRequest, ManualLedgerResponse, PortfolioCommentItem, PortfolioCommentListResponse, PortfolioCommentRequest, PortfolioCommentUpdateRequest, PortfolioCreateRequest, PortfolioItem, PortfolioListResponse, PortfolioReturnSummaryResponse, PortfolioTagListResponse, PortfolioUpdateRequest, HoldingsCostBasisResponse, RealizedPnlItem, RealizedPnlResponse, TagApplyRequest, TagApplyResponse, TagItem, TradeHistoryItem, TradeHistoryResponse, ) from app.services.strategy_set_service import StrategyInactiveError, StrategyNotFoundError 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, *, return_summary: dict[str, float] | None = None, ) -> PortfolioItem: summary = return_summary or {} 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, net_contributed_capital=summary.get("net_contributed_capital", 0.0), cumulative_profit_withdrawn=summary.get("cumulative_profit_withdrawn", 0.0), 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, # REQ-055: 전략·시장 메타데이터 additive 필드 active_strategy_id=portfolio.active_strategy_id, strategy_name=portfolio.strategy_name, strategy_params=portfolio.strategy_params, strategy_revision=portfolio.strategy_revision, markets=portfolio.markets, ) 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 def _get_owner_portfolio_or_404(auth: AuthContext, portfolio_id: str, repo) -> Portfolio: """Fetch a portfolio for APIs that are strictly owner-scoped. Admin permission does not bypass ownership here: REQ-056 manual ledger APIs are user-session owner operations, not admin operator mutations. """ try: portfolio = repo.get(portfolio_id) except ValueError: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}") if 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_strategy_set(body.strategy_id) if strategy_set is None: raise StrategyNotFoundError(f"strategy not found: {body.strategy_id}") if strategy_set.status != "active": raise StrategyInactiveError(f"strategy is inactive: {body.strategy_id}") 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 StrategyNotFoundError as exc: 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 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_id" in fields: strategy_id = str(fields.pop("strategy_id")) strategy_set = repo.get_strategy_set(strategy_id) if strategy_set is None: raise StrategyNotFoundError(f"strategy not found: {strategy_id}") if strategy_set.status != "active": raise StrategyInactiveError(f"strategy is inactive: {strategy_id}") fields["active_strategy_id"] = strategy_set.id fields["avabile_strategies"] = [strategy_set.id] repo.update_portfolio(portfolio_id, **fields) portfolio = repo.get(portfolio_id) except HTTPException: raise except StrategyNotFoundError as exc: 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 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, ) return_summaries = repo.get_return_summaries([portfolio.portfolio_id for portfolio in portfolios]) except HTTPException: raise finally: _close_repo(repo) items = [ _portfolio_to_item(p, return_summary=return_summaries.get(p.portfolio_id)) 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, cash_balance_after=trade.cash_balance_after, asset_quantity_after=trade.asset_quantity_after, status=trade_status, ) def _manual_entry_to_response(entry) -> ManualLedgerResponse: action = (entry.action or entry.side or "").upper() amount = entry.price if action in {"INVEST", "BUFFER_OUT", "BUFFER_IN", "WITHDRAW", "PROFIT"} else None return ManualLedgerResponse( ledger_entry_id=entry.ledger_entry_id, portfolio_id=entry.portfolio_id, action=action, amount=amount, quantity=entry.quantity, price=entry.price, fee=entry.fee_amount, tax=entry.tax_amount, note=entry.note, recorded_at=entry.recorded_at, cash_balance_after=entry.cash_balance_after, asset_quantity_after=entry.asset_quantity_after, profit_buffer_after=entry.profit_buffer_after, total_value_after=entry.total_value_after, source=entry.source or "manual", target_ledger_entry_id=entry.target_ledger_entry_id, confirmation_evidence=entry.confirmation_evidence, ) def _manual_error_code(message: str) -> str: if "LEDGER_ENTRY_NOT_FOUND" in message or "원장 항목을 찾을 수 없습니다" in message: return "LEDGER_ENTRY_NOT_FOUND" if "INSUFFICIENT_CASH" in message or "현금" in message: return "INSUFFICIENT_CASH" if "INSUFFICIENT_QUANTITY" in message or "수량" in message: return "INSUFFICIENT_QUANTITY" if "INSUFFICIENT_BUFFER" in message or "버퍼" in message: return "INSUFFICIENT_BUFFER" if "LEDGER_ENTRY_ALREADY_REVERSED" in message: return "LEDGER_ENTRY_ALREADY_REVERSED" return "MANUAL_LEDGER_CONFLICT" @router.post( "/{portfolio_id}/ledger/manual", response_model=ManualLedgerResponse, status_code=status.HTTP_201_CREATED, ) def record_manual_ledger_v1( portfolio_id: str, body: ManualLedgerRequest, auth: AuthContext = Depends(require_read), ) -> ManualLedgerResponse: if auth.token_type in {"service", "client"} or auth.user_id is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="manual ledger requires user/session token") repo = _get_repo() try: portfolio = _get_owner_portfolio_or_404(auth, portfolio_id, repo) if portfolio.status == "disabled": raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail={"code": "PORTFOLIO_DISABLED"}) if portfolio.managed_by_daemon_id and not body.confirm_daemon_managed_manual_write: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={ "code": "MANUAL_WRITE_REQUIRES_CONFIRMATION", "managed_by_daemon_id": portfolio.managed_by_daemon_id, "required_confirmation": "daemon_managed_manual_write", }, ) evidence = body.confirmation_evidence if portfolio.managed_by_daemon_id and body.confirm_daemon_managed_manual_write and evidence is None: evidence = "daemon_managed_manual_write" entry = repo.record_manual_ledger( portfolio_id, action=body.action, amount=float(body.amount) if body.amount is not None else None, quantity=float(body.quantity) if body.quantity is not None else None, price=float(body.price) if body.price is not None else None, fee=float(body.fee), tax=float(body.tax), target_ledger_entry_id=body.target_ledger_entry_id, note=body.note, actor_user_id=auth.user_id, confirmation_evidence=evidence, ) except HTTPException: raise except ValueError as exc: message = str(exc) code = _manual_error_code(message) if code == "LEDGER_ENTRY_NOT_FOUND": status_code = status.HTTP_404_NOT_FOUND else: status_code = status.HTTP_409_CONFLICT if code != "MANUAL_LEDGER_CONFLICT" else status.HTTP_422_UNPROCESSABLE_ENTITY raise HTTPException(status_code=status_code, detail={"code": code, "message": message}) from exc finally: _close_repo(repo) return _manual_entry_to_response(entry) @router.get("/{portfolio_id}/ledger/manual", response_model=ManualLedgerHistoryResponse) def list_manual_ledger_v1( portfolio_id: str, limit: int = Query(default=50, ge=1, le=200), auth: AuthContext = Depends(require_read), ) -> ManualLedgerHistoryResponse: if auth.token_type in {"service", "client"} or auth.user_id is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="manual ledger requires user/session token") repo = _get_repo() try: _get_owner_portfolio_or_404(auth, portfolio_id, repo) entries = repo.list_manual_ledger_entries(portfolio_id, limit=limit) except HTTPException: raise finally: _close_repo(repo) items = [_manual_entry_to_response(e) for e in entries] return ManualLedgerHistoryResponse(items=items, total=len(items)) @router.get("/{portfolio_id}/returns/summary", response_model=PortfolioReturnSummaryResponse) def get_portfolio_return_summary_v1( portfolio_id: str, auth: AuthContext = Depends(require_read), ) -> PortfolioReturnSummaryResponse: if auth.token_type in {"service", "client"} or auth.user_id is None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="return summary requires user/session token") repo = _get_repo() try: _get_owner_portfolio_or_404(auth, portfolio_id, repo) summary = repo.get_return_summary(portfolio_id) except HTTPException: raise finally: _close_repo(repo) return PortfolioReturnSummaryResponse(portfolio_id=portfolio_id, **summary) # --------------------------------------------------------------------------- # Helpers for date parameter parsing (REQ-039, REQ-040) # --------------------------------------------------------------------------- def _parse_date_param(value: str, param_name: str) -> datetime: """Parse YYYY-MM-DD string to UTC datetime (start of day). Raises 422 on bad format. Strictly requires YYYY-MM-DD with dashes (e.g. 2026-06-15). Python's date.fromisoformat() also accepts YYYYMMDD in 3.11+, so we explicitly validate the format first. """ import re if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"날짜 형식 오류: {param_name}은 YYYY-MM-DD 형식이어야 합니다. 입력값: {value!r}", ) try: d = date.fromisoformat(value) except ValueError: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"날짜 형식 오류: {param_name}은 YYYY-MM-DD 형식이어야 합니다. 입력값: {value!r}", ) return datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=timezone.utc) # --------------------------------------------------------------------------- # GET /v1/portfolios/{portfolio_id}/trades — REQ-039 # --------------------------------------------------------------------------- @router.get("/{portfolio_id}/trades", response_model=TradeHistoryResponse) def list_portfolio_trades( portfolio_id: str, date_param: str | None = Query(default=None, alias="date", description="단일 날짜 필터 (YYYY-MM-DD)"), from_param: str | None = Query(default=None, alias="from", description="범위 시작 날짜 (YYYY-MM-DD, 포함)"), to_param: str | None = Query(default=None, alias="to", description="범위 종료 날짜 (YYYY-MM-DD, 포함)"), auth: AuthContext = Depends(require_read), ) -> TradeHistoryResponse: """체결 내역 조회 — 날짜/범위 필터 (REQ-039). - `date`: YYYY-MM-DD 단일 날짜 - `from` + `to`: 날짜 범위 (경계 포함) - 날짜 파라미터 없으면 HTTP 422 """ if auth.token_type in ("service", "client"): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"{auth.token_type} token cannot query trade history", ) # Validate date params — AC-3: at least one of date or from/to required has_date = date_param is not None has_range = from_param is not None or to_param is not None if not has_date and not has_range: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="날짜 파라미터가 필요합니다: date 또는 from/to 중 하나를 지정하세요.", ) # If range is partially specified, require both if has_range and (from_param is None or to_param is None): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="from과 to 파라미터는 둘 다 필요합니다.", ) repo = _get_repo() try: _get_portfolio_or_404(auth, portfolio_id, repo) if has_date: date_dt = _parse_date_param(date_param, "date") # type: ignore[arg-type] trades = repo.list_trades_by_date(portfolio_id, date=date_dt) else: from_dt = _parse_date_param(from_param, "from") # type: ignore[arg-type] to_dt = _parse_date_param(to_param, "to") # type: ignore[arg-type] trades = repo.list_trades_by_date(portfolio_id, from_=from_dt, to_=to_dt) except HTTPException: raise finally: _close_repo(repo) items = [ TradeHistoryItem( ledger_entry_id=t.ledger_entry_id, portfolio_id=t.portfolio_id, order_id=t.order_id, execution_id=t.execution_id, side=t.side or "", quantity=t.quantity, price=t.price, fee_amount=t.fee_amount, tax_amount=t.tax_amount, executed_at=t.executed_at, # type: ignore[arg-type] recorded_at=t.recorded_at, ) for t in trades ] return TradeHistoryResponse(items=items, total=len(items)) # --------------------------------------------------------------------------- # GET /v1/portfolios/{portfolio_id}/pnl/realized — REQ-040 # --------------------------------------------------------------------------- @router.get("/{portfolio_id}/pnl/realized", response_model=RealizedPnlResponse) def get_realized_pnl( portfolio_id: str, from_param: str | None = Query(default=None, alias="from", description="집계 시작 날짜 (YYYY-MM-DD, 필수)"), to_param: str | None = Query(default=None, alias="to", description="집계 종료 날짜 (YYYY-MM-DD, 필수)"), symbol: str | None = Query(default=None, description="종목 필터 (선택)"), auth: AuthContext = Depends(require_read), ) -> RealizedPnlResponse: """실현 PnL 집계 조회 — WAC 방식 (REQ-040). - `from`, `to`: 집계 기간 (필수, 경계 포함) - `symbol`: 선택적 종목 필터 - 응답에 data_completeness_warning (이력 불완전 시), cost_basis_from 포함 """ if auth.token_type in ("service", "client"): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"{auth.token_type} token cannot query realized PnL", ) # AC-3: both from and to are required if from_param is None or to_param is None: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="from과 to 파라미터는 둘 다 필요합니다.", ) from_dt = _parse_date_param(from_param, "from") to_dt = _parse_date_param(to_param, "to") # to_dt is inclusive: extend to end of day (start of next day is used in WAC filter) to_dt_inclusive_end = to_dt + timedelta(days=1) repo = _get_repo() try: portfolio = _get_portfolio_or_404(auth, portfolio_id, repo) # Fetch all buy/sell history for this portfolio (WAC requires full history) ledger_entries = repo.get_all_trades_for_pnl(portfolio_id, symbol=symbol) except HTTPException: raise finally: _close_repo(repo) # Convert LedgerEntry objects to dicts for the domain function # symbol is taken from the portfolio (one portfolio = one symbol) portfolio_symbol = portfolio.symbol rows = [ { "side": e.side, "quantity": e.quantity, "price": e.price, "fee_amount": e.fee_amount, "tax_amount": e.tax_amount, "executed_at": e.executed_at, "order_id": e.order_id, "execution_id": e.execution_id, # symbol is set from portfolio; symbol filter applied in WAC function "symbol": portfolio_symbol if symbol is None else (portfolio_symbol if portfolio_symbol == symbol else None), } for e in ledger_entries ] result = calculate_realized_pnl(rows, from_dt=from_dt, to_dt=to_dt_inclusive_end, symbol=symbol) pnl_items = [ RealizedPnlItem( symbol=item.symbol, executed_at=item.executed_at, quantity=item.quantity, sell_price=item.sell_price, cost_basis_per_unit=item.cost_basis_per_unit, fee_amount=item.fee_amount, tax_amount=item.tax_amount, realized_pnl=item.realized_pnl, order_id=item.order_id, execution_id=item.execution_id, ) for item in result.items ] return RealizedPnlResponse( total_realized_pnl=result.total_realized_pnl, items=pnl_items, cost_basis_from=result.cost_basis_from, data_completeness_warning=result.data_completeness_warning, ) # --------------------------------------------------------------------------- # GET /v1/portfolios/{portfolio_id}/holdings/cost-basis — REQ-043 # --------------------------------------------------------------------------- @router.get("/{portfolio_id}/holdings/cost-basis", response_model=HoldingsCostBasisResponse) def get_holdings_cost_basis( portfolio_id: str, auth: AuthContext = Depends(require_read), ) -> HoldingsCostBasisResponse: """현재 보유 평단(WAC) 조회 — 미매도 보유분의 cost basis (REQ-043). - service token: daemon_id == portfolio.managed_by_daemon_id 일치 시만 허용 (불일치/null → 403). - user token: 소유자 또는 admin만 허용 (비소유자 → 403). - 존재하지 않는 portfolio_id → 404. - 보유수량 0인 경우 → 200 + current_quantity=0, cost_basis_per_unit=null, total_cost_basis=null. """ repo = _get_repo() try: # Fetch portfolio (existence check) try: portfolio = repo.get(portfolio_id) except ValueError: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}", ) # Ownership / scope check if auth.token_type == "service": # daemon scope: daemon_id must match managed_by_daemon_id if portfolio.managed_by_daemon_id is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="daemon 미배정 portfolio에 daemon 접근 불가", ) if auth.daemon_id is None or auth.daemon_id != portfolio.managed_by_daemon_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Token daemon does not match portfolio daemon assignment", ) else: # user/admin token: non-admin must own the portfolio 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", ) # Fetch all ledger history for WAC replay ledger_entries = repo.get_all_trades_for_pnl(portfolio_id, symbol=None) except HTTPException: raise finally: _close_repo(repo) # Build rows for domain function rows = [ { "side": e.side, "quantity": e.quantity, "price": e.price, "fee_amount": e.fee_amount, "executed_at": e.executed_at, } for e in ledger_entries ] wac_result = calculate_current_wac(rows) current_quantity = portfolio.asset_quantity cost_basis_per_unit = wac_result.current_wac if current_quantity > 0 else None total_cost_basis = ( cost_basis_per_unit * current_quantity if cost_basis_per_unit is not None and current_quantity > 0 else None ) return HoldingsCostBasisResponse( portfolio_id=portfolio_id, symbol=portfolio.symbol, current_quantity=current_quantity, cost_basis_per_unit=cost_basis_per_unit, total_cost_basis=total_cost_basis, cost_basis_from=wac_result.cost_basis_from, data_completeness_warning=wac_result.data_completeness_warning, )