Files
portfolio-hub/app/domain/pnl.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

180 lines
6.5 KiB
Python

"""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