Files
portfolio-hub/app/domain/pnl.py
T
genichinandClaude Sonnet 4.6 73414817e0 fix(pnl): 매수 수수료를 WAC cost basis에 편입 (REQ-041 CHG-20260626-01)
WAC 공식에 buy row의 fee_amount를 분자에 포함해 실현 PnL이 당일 net과 정확히 일치하도록 수정.
calculate_realized_pnl + calculate_current_wac 양 함수 동일 적용.
신규 단위 테스트 9개(TestBuyFeeInCostBasis 5 + TestCurrentWacBuyFeeInCostBasis 4) 추가.
change-req: REQ-041 AC-9/AC-10 추가, REQ-040/REQ-043 Notes 갱신, index 갱신.
전체 456 passed / 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
2026-06-26 20:34:03 +09:00

284 lines
11 KiB
Python

"""WAC (weighted average cost) based realized PnL calculation (REQ-041, REQ-043).
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
@dataclass
class CurrentWacResult:
"""Result of current holding WAC calculation (REQ-043).
Represents the running WAC state after replaying all ledger history,
giving the cost basis of currently held (unsold) shares.
"""
current_wac: float | None
"""WAC per unit for currently held shares. None when current_quantity is 0."""
current_quantity: float
"""Remaining quantity after all buys and sells."""
cost_basis_from: datetime | None
"""Earliest buy date used in WAC calculation. None when no buy history exists."""
data_completeness_warning: str | None
"""Non-None when ledger history is incomplete (sell before buy, oversell, etc.)."""
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-fee-in-cost-basis):
buy: running_wac = (running_wac * running_qty + price * qty + buy_fee) / (running_qty + qty)
running_qty += qty
(buy_fee = fee_amount of the buy row, incorporated into cost basis)
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 — include buy fee in cost basis (매수 수수료 편입)
new_total_qty = running_qty + qty
if new_total_qty > 0:
running_wac = (running_wac * running_qty + price * qty + fee) / 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 calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult:
"""Calculate the current holding WAC (weighted average cost) from a time-ordered ledger.
Replays the full ledger history (buy/sell) and returns the running WAC state,
representing the cost basis of currently held (unsold) shares.
Args:
rows: List of portfolio_ledger row dicts, each containing:
side (str), quantity (float), price (float),
executed_at (datetime).
Must be sorted by executed_at ASC.
Returns:
CurrentWacResult with:
- current_wac: WAC per unit (None when current_quantity == 0)
- current_quantity: remaining quantity after all trades
- cost_basis_from: earliest buy date used
- data_completeness_warning: non-None when history is incomplete
WAC Rules (same as calculate_realized_pnl, REQ-041, buy-fee-in-cost-basis):
buy: running_wac = (running_wac * running_qty + price * qty + buy_fee) / (running_qty + qty)
running_qty += qty
(buy_fee = fee_amount of the buy row, incorporated into cost basis)
sell: running_qty -= qty (WAC per unit unchanged on sell)
Incomplete history (sell with running_qty=0, or sell qty > running_qty):
data_completeness_warning is set; calculation continues with best estimate.
"""
if not rows:
return CurrentWacResult(
current_wac=None,
current_quantity=0.0,
cost_basis_from=None,
data_completeness_warning=None,
)
running_wac: float = 0.0
running_qty: float = 0.0
cost_basis_from: datetime | None = None
warning_triggered: bool = False
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))
executed_at: datetime | None = row.get("executed_at")
if side == "buy":
# Include buy fee in cost basis (매수 수수료 편입)
new_total_qty = running_qty + qty
if new_total_qty > 0:
running_wac = (running_wac * running_qty + price * qty + fee) / new_total_qty
running_qty = new_total_qty
if cost_basis_from is None and executed_at is not None:
cost_basis_from = executed_at
elif side == "sell":
if running_qty <= 0:
warning_triggered = True
elif qty > running_qty + 1e-9:
warning_triggered = True
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 CurrentWacResult(
current_wac=running_wac if running_qty > 0 else None,
current_quantity=running_qty,
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