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,
)
+179
View File
@@ -0,0 +1,179 @@
"""WAC (weighted average cost) based realized PnL calculation (REQ-041).
This module contains pure Python domain logic with no repository or DB dependency.
All inputs are plain dicts or dataclasses; no database driver imports are used here.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
@dataclass
class PnlItem:
"""Realized PnL for a single sell trade."""
symbol: str | None
executed_at: datetime
quantity: float
sell_price: float
cost_basis_per_unit: float
fee_amount: float
tax_amount: float
realized_pnl: float
order_id: str | None = None
execution_id: str | None = None
@dataclass
class WacPnlResult:
"""Result of WAC realized PnL calculation over a set of ledger rows."""
items: list[PnlItem] = field(default_factory=list)
total_realized_pnl: float = 0.0
cost_basis_from: datetime | None = None
data_completeness_warning: str | None = None
def calculate_realized_pnl(
rows: list[dict[str, Any]],
*,
from_dt: datetime | None = None,
to_dt: datetime | None = None,
symbol: str | None = None,
) -> WacPnlResult:
"""Calculate WAC-based realized PnL from a time-ordered list of ledger rows.
Args:
rows: List of portfolio_ledger row dicts, each containing:
side (str), quantity (float), price (float),
fee_amount (float), tax_amount (float),
executed_at (datetime), order_id (str|None),
execution_id (str|None), symbol (str|None, optional).
Must be sorted by executed_at ASC.
from_dt: Inclusive lower bound for sell trade date filter (UTC datetime).
If None, no lower bound is applied.
to_dt: Inclusive upper bound for sell trade date filter (UTC datetime).
If None, no upper bound is applied.
symbol: Optional symbol filter for sell trades. Ignored if None.
Returns:
WacPnlResult with per-sell PnL items, total, cost_basis_from,
and data_completeness_warning when history is incomplete.
WAC Rules (REQ-041):
buy: running_wac = (running_wac * running_qty + price * qty) / (running_qty + qty)
running_qty += qty
sell: realized_pnl = (sell_price - running_wac) * qty - fee - tax
running_qty -= qty
Incomplete history (running_qty < sell_qty or sell with running_qty=0):
data_completeness_warning is set; calculation continues with best estimate.
"""
if not rows:
return WacPnlResult()
running_wac: float = 0.0
running_qty: float = 0.0
cost_basis_from: datetime | None = None
warning_triggered: bool = False
items: list[PnlItem] = []
total_pnl: float = 0.0
for row in rows:
side = str(row.get("side", "")).lower()
qty = float(row.get("quantity", 0.0))
price = float(row.get("price", 0.0))
fee = float(row.get("fee_amount", 0.0))
tax = float(row.get("tax_amount", 0.0))
executed_at: datetime | None = row.get("executed_at")
row_symbol: str | None = row.get("symbol")
order_id: str | None = row.get("order_id")
execution_id: str | None = row.get("execution_id")
if side == "buy":
# Update running WAC
new_total_qty = running_qty + qty
if new_total_qty > 0:
running_wac = (running_wac * running_qty + price * qty) / new_total_qty
running_qty = new_total_qty
# Track earliest buy for cost_basis_from
if cost_basis_from is None and executed_at is not None:
cost_basis_from = executed_at
elif side == "sell":
# Check for incomplete history
if running_qty <= 0:
warning_triggered = True
elif qty > running_qty + 1e-9:
warning_triggered = True
# Apply date filter for sell trades (all buy rows participate in WAC regardless)
in_range = True
if from_dt is not None and executed_at is not None:
# Make both timezone-aware or both naive for comparison
exec_dt = _normalize_tz(executed_at, from_dt)
if exec_dt < from_dt:
in_range = False
if to_dt is not None and executed_at is not None and in_range:
exec_dt_to = _normalize_tz(executed_at, to_dt)
if exec_dt_to > to_dt:
in_range = False
# Apply symbol filter
if symbol is not None and row_symbol is not None and row_symbol != symbol:
in_range = False
if in_range:
realized = (price - running_wac) * qty - fee - tax
total_pnl += realized
items.append(
PnlItem(
symbol=row_symbol,
executed_at=executed_at, # type: ignore[arg-type]
quantity=qty,
sell_price=price,
cost_basis_per_unit=running_wac,
fee_amount=fee,
tax_amount=tax,
realized_pnl=realized,
order_id=order_id,
execution_id=execution_id,
)
)
# Update running qty regardless of date filter (WAC state must be correct)
running_qty -= qty
if running_qty < 0:
running_qty = 0.0
warning: str | None = None
if warning_triggered:
warning = (
"이력 불완전: 보유 수량보다 많은 매도 또는 buy 이력 없는 sell이 감지되어 "
"cost_basis 산출이 부정확할 수 있습니다. "
"portfolio_ledger 이력이 포트폴리오 생성 초기부터 완전한지 확인하세요."
)
return WacPnlResult(
items=items,
total_realized_pnl=total_pnl,
cost_basis_from=cost_basis_from,
data_completeness_warning=warning,
)
def _normalize_tz(dt: datetime, reference: datetime) -> datetime:
"""Align timezone-awareness of dt to match reference.
If reference is timezone-aware and dt is naive, treat dt as UTC.
If reference is naive and dt is aware, strip tzinfo.
"""
import datetime as _dt_module
if reference.tzinfo is not None and dt.tzinfo is None:
return dt.replace(tzinfo=_dt_module.timezone.utc)
if reference.tzinfo is None and dt.tzinfo is not None:
return dt.replace(tzinfo=None)
return dt
+16
View File
@@ -250,6 +250,22 @@ class PortfolioRepository(Protocol):
def list_recent_trades(self, portfolio_id: str, limit: int = 5) -> list[LedgerEntry]: ...
def list_trades_by_date(
self,
portfolio_id: str,
*,
date: datetime | None = None,
from_: datetime | None = None,
to_: datetime | None = None,
) -> list[LedgerEntry]: ...
def get_all_trades_for_pnl(
self,
portfolio_id: str,
*,
symbol: str | None = None,
) -> list[LedgerEntry]: ...
def insert_ledger_trade(
self,
*,
+96
View File
@@ -1337,6 +1337,102 @@ class PostgresPortfolioRepository:
rows = cur.fetchall()
return [self._row_to_ledger_entry(row) for row in rows]
def list_trades_by_date(
self,
portfolio_id: str,
*,
date: "datetime | None" = None,
from_: "datetime | None" = None,
to_: "datetime | None" = None,
) -> list[LedgerEntry]:
"""Return ledger entries for portfolio_id filtered by date range (REQ-039).
Either `date` (single day) or `from_`/`to_` (range, both inclusive) must be
provided. The caller is responsible for converting date strings to datetime
boundaries (start of day / start of next day).
"""
self.ensure_schema()
clauses = ["portfolio_id = %s"]
params: list[object] = [portfolio_id]
if date is not None:
# Single day: [date 00:00:00, date+1day 00:00:00)
clauses.append("executed_at >= %s")
params.append(date)
import datetime as _dt
next_day = date + _dt.timedelta(days=1)
clauses.append("executed_at < %s")
params.append(next_day)
else:
if from_ is not None:
clauses.append("executed_at >= %s")
params.append(from_)
if to_ is not None:
import datetime as _dt
# to_ is inclusive — extend to end of that day
next_day = to_ + _dt.timedelta(days=1)
clauses.append("executed_at < %s")
params.append(next_day)
where = "WHERE " + " AND ".join(clauses)
with self._connect().cursor() as cur:
cur.execute(
f"""
SELECT
ledger_entry_id, portfolio_id, order_id, execution_id, side,
quantity::float8, price::float8,
fee_amount::float8, tax_amount::float8,
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note
FROM portfolio_ledger
{where}
ORDER BY executed_at ASC, ledger_entry_id ASC
""",
tuple(params),
)
rows = cur.fetchall()
return [self._row_to_ledger_entry(row) for row in rows]
def get_all_trades_for_pnl(
self,
portfolio_id: str,
*,
symbol: str | None = None,
) -> list[LedgerEntry]:
"""Return all buy/sell ledger entries for portfolio_id for WAC PnL calculation (REQ-040/041).
Returns ALL buy/sell history (not bounded by from_/to_) so that the WAC
calculation is accurate. The caller (domain layer) applies the date filter
to sell events when collecting realized PnL items.
portfolio_ledger does not store symbol per row — symbol filtering is handled
at the portfolio level (one portfolio = one symbol). The `symbol` parameter
is forwarded to the domain layer via the returned rows; it is not used in SQL.
"""
self.ensure_schema()
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT
ledger_entry_id, portfolio_id, order_id, execution_id, side,
quantity::float8, price::float8,
fee_amount::float8, tax_amount::float8,
executed_at, recorded_at,
cash_balance_after::float8, asset_quantity_after::float8,
total_value_after::float8, profit_buffer_after::float8,
strategy_set_id, note
FROM portfolio_ledger
WHERE portfolio_id = %s
AND side IN ('buy', 'sell')
ORDER BY executed_at ASC, ledger_entry_id ASC
""",
(portfolio_id,),
)
rows = cur.fetchall()
return [self._row_to_ledger_entry(row) for row in rows]
def insert_ledger_trade(
self,
*,
+48 -1
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime
from datetime import date, datetime
from decimal import Decimal
from typing import Any, Literal
@@ -249,3 +249,50 @@ class AssetCatalogResponse(BaseModel):
class AssetCatalogListResponse(BaseModel):
items: list[AssetCatalogResponse]
total: int
# ---------------------------------------------------------------------------
# GET /v1/portfolios/{id}/trades (REQ-039)
# ---------------------------------------------------------------------------
class TradeHistoryItem(BaseModel):
ledger_entry_id: int
portfolio_id: str
order_id: str | None = None
execution_id: str | None = None
side: str
quantity: float
price: float
fee_amount: float
tax_amount: float
executed_at: datetime
recorded_at: datetime | None = None
class TradeHistoryResponse(BaseModel):
items: list[TradeHistoryItem]
total: int
# ---------------------------------------------------------------------------
# GET /v1/portfolios/{id}/pnl/realized (REQ-040)
# ---------------------------------------------------------------------------
class RealizedPnlItem(BaseModel):
symbol: str | None = None
executed_at: datetime | None = None
quantity: float
sell_price: float
cost_basis_per_unit: float
fee_amount: float
tax_amount: float
realized_pnl: float
order_id: str | None = None
execution_id: str | None = None
class RealizedPnlResponse(BaseModel):
total_realized_pnl: float
items: list[RealizedPnlItem]
cost_basis_from: datetime | None = None
data_completeness_warning: str | None = None