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
+90 -1
View File
@@ -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,
)