feat(holdings): REQ-043 현재 보유 평단(WAC) read — GET /v1/portfolios/{id}/holdings/cost-basis (BAT-018, PR #14)

dcy-008(confirmed)/REQ-043(Approved)/BAT-018. trex-core dcy-034 FR-7 cold-start seed 의존. AC 11/11, QC PASS. worktree+PR 워크플로 첫 파일럿.

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-26 00:07:43 +09:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 8f9865bd8a
commit d93c0bcaf5
14 changed files with 1229 additions and 9 deletions
+101 -1
View File
@@ -1,4 +1,4 @@
"""WAC (weighted average cost) based realized PnL calculation (REQ-041).
"""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.
@@ -37,6 +37,27 @@ class WacPnlResult:
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]],
*,
@@ -164,6 +185,85 @@ def calculate_realized_pnl(
)
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: running_wac = (running_wac * running_qty + price * qty) / (running_qty + qty)
running_qty += qty
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))
executed_at: datetime | None = row.get("executed_at")
if side == "buy":
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
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.