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:
+208
-1
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user