306 lines
12 KiB
Python
306 lines
12 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
|
|
|
|
elif side == "stock_consolidation":
|
|
before = float(row.get("quantity_before", running_qty))
|
|
after = float(row.get("quantity_after", row.get("asset_quantity_after", 0.0)))
|
|
if before <= 0 or after <= 0 or running_qty <= 0:
|
|
warning_triggered = True
|
|
continue
|
|
if abs(running_qty - before) > 1e-9:
|
|
warning_triggered = True
|
|
running_qty = after
|
|
running_wac = float(row.get("average_price_after", running_wac * before / after))
|
|
|
|
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
|
|
|
|
elif side == "stock_consolidation":
|
|
before = float(row.get("quantity_before", running_qty))
|
|
after = float(row.get("quantity_after", row.get("asset_quantity_after", 0.0)))
|
|
if before <= 0 or after <= 0 or running_qty <= 0:
|
|
warning_triggered = True
|
|
continue
|
|
if abs(running_qty - before) > 1e-9:
|
|
warning_triggered = True
|
|
running_qty = after
|
|
running_wac = float(row.get("average_price_after", running_wac * before / after))
|
|
|
|
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
|