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:
@@ -3,6 +3,7 @@
|
||||
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.
|
||||
REQ-043: GET /v1/portfolios/{id}/holdings/cost-basis — current holding WAC cost basis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -16,7 +17,7 @@ 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.domain.pnl import calculate_current_wac, calculate_realized_pnl
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.v1 import (
|
||||
LedgerTradeRequest,
|
||||
@@ -30,6 +31,7 @@ from app.schemas.v1 import (
|
||||
PortfolioListResponse,
|
||||
PortfolioTagListResponse,
|
||||
PortfolioUpdateRequest,
|
||||
HoldingsCostBasisResponse,
|
||||
RealizedPnlItem,
|
||||
RealizedPnlResponse,
|
||||
TagApplyRequest,
|
||||
@@ -794,3 +796,90 @@ def get_realized_pnl(
|
||||
cost_basis_from=result.cost_basis_from,
|
||||
data_completeness_warning=result.data_completeness_warning,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/portfolios/{portfolio_id}/holdings/cost-basis — REQ-043
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/{portfolio_id}/holdings/cost-basis", response_model=HoldingsCostBasisResponse)
|
||||
def get_holdings_cost_basis(
|
||||
portfolio_id: str,
|
||||
auth: AuthContext = Depends(require_read),
|
||||
) -> HoldingsCostBasisResponse:
|
||||
"""현재 보유 평단(WAC) 조회 — 미매도 보유분의 cost basis (REQ-043).
|
||||
|
||||
- service token: daemon_id == portfolio.managed_by_daemon_id 일치 시만 허용 (불일치/null → 403).
|
||||
- user token: 소유자 또는 admin만 허용 (비소유자 → 403).
|
||||
- 존재하지 않는 portfolio_id → 404.
|
||||
- 보유수량 0인 경우 → 200 + current_quantity=0, cost_basis_per_unit=null, total_cost_basis=null.
|
||||
"""
|
||||
repo = _get_repo()
|
||||
try:
|
||||
# Fetch portfolio (existence check)
|
||||
try:
|
||||
portfolio = repo.get(portfolio_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"portfolio를 찾을 수 없습니다: {portfolio_id}",
|
||||
)
|
||||
|
||||
# Ownership / scope check
|
||||
if auth.token_type == "service":
|
||||
# daemon scope: daemon_id must match managed_by_daemon_id
|
||||
if portfolio.managed_by_daemon_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="daemon 미배정 portfolio에 daemon 접근 불가",
|
||||
)
|
||||
if auth.daemon_id is None or auth.daemon_id != portfolio.managed_by_daemon_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Token daemon does not match portfolio daemon assignment",
|
||||
)
|
||||
else:
|
||||
# user/admin token: non-admin must own the portfolio
|
||||
if auth.permission != Permission.ADMIN and portfolio.owner_user_id != auth.user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Portfolio ownership mismatch",
|
||||
)
|
||||
|
||||
# Fetch all ledger history for WAC replay
|
||||
ledger_entries = repo.get_all_trades_for_pnl(portfolio_id, symbol=None)
|
||||
except HTTPException:
|
||||
raise
|
||||
finally:
|
||||
_close_repo(repo)
|
||||
|
||||
# Build rows for domain function
|
||||
rows = [
|
||||
{
|
||||
"side": e.side,
|
||||
"quantity": e.quantity,
|
||||
"price": e.price,
|
||||
"executed_at": e.executed_at,
|
||||
}
|
||||
for e in ledger_entries
|
||||
]
|
||||
|
||||
wac_result = calculate_current_wac(rows)
|
||||
|
||||
current_quantity = portfolio.asset_quantity
|
||||
cost_basis_per_unit = wac_result.current_wac if current_quantity > 0 else None
|
||||
total_cost_basis = (
|
||||
cost_basis_per_unit * current_quantity
|
||||
if cost_basis_per_unit is not None and current_quantity > 0
|
||||
else None
|
||||
)
|
||||
|
||||
return HoldingsCostBasisResponse(
|
||||
portfolio_id=portfolio_id,
|
||||
symbol=portfolio.symbol,
|
||||
current_quantity=current_quantity,
|
||||
cost_basis_per_unit=cost_basis_per_unit,
|
||||
total_cost_basis=total_cost_basis,
|
||||
cost_basis_from=wac_result.cost_basis_from,
|
||||
data_completeness_warning=wac_result.data_completeness_warning,
|
||||
)
|
||||
|
||||
+101
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -296,3 +296,17 @@ class RealizedPnlResponse(BaseModel):
|
||||
items: list[RealizedPnlItem]
|
||||
cost_basis_from: datetime | None = None
|
||||
data_completeness_warning: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/portfolios/{id}/holdings/cost-basis (REQ-043)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class HoldingsCostBasisResponse(BaseModel):
|
||||
portfolio_id: str
|
||||
symbol: str | None = None
|
||||
current_quantity: float
|
||||
cost_basis_per_unit: float | None = None
|
||||
total_cost_basis: float | None = None
|
||||
cost_basis_from: datetime | None = None
|
||||
data_completeness_warning: str | None = None
|
||||
|
||||
Reference in New Issue
Block a user