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
This commit is contained in:
2026-06-18 16:45:01 +09:00
co-authored by Claude Opus 4.8
parent f37ef87c8c
commit bd34450ee9
19 changed files with 2034 additions and 10 deletions
+208 -1
View File
@@ -1,15 +1,22 @@
"""v1 portfolio CRUD and tag endpoints (REQ-005, REQ-025, REQ-026, REQ-027, REQ-028)."""
"""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,
@@ -23,9 +30,13 @@ from app.schemas.v1 import (
PortfolioListResponse,
PortfolioTagListResponse,
PortfolioUpdateRequest,
RealizedPnlItem,
RealizedPnlResponse,
TagApplyRequest,
TagApplyResponse,
TagItem,
TradeHistoryItem,
TradeHistoryResponse,
)
logger = logging.getLogger(__name__)
@@ -587,3 +598,199 @@ def record_ledger_trade_v1(
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,
)