Files
portfolio-hub/app/api/v1_portfolios.py
T
genichinandClaude Opus 4.8 bd34450ee9 feat(pnl): trade history + realized PnL API (WAC) — BAT-017, REQ-039~042 Implemented
- GET /v1/portfolios/{id}/trades and GET /v1/portfolios/{id}/pnl/realized endpoints
- Weighted Average Cost (WAC) realized PnL calculation in app/domain/pnl.py
- cost_basis and data_completeness_warning returned in pnl API response
- Test coverage: 50 passed (WAC 20 + trades 12 + pnl 18), full 427 passed regression ✓
- WAC validated against 199,300 unit e2e test cases
- DB migration: none required
- QC: qc-reviewer PASS-WITH-NOTES; team lead confirm-batch-verification → release-candidate
- Remaining non-blocking: symbol filter edge case testing, HTTP_422 deprecation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
2026-06-18 16:45:01 +09:00

797 lines
30 KiB
Python

"""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.
"""
from __future__ import annotations
import json
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_realized_pnl
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,
RealizedPnlItem,
RealizedPnlResponse,
TagApplyRequest,
TagApplyResponse,
TagItem,
TradeHistoryItem,
TradeHistoryResponse,
)
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,
cash_balance_after=trade.cash_balance_after,
asset_quantity_after=trade.asset_quantity_after,
status=trade_status,
)
# ---------------------------------------------------------------------------
# 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,
)