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:
@@ -44,3 +44,5 @@ htmlcov/
|
||||
# OS junk
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# Agent worktree 격리 디렉토리 (커밋 금지)
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# BAT-018 Implementation
|
||||
|
||||
## 구현 결정 사항
|
||||
|
||||
### domain 함수 선택: 신규 함수 분리 (OQ-2 위임 → 분리 채택)
|
||||
- `calculate_current_wac(rows) -> CurrentWacResult` 신규 함수를 `app/domain/pnl.py`에 추가.
|
||||
- `calculate_realized_pnl`의 WAC 누적 로직과 동일 규칙을 따르되, 매도 PnL 집계 없이 최종 running_wac/running_qty만 반환.
|
||||
- 이유: 단일책임 원칙 + 기존 함수 파라미터 과부하 방지. 두 함수는 입력/출력 목적이 다름(매도 PnL vs 현재 평단).
|
||||
|
||||
### 소유권 분기 방식
|
||||
- 핸들러 내에서 `auth.token_type == "service"` 분기로 처리.
|
||||
- service: `managed_by_daemon_id is None` → 403, `auth.daemon_id != managed_by_daemon_id` → 403.
|
||||
- user/admin: `auth.permission != Permission.ADMIN and portfolio.owner_user_id != auth.user_id` → 403.
|
||||
- `_get_portfolio_or_404` 헬퍼는 user token 소유권 검사를 포함하므로 서비스 토큰에는 미사용(직접 `repo.get()` 호출).
|
||||
|
||||
### current_quantity 출처
|
||||
- `portfolio.asset_quantity` 직접 읽기 (REQ-043 Notes 준수).
|
||||
- WAC 산출은 ledger replay 독립 계산(calculate_current_wac).
|
||||
|
||||
## 변경 파일 목록
|
||||
|
||||
| 파일 | 변경 유형 | 내용 |
|
||||
| --- | --- | --- |
|
||||
| `app/domain/pnl.py` | 수정 | `CurrentWacResult` dataclass + `calculate_current_wac` 함수 추가 |
|
||||
| `app/schemas/v1.py` | 수정 | `HoldingsCostBasisResponse` 스키마 추가 |
|
||||
| `app/api/v1_portfolios.py` | 수정 | `calculate_current_wac` import, `HoldingsCostBasisResponse` import, `get_holdings_cost_basis` 핸들러 추가 |
|
||||
| `tests/test_v1_portfolios_holdings.py` | 신규 | AC-1~11 커버 테스트 20개 |
|
||||
| `docs/batches/index.md` | 수정 | BAT-018 register 행 추가 |
|
||||
| `docs/batches/bat-018_*/index.md` | 신규 | batch index 문서 |
|
||||
| `docs/batches/bat-018_*/planning.md` | 신규 | batch planning 문서 |
|
||||
|
||||
## 커밋 SHA
|
||||
- `748aa5d` — feat(holdings): GET /v1/portfolios/{id}/holdings/cost-basis — REQ-043, BAT-018
|
||||
@@ -0,0 +1,36 @@
|
||||
# BAT-018: 현재 보유 평단(WAC) 조회 엔드포인트 신설 (REQ-043)
|
||||
|
||||
- Status: in-progress
|
||||
- Profile: batch-lite
|
||||
- Source Discovery: dcy-008 (현재 보유 종목별 평단(WAC) 조회 API)
|
||||
- Included REQ: REQ-043
|
||||
- Excluded REQ: none
|
||||
|
||||
## Summary
|
||||
- `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis` 신규 엔드포인트 신설.
|
||||
- 현재 미매도 보유 종목의 (symbol, current_quantity, cost_basis_per_unit=WAC, total_cost_basis, cost_basis_from, data_completeness_warning) 반환.
|
||||
- WAC 산출은 `app/domain/pnl.py`의 기존 running_wac 로직(REQ-041)을 재사용하는 `calculate_current_wac` 신규 함수로 분리(단일책임 원칙).
|
||||
- 인증: `require_read` + service token(daemon scope) 소유권 정책(REQ-029 선례, `daemon_id == managed_by_daemon_id` 일치).
|
||||
- trex-core dcy-034 FR-7(cold-start 슬롯 평단 seed)의 선행 의존.
|
||||
|
||||
## Scope
|
||||
- In Scope:
|
||||
- `app/domain/pnl.py` — `calculate_current_wac` 신규 함수 추가 (running_wac 로직 재사용)
|
||||
- `app/schemas/v1.py` — `HoldingsCostBasisResponse` 스키마 신규 추가
|
||||
- `app/api/v1_portfolios.py` — `GET /holdings/cost-basis` 엔드포인트 추가 (service/user token 소유권 분기)
|
||||
- `tests/test_v1_portfolios_holdings.py` — AC-1~11 커버 신규 테스트 파일
|
||||
- `docs/batches/index.md` — BAT-018 register 행 추가
|
||||
- Out of Scope:
|
||||
- 미실현 손익(현재가 결합) — portfolio-hub 시세 비소유
|
||||
- DB 스키마 변경/마이그레이션 — 기존 portfolio_ledger 재사용
|
||||
- repository 신규 메서드 — `get_all_trades_for_pnl` 재사용(신규 SQL 불필요)
|
||||
|
||||
## Documents
|
||||
- Planning: [planning.md](./planning.md)
|
||||
- Design: none (batch-lite, design gate skip)
|
||||
- Implementation: [implementation.md](./implementation.md)
|
||||
- Verification: [verification.md](./verification.md)
|
||||
|
||||
## Notes
|
||||
- profile 선정 근거: 단일 Approved REQ(REQ-043) + read-only(쓰기·마이그레이션 없음) + 기존 WAC 로직/인증 패턴 재사용 → batch-lite 적합.
|
||||
- design 생략 근거: 신규 엔드포인트이나 기존 `get_realized_pnl` 패턴을 그대로 따르고, WAC 함수는 기존 `calculate_realized_pnl` running_wac 추출로 구현 결정이 단순함.
|
||||
@@ -0,0 +1,60 @@
|
||||
# BAT-018 Planning
|
||||
|
||||
- Status: confirmed
|
||||
- Batch: BAT-018
|
||||
- Profile: batch-lite
|
||||
|
||||
## Included REQ
|
||||
- REQ-043 (Interface, Approved) — GET /v1/portfolios/{id}/holdings/cost-basis 현재 보유 평단(WAC) read 엔드포인트 신설.
|
||||
|
||||
## Delivery Plan
|
||||
|
||||
### 구현 순서
|
||||
1. **domain 함수 추가** (`app/domain/pnl.py`)
|
||||
- `calculate_current_wac(rows)` 신규 함수 — 기존 `calculate_realized_pnl`의 running_wac 누적 로직 재사용, 매도 완료 후 잔여 running_wac/running_qty/cost_basis_from/data_completeness_warning 반환.
|
||||
- 반환 타입: 새 `CurrentWacResult` dataclass (wac, quantity, cost_basis_from, data_completeness_warning).
|
||||
2. **스키마 추가** (`app/schemas/v1.py`)
|
||||
- `HoldingsCostBasisResponse` 스키마 신규 추가 (REQ-043 응답 필드 전체 포함).
|
||||
3. **엔드포인트 추가** (`app/api/v1_portfolios.py`)
|
||||
- `GET /{portfolio_id}/holdings/cost-basis` 핸들러 — `require_read` 의존성.
|
||||
- 소유권 분기: service token → `daemon_id == managed_by_daemon_id` 일치 확인(불일치/null → 403). user token → admin 외 비소유자 403.
|
||||
- `portfolio.asset_quantity`에서 current_quantity 직접 읽기.
|
||||
- WAC: `get_all_trades_for_pnl` 재사용 → `calculate_current_wac` 호출.
|
||||
4. **테스트** (`tests/test_v1_portfolios_holdings.py` 신규)
|
||||
- AC-1~11 커버. MagicMock repo 주입 패턴(기존 test_v1_portfolios_pnl.py 참조).
|
||||
|
||||
### 파일 변경 목록
|
||||
- **신규 생성**:
|
||||
- `tests/test_v1_portfolios_holdings.py` — AC-1~11 테스트
|
||||
- `docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/` — batch 문서
|
||||
- **수정**:
|
||||
- `app/domain/pnl.py` — `CurrentWacResult` dataclass + `calculate_current_wac` 함수 추가
|
||||
- `app/schemas/v1.py` — `HoldingsCostBasisResponse` 추가
|
||||
- `app/api/v1_portfolios.py` — 엔드포인트 추가, import 갱신
|
||||
- `docs/batches/index.md` — BAT-018 register 행 추가
|
||||
|
||||
## Dependencies
|
||||
- `app/domain/pnl.py`의 `calculate_realized_pnl` running_wac 로직(REQ-041) — 이미 구현됨.
|
||||
- `app/repositories/portfolio_repository.py`의 `get_all_trades_for_pnl` — 이미 구현됨.
|
||||
- `require_read` 인증 의존성 — 변경 없음.
|
||||
- `portfolio.asset_quantity` 컬럼 — 이미 존재.
|
||||
|
||||
## Design Gate
|
||||
- Design Required Before Implementation: **no**
|
||||
- Reason:
|
||||
- 기존 `get_realized_pnl` 핸들러(L710)의 직계 변형. HTTP contract·schema·계층 경계 모두 기존 패턴 답습.
|
||||
- WAC 함수: `calculate_realized_pnl`의 running_wac 추출 — 구조적 신규 설계 없음.
|
||||
- 소유권 정책: REQ-029 선례(`_require_service_token` + `managed_by_daemon_id` 검사)를 read 엔드포인트에 적용, 분기만 추가.
|
||||
|
||||
## Milestones
|
||||
1. domain 함수(`calculate_current_wac`) + 단위 테스트 작성
|
||||
2. 스키마(`HoldingsCostBasisResponse`) 추가
|
||||
3. 엔드포인트(`GET /holdings/cost-basis`) 추가
|
||||
4. 통합 테스트(`test_v1_portfolios_holdings.py`) AC-1~11 작성
|
||||
5. `pytest` 전체 실행 — 신규 + 기존 회귀 확인
|
||||
|
||||
## Risks
|
||||
| Risk | 완화 |
|
||||
| --- | --- |
|
||||
| R-1 (Low): current_quantity와 WAC 수량 불일치 시 data_completeness_warning 조건 | `portfolio.asset_quantity`는 직접 읽고, WAC는 ledger replay 독립 계산. 불일치 시 warning 트리거 → REQ-043 Notes 항목 준수. |
|
||||
| R-2 (Low): service token 인증 분기가 기존 user token 패턴과 충돌 | `_get_portfolio_or_404`는 user token 전용 소유권 검사. service token 분기는 핸들러 내 명시적 분기로 별도 처리. |
|
||||
@@ -0,0 +1,60 @@
|
||||
# BAT-018 Verification
|
||||
|
||||
- Status: draft-batch-verification
|
||||
- Verified At: 2026-06-25
|
||||
- Profile: batch-lite (app-service)
|
||||
|
||||
## pytest 실행 결과 (원문)
|
||||
|
||||
```
|
||||
============================================================= test session starts =============================================================
|
||||
platform linux -- Python 3.12.3, pytest-8.4.2, pluggy-1.6.0 -- /home/genichin/workspace/trex-lead/repos/portfolio-hub/.venv/bin/python3
|
||||
cachedir: .pytest_cache
|
||||
rootdir: /home/genichin/workspace/trex-lead/repos/portfolio-hub/.claude/worktrees/agent-adf14aa7889b45655
|
||||
configfile: pyproject.toml
|
||||
plugins: anyio-4.3.0
|
||||
collecting ... collected 447 items
|
||||
|
||||
447 passed, 11 warnings in 23.24s
|
||||
```
|
||||
|
||||
신규 테스트 20개: 전부 PASS
|
||||
기존 회귀 427개: 전부 PASS (0 failed, 0 error)
|
||||
경고 11개: 기존 DeprecationWarning (HTTP_422_UNPROCESSABLE_ENTITY deprecated) — 본 batch 변경과 무관, 선행 코드에서 유입.
|
||||
|
||||
## AC 커버리지
|
||||
|
||||
| AC | 테스트 함수 | 결과 |
|
||||
| --- | --- | --- |
|
||||
| AC-1 (buy-only WAC=11,000) | TestAC1WacBuysOnly::test_cost_basis_per_unit_is_11000 + 4개 | PASS |
|
||||
| AC-2 (부분매도 WAC 불변, qty=50) | TestAC2PartialSellWacUnchanged::* (3개) | PASS |
|
||||
| AC-3 (user owner → 200) | test_ac3_user_owner_read_token_returns_200 | PASS |
|
||||
| AC-4 (비소유자 user → 403) | test_ac4_user_non_owner_returns_403 | PASS |
|
||||
| AC-5 (daemon mismatch → 403) | test_ac5_daemon_mismatch_returns_403 | PASS |
|
||||
| AC-6 (null daemon → 403) | test_ac6_null_daemon_returns_403 | PASS |
|
||||
| AC-7 (미존재 portfolio → 404) | test_ac7_nonexistent_portfolio_returns_404 | PASS |
|
||||
| AC-8 (빈 이력, qty=0 → 200 null) | test_ac8_empty_ledger_returns_200_null_fields | PASS |
|
||||
| AC-9 (sell without buy → warning) | test_ac9_sell_without_buy_triggers_warning | PASS |
|
||||
| AC-10 (no token → 401) | test_ac10_no_auth_token_returns_401 | PASS |
|
||||
| AC-11 (OpenAPI 문서화) | test_ac11_endpoint_in_openapi | PASS |
|
||||
|
||||
## 추가 테스트 (커버리지 보강)
|
||||
|
||||
| 테스트 | 내용 |
|
||||
| --- | --- |
|
||||
| test_response_contains_all_required_fields | 7개 응답 필드 전부 존재 확인 |
|
||||
| test_cost_basis_from_is_first_buy_date | cost_basis_from 비null 확인 |
|
||||
| test_portfolio_id_in_response_matches_request | 응답 portfolio_id 일치 확인 |
|
||||
|
||||
## 회귀 영향
|
||||
|
||||
- 기존 테스트 427개 전부 통과 — 본 batch 변경(신규 함수/엔드포인트/스키마 추가)이 기존 코드에 미치는 회귀 없음.
|
||||
- 변경 파일(app/domain/pnl.py, app/schemas/v1.py, app/api/v1_portfolios.py)은 모두 additive 변경(기존 코드 수정 없음, 신규 추가만).
|
||||
|
||||
## 검증 결론
|
||||
|
||||
- AC-1~11 전부 PASS.
|
||||
- 기존 회귀 0 실패.
|
||||
- HTTP API 계약: `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis` 신규 추가(호환성 깨짐 없음, read-only).
|
||||
- DB 마이그레이션: 없음(쓰기/스키마 변경 없음).
|
||||
- 상태: `draft-batch-verification` 완료 → 팀장 `confirm-batch-verification` 대기.
|
||||
@@ -25,3 +25,4 @@
|
||||
| BAT-015 | standard | released | REQ-033, REQ-034, REQ-035 | dcy-005 | [bat-015](./bat-015_20260601_rel-001-feedback-cleanup/) |
|
||||
| BAT-016 | standard | released | REQ-036, REQ-037, REQ-038 | dcy-006 | [bat-016](./bat-016_20260605_ledger-accounting-consolidation/) |
|
||||
| BAT-017 | standard | released | REQ-039, REQ-040, REQ-041, REQ-042 | dcy-007 | [bat-017](./bat-017_20260618_trade-history-and-pnl-api/) |
|
||||
| BAT-018 | batch-lite | in-progress | REQ-043 | dcy-008 | [bat-018](./bat-018_20260625_holdings-cost-basis-endpoint/) |
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# 0. 문서 상태
|
||||
이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다.
|
||||
|
||||
- 상태: confirmed
|
||||
- 문서 ID: dcy-008_20260625_current-holding-wac-cost-basis-read
|
||||
- 이슈명: 현재 보유 종목별 평단(WAC) 조회 API — trex-core 중장기 cold-start 슬롯 평단 seed 의존
|
||||
- 작성 시각(KST): 2026-06-25 (KST)
|
||||
- 마지막 갱신 시각(KST): 2026-06-25 (KST, confirm-discovery — 사장님 승인, OQ-1/OQ-2 Answered)
|
||||
- 대체됨: 없음
|
||||
- 후속 Discovery 참조: 없음
|
||||
- 교차 서비스 발원(driver): trex-core `dcy-034_20260625_slot-account-blocking-reconcile-sync-coldstart` FR-7 — trex-core가 cold-start 시 슬롯 평단을 seed하려면 portfolio-hub의 현재 보유분 평단 read가 필요하다. 본 Discovery는 그 의존을 portfolio-hub측 변경 단위로 정식화한다.
|
||||
- 생성된 REQ 참조: REQ-043 (GET /v1/portfolios/{id}/holdings/cost-basis 현재 보유 평단 read, Interface) → [req-043](../srs/Interface/req-043_holdings-cost-basis-read-endpoint.md) (draft-req, confirm-req 대기)
|
||||
|
||||
# 1. 계획 상태 요약
|
||||
- 연계 문서 현황:
|
||||
- 관련 점수 파일: 없음
|
||||
- 관련 TODO/메모: 교차 서비스 의존 — trex-core dcy-034(M3 중장기 cold-start) FR-7. GitHub 보드 #7 dcy-034 항목.
|
||||
- 직계 선행: dcy-007(일일 거래내역 + 실현 PnL 조회 API) — WAC cost basis 산출 로직(REQ-041)을 이미 도입한 직계 형제.
|
||||
- 해석:
|
||||
- trex-core 중장기 인스턴스가 실계좌에 이미 보유 중인 종목 위에서 cold-start 할 때, 슬롯에 보유분을 onboarding하며 **슬롯 평단(cost basis)을 seed**해야 한다. 그 평단의 단일 정본 출처는 원장(ledger)을 보유한 portfolio-hub다.
|
||||
- 그러나 portfolio-hub는 WAC를 내부 계산만 하고(미매도 보유분의 현재 평단을 주는 read가 없음), 외부 노출은 매도 시점 `cost_basis_per_unit`(dcy-007/REQ-040)뿐이다. 즉 "현재 보유분의 평단"을 조회할 표면이 비어 있다.
|
||||
- 본 Discovery는 그 read 표면을 portfolio-hub 자체 변경(신규 조회 엔드포인트 + 현재 보유 WAC 집계)으로 정의한다.
|
||||
|
||||
# 2. 요구사항 판정 결과
|
||||
- 판정: 신규
|
||||
- 근거
|
||||
- dcy-007은 **매도 기준 실현 PnL 집계**(GET /pnl/realized, 매도행 cost_basis_per_unit)다. 이번은 **미매도 현재 보유분의 평단 조회**로, 데이터 대상(미청산 포지션)과 용도(외부 seed)가 다르다.
|
||||
- 기존 dcy-001~007 중 "현재 보유 평단 read"를 정의한 Discovery가 없다. WAC 산출 로직(REQ-041)은 재사용 가능하나 이를 노출하는 read 계약은 신규다.
|
||||
|
||||
# 3. 문제점의 요약
|
||||
- 현재 상태
|
||||
- `app/domain/pnl.py`의 `calculate_realized_pnl`이 매수 누적으로 `running_wac`를 계산하지만, 이 값은 **매도 이벤트 행(`cost_basis_per_unit`)으로만** 응답에 노출된다(`GET /v1/portfolios/{id}/pnl/realized`, REQ-040). 매도가 0건이면 보유분 평단을 알 길이 없다.
|
||||
- portfolio_ledger(TimescaleDB)에 매수/매도 체결이 적재돼 있어 보유 수량·WAC는 산출 가능하나, **"현재 보유 종목별 (수량, 평단)"을 반환하는 GET 엔드포인트가 없다.**
|
||||
- 결과: trex-core cold-start가 슬롯 평단을 seed할 외부 read가 없어, 브로커 평균가(dcy-032에서 0/None 케이스 확인) 등 불안정한 폴백에 의존해야 한다.
|
||||
- 기대 상태
|
||||
- 포트폴리오의 **현재 보유 종목별 보유수량 + 현재 평단(WAC, cost_basis_per_unit)** 을 반환하는 read 엔드포인트가 존재한다.
|
||||
- WAC 산출은 dcy-007/REQ-041의 검증된 `running_wac` 로직을 재사용하고, 원장 이력이 불완전하면 dcy-007과 동일하게 `data_completeness_warning`으로 표면화한다.
|
||||
- 인증/역할은 기존 portfolio read API와 동일 정책이라 daemon 호출자(trex-core)가 소비할 수 있다.
|
||||
- 이해관계자
|
||||
- 사장님 — 교차 서비스 계약·범위 승인자
|
||||
- 팀장(Claude) — Discovery/게이트 관리, 교차 의존 추적자
|
||||
- portfolio-hub-dev — delivery 담당
|
||||
- 소비자: trex-core(중장기 cold-start seed), 운영자(보유 평단 가시성)
|
||||
- 제약사항
|
||||
- portfolio-hub는 **시세(현재가)를 소유하지 않는다**(그건 kis-data-hub). 따라서 본 read는 **평단/취득원가(cost basis)만** 다루고 미실현 손익(현재가 결합)은 범위 밖이다.
|
||||
- WAC 산출 정본은 원장(portfolio_ledger)이며, 원장 이력 불완전 시 추정 한계가 dcy-007과 동일하게 존재한다.
|
||||
- 응답 스키마/인증은 기존 v1 portfolio read 계약과 일관해야 한다(프론트/daemon 회귀 방지).
|
||||
|
||||
## 주요 사용자 시나리오
|
||||
- trex-core(daemon): 중장기 인스턴스 cold-start에서 보유 종목 onboarding 시, 이 엔드포인트로 종목별 현재 평단을 읽어 슬롯 cost basis를 seed한다.
|
||||
- 운영자: 매도 전이라도 특정 포트폴리오의 현재 보유 평단/취득원가를 조회해 손익 기준선을 확인한다.
|
||||
|
||||
# 4. 이번에 정의할 변경
|
||||
- 변경 1 — 현재 보유 평단 조회 엔드포인트 신설: 포트폴리오의 미청산 보유 종목별 (symbol, 보유수량, WAC, 보유 cost basis 합)을 반환하는 GET 엔드포인트.
|
||||
- 변경 2 — 현재 보유 WAC 집계 로직: dcy-007/REQ-041의 `running_wac`(매수 누적 WAC, 매도 시 수량 차감) 로직을 재사용해 "매도 시점 PnL"이 아니라 "현재 잔여 보유분의 평단"을 산출. 원장 불완전 시 `data_completeness_warning`.
|
||||
|
||||
## 영향 범위
|
||||
- 코드 영향:
|
||||
- `app/api/v1_portfolios.py` — 신규 GET 핸들러(기존 `/pnl/realized` 핸들러 :710 인증 패턴 재사용).
|
||||
- `app/domain/pnl.py` — 현재 보유 WAC 산출 함수(기존 `calculate_realized_pnl` running_wac 로직 재사용/추출).
|
||||
- `app/schemas/v1.py` — 응답 스키마 신설(RealizedPnl* 인근 패턴).
|
||||
- `app/repositories/postgres.py` — 보유 집계 조회(필요 시).
|
||||
- 문서 영향: `docs/discovery/index.md` 신규 행, srs/index.md(REQ 생성 시).
|
||||
- 운영 영향: 신규 read 엔드포인트 1개 추가(쓰기·마이그레이션 없음). 인증 정책 변화 없음.
|
||||
|
||||
# 5. 요구사항 목록
|
||||
## 기능 요구사항
|
||||
- FR-1: 포트폴리오의 **현재 보유(미청산) 종목별** `symbol`, `quantity`, `cost_basis_per_unit`(WAC)을 반환하는 GET 엔드포인트를 제공한다.
|
||||
- FR-2: WAC는 매수 누적 가중평균(매도 시 수량 차감, 단가 불변)으로 산출한다(dcy-007/REQ-041 `running_wac` 재사용). 매수만 한 보유분은 WAC=가중평균 매수가.
|
||||
- FR-3: 원장 이력이 불완전하면 `data_completeness_warning`을 응답에 포함한다(dcy-007 패턴 일관).
|
||||
- FR-4: 인증/역할은 기존 portfolio read API(REQ-040/041)와 동일 정책으로, daemon scope 호출자가 사용 가능해야 한다.
|
||||
|
||||
## 비기능 요구사항
|
||||
- NFR-1: 응답 스키마·인증은 기존 v1 portfolio read 계약과 일관(프론트/daemon 회귀 0).
|
||||
- NFR-2: 본 엔드포인트는 read-only, 쓰기/스키마 마이그레이션 없음.
|
||||
- NFR-3: 시세 의존 없음 — 평단/취득원가만(미실현 손익 비포함).
|
||||
|
||||
## 범위 경계
|
||||
- In Scope
|
||||
- 현재 보유 종목별 (수량, WAC, cost basis 합) read 엔드포인트 + 산출 로직 + 테스트 + data_completeness_warning.
|
||||
- Out of Scope
|
||||
- 미실현 손익(현재가 결합) — portfolio-hub가 시세 비소유.
|
||||
- trex-core 슬롯 seed 로직 자체(소비측 — trex-core dcy-034).
|
||||
- 원장 소급 보정(데이터 정합화 별건).
|
||||
|
||||
# 6. 리스크/가정 목록
|
||||
## 리스크
|
||||
- R-1 (Low): 현재 보유 WAC 산출이 dcy-007 매도 기준 계산과 미세하게 갈라질 수 있다(잔여 수량 0 처리 등).
|
||||
- 완화: `running_wac` 로직을 단일 출처로 추출/재사용하고, 매수만/부분매도 후 시나리오 테스트로 dcy-007과 정합 확인.
|
||||
- R-2 (Low): 응답 스키마가 daemon/프론트 양 소비자 요구와 어긋날 수 있다.
|
||||
- 완화: 기존 RealizedPnl* 스키마 패턴 답습 + trex-core 소비 형태(symbol→평단 맵)를 design에서 확정.
|
||||
|
||||
## 가정
|
||||
- A-1: portfolio_ledger에 cold-start 대상 종목의 매수 이력이 적재돼 있어 WAC 산출이 가능하다(없으면 data_completeness_warning).
|
||||
- A-2: trex-core는 포트폴리오 단위(portfolio_id)로 평단을 소비한다(슬롯↔portfolio_id 1:1, trex-core dcy-020).
|
||||
- A-3: dcy-007/REQ-041의 WAC 로직은 현재 보유 평단 산출에 그대로 재사용 가능하다.
|
||||
|
||||
## 오픈 질문 (전부 Answered — confirm-discovery 사장님 승인 2026-06-25)
|
||||
- OQ-1 (Answered): 집계 단위 → **포트폴리오별** `GET /v1/portfolios/{id}/holdings/cost-basis`. trex-core 슬롯↔portfolio_id 1:1 + 기존 `/v1/portfolios/{id}/` 패턴 일관. (REQ-043 반영.)
|
||||
- OQ-2 (Answered): 응답 스키마 → 종목별 (symbol, current_quantity, cost_basis_per_unit=WAC, total_cost_basis, cost_basis_from, data_completeness_warning). 보유 cost basis 합계·최초 매수시각 포함. (REQ-043 반영.)
|
||||
- OQ-3 (Answered, 교차 서비스 — REQ-043 draft-req 제기): service token 소유권 — cold-start가 `managed_by_daemon_id=null` 미배정 포트폴리오 평단을 읽으면 403 blocker 우려. **해소: trex-core cold-start는 `snapshot_scheduler.run_once`에서 whoami→get_portfolios(daemon_id)로 이미 자기 daemon에 배정된 포트폴리오만 가져오므로, 그 포트폴리오들은 `managed_by_daemon_id==daemon`이라 소유권 정책(REQ-029 선례) 성립. null 케이스는 cold-start 경로에 없음(팀장 2026-06-25).** REQ-043은 daemon scope 소유권 정책 유지.
|
||||
|
||||
### 오픈 질문 처리 기준
|
||||
- `Open`: 다음 단계 진행에 영향이 있는 미결. 유지.
|
||||
- `Answered`: 확정. 본문/REQ 반영 후 정리.
|
||||
- `Deferred`: 다음 단계로 이월(대상·이유 명시).
|
||||
- `Dropped`: 무효(사유 명시).
|
||||
|
||||
# 7. 초기 성공 기준
|
||||
- S-1: 매수만 한 보유분에 대해 엔드포인트가 WAC=가중평균 매수가, 보유수량을 정확히 반환한다.
|
||||
- S-2: 부분 매도 후 잔여 보유분의 WAC가 매도로 변하지 않고(수량만 감소) 정확히 반환된다.
|
||||
- S-3: 원장 이력 불완전 시 `data_completeness_warning`이 응답에 포함된다.
|
||||
- S-4: 인증/역할이 기존 portfolio read와 동일하게 적용된다(무권한 거부, daemon scope 허용).
|
||||
|
||||
## 측정 방식 및 데이터 출처
|
||||
- S-1/S-2: 매수·부분매도 픽스처 단위/통합 테스트로 WAC·수량 검증 / 신규 테스트 + portfolio_ledger.
|
||||
- S-3: 불완전 이력 주입 테스트로 warning 단언 / 응답 페이로드.
|
||||
- S-4: 인증 테스트(무헤더/무권한/daemon scope) / 기존 auth 테스트 패턴.
|
||||
|
||||
# 8. REQ로 넘기기 전 확인 체크
|
||||
- [x] 범위(In/Out) 확정 — 미실현 손익 제외·평단만 제공 합의(사장님 2026-06-25)
|
||||
- [x] 성공 지표 측정 방식 확정 — S-1~S-4 이견 없음
|
||||
- [x] High 리스크 — 현재 없음(R-1/R-2 Low)
|
||||
- [x] 모든 OQ `Open` 없음 — OQ-1/OQ-2/OQ-3 전부 Answered
|
||||
- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 — 팀장(Claude)
|
||||
|
||||
# 9. 파일 처리 결과
|
||||
- 처리 결과: 생성
|
||||
- 생성 경로: docs/discovery/dcy-008_20260625_current-holding-wac-cost-basis-read.md
|
||||
- 참조 문서:
|
||||
- docs/discovery/dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api.md (WAC cost basis 산출 직계)
|
||||
- trex-core docs/discovery/dcy-034_...slot-account-blocking-reconcile-sync-coldstart.md (교차 서비스 발원, FR-7)
|
||||
- app/domain/pnl.py, app/api/v1_portfolios.py(:710), app/schemas/v1.py (현재 구현 근거)
|
||||
|
||||
# 10. 사용자 결정 필요 항목 요약
|
||||
## DECIDE (해소됨 — 사장님 2026-06-25)
|
||||
- (확정) OQ-1 집계 단위 → 포트폴리오별(`/v1/portfolios/{id}/holdings/cost-basis`).
|
||||
## CONFIRM (확정)
|
||||
- 본 read 엔드포인트를 trex-core dcy-034 FR-7의 portfolio-hub측 의존 단위로 채택 — 사장님 2026-06-25.
|
||||
- 미실현 손익 제외(평단/취득원가만) 범위 — 확정.
|
||||
## DATA
|
||||
- 없음(원장 이미 적재, WAC 로직 기존 보유).
|
||||
|
||||
# 11. Discovery Freeze
|
||||
## 섹션 참조
|
||||
- 범위: [# 5 > 범위 경계](#5-요구사항-목록)
|
||||
- 리스크/오픈 질문: [# 6](#6-리스크가정-목록)
|
||||
- 성공 기준: [# 7](#7-초기-성공-기준)
|
||||
- 인계 전 확인: [# 8](#8-req로-넘기기-전-확인-체크)
|
||||
- 사용자 결정 필요: [# 10](#10-사용자-결정-필요-항목-요약)
|
||||
|
||||
## Freeze 플래그
|
||||
- Handoff Decision: REQ Drafting 진행 가능
|
||||
- Handoff Rationale: 사장님 승인(2026-06-25). OQ-1(포트폴리오별)·OQ-2(스키마)·OQ-3(소유권 정책) 전부 Answered. REQ-043이 이미 본 Discovery에서 파생 작성됨(confirm-req 대기). 미실현 손익 제외 범위 합의. Open OQ 0건.
|
||||
- Ready for REQ Drafting: true
|
||||
- Confirmed By: 팀장(Claude)
|
||||
- Confirmed At (KST): 2026-06-25 (KST)
|
||||
|
||||
# 12. 작성 가이드
|
||||
- 이 문서는 portfolio-hub dcy 템플릿 기반이며 Open Questions는 가정으로 대체하지 않는다.
|
||||
- confirm-discovery 시 OQ-1/OQ-2 처리와 Freeze 플래그를 갱신한다.
|
||||
@@ -13,3 +13,4 @@
|
||||
| dcy-005_20260601_rel-001-feedback-cleanup | 20260601 | REL-001 Feedback 정합화 — ledger_trades.asset_id 명명 / session no_expiry enforcement / health db_enabled | [Discovery](./dcy-005_20260601_rel-001-feedback-cleanup.md) | confirmed |
|
||||
| dcy-006_20260605_ledger-trade-write-portfolio-state-return | 20260605 | ledger trade write 회계 책임 단일화 — 응답에 portfolio 상태 포함 + portfolio_ledger/ledger_trades 테이블 통합 (issue #8) | [Discovery](./dcy-006_20260605_ledger-trade-write-portfolio-state-return.md) | confirmed |
|
||||
| dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api | 20260616 | 일일 거래내역 + 실현 PnL 조회 API (운영 리포트/프론트 데이터 소스, issue #11) | [Discovery](./dcy-007_20260616_daily-trade-history-and-realized-pnl-query-api.md) | confirmed |
|
||||
| dcy-008_20260625_current-holding-wac-cost-basis-read | 20260625 | 현재 보유 종목별 평단(WAC) 조회 API — trex-core 중장기 cold-start 슬롯 평단 seed 의존(교차 발원 trex-core dcy-034 FR-7). GET /v1/portfolios/{id}/holdings/cost-basis | [Discovery](./dcy-008_20260625_current-holding-wac-cost-basis-read.md) | confirmed |
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# REQ-043: GET /v1/portfolios/{id}/holdings/cost-basis 현재 보유 평단(WAC) read 엔드포인트 신설
|
||||
|
||||
- Status: Approved
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: Justin Ko
|
||||
|
||||
## Intent
|
||||
- 지정 포트폴리오의 **현재 미매도 보유분에 대한 WAC(weighted average cost, 가중평균 취득단가)** 와 보유수량을 반환하는 GET 엔드포인트를 신설한다.
|
||||
- trex-core dcy-034 cold-start onboarding(FR-7)에서, 실계좌에 이미 보유 중인 종목을 슬롯에 등록할 때 슬롯의 평단(cost basis)을 seed하기 위해 이 엔드포인트를 소비한다.
|
||||
- WAC 산출은 기존 `calculate_realized_pnl`의 running_wac 로직(REQ-041)을 재사용하며, 데이터 불완전 시 `data_completeness_warning` 경고 패턴(REQ-040 관례)도 동일 적용한다.
|
||||
- 이 엔드포인트는 **trex-core dcy-034 FR-7의 선행 의존**이다. trex-core cold-start가 이 엔드포인트의 구현 완료 전에는 평단 seed를 수행할 수 없다.
|
||||
|
||||
## Requirement
|
||||
- 시스템은 `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis` 엔드포인트를 제공해야 한다.
|
||||
- 응답은 다음 필드를 포함해야 한다:
|
||||
- `portfolio_id` (str): 요청한 포트폴리오 ID
|
||||
- `symbol` (str | None): 포트폴리오에 연결된 종목 심볼
|
||||
- `current_quantity` (float): 현재 보유수량 (portfolios.asset_quantity 기준)
|
||||
- `cost_basis_per_unit` (float | None): WAC로 산출한 현재 평단 (보유수량이 0인 경우 None)
|
||||
- `total_cost_basis` (float | None): cost_basis_per_unit × current_quantity (보유수량이 0인 경우 None)
|
||||
- `cost_basis_from` (datetime | None): WAC 산출에 사용된 최초 buy 기록 시점 (REQ-041 관례)
|
||||
- `data_completeness_warning` (str | None): 이력 불완전 감지 시 경고 문자열, 없으면 None (REQ-040/041 관례)
|
||||
- WAC 산출 방식: 해당 portfolio_id의 전체 buy/sell 원장 이력(portfolio_ledger)을 시간 순으로 replay하여 현재 running_wac를 도출한다. 이는 `calculate_realized_pnl` 함수(app/domain/pnl.py)의 WAC 누적 로직을 재사용하거나, 동일 규칙을 확장한 신규 domain 함수로 구현한다.
|
||||
- 인증/역할: `require_read` 의존성(Permission.READ 이상) 적용. **service 토큰(`token_type="service"`)도 허용한다** — trex-core daemon이 DB service token으로 호출한다(OQ-1 참조).
|
||||
- 소유권 확인: admin이 아닌 user token 호출자는 자신이 소유한 portfolio만 조회 가능(비소유자 HTTP 403). service token(daemon)은 자신의 daemon_id가 `portfolio.managed_by_daemon_id`와 일치하는 portfolio만 조회 가능(불일치 HTTP 403).
|
||||
- 존재하지 않는 portfolio_id 요청은 HTTP 404를 반환해야 한다.
|
||||
- 보유수량이 0인 portfolio를 요청한 경우 HTTP 200 + `current_quantity=0.0`, `cost_basis_per_unit=null`, `total_cost_basis=null`을 반환한다(404 아님).
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given service token(daemon_id=D, portfolio.managed_by_daemon_id=D)으로 인증, portfolio에 buy 100주@10,000 + buy 100주@12,000 이력 존재(sell 없음), When `GET /v1/portfolios/{id}/holdings/cost-basis`, Then HTTP 200 + `current_quantity=200.0` + `cost_basis_per_unit=11000.0` (WAC = (100×10,000 + 100×12,000) / 200) + `total_cost_basis=2,200,000.0` + `data_completeness_warning=null`.
|
||||
2. Given service token(daemon_id=D, portfolio.managed_by_daemon_id=D)으로 인증, portfolio에 buy 200주@10,000 후 sell 150주 이력 존재, When 호출, Then HTTP 200 + `current_quantity=50.0` + `cost_basis_per_unit=10000.0` (WAC 불변) + `total_cost_basis=500,000.0`.
|
||||
3. Given user read_token(portfolio 소유자), When 호출, Then HTTP 200 + 정상 응답.
|
||||
4. Given user read_token(비소유자, admin 아님), When 타인 portfolio_id 조회, Then HTTP 403 반환.
|
||||
5. Given service token(daemon_id=D1, portfolio.managed_by_daemon_id=D2, D1≠D2), When 호출, Then HTTP 403 반환.
|
||||
6. Given service token(managed_by_daemon_id=null인 portfolio), When 호출, Then HTTP 403 반환 (daemon 미배정 portfolio에 daemon 접근 불가).
|
||||
7. Given 존재하지 않는 portfolio_id, When 호출, Then HTTP 404 반환.
|
||||
8. Given portfolio에 buy/sell 이력이 전혀 없음(current_quantity=0), When 호출, Then HTTP 200 + `current_quantity=0.0` + `cost_basis_per_unit=null` + `total_cost_basis=null` + `data_completeness_warning=null`.
|
||||
9. Given portfolio에 buy 이력 없이 sell 기록이 존재하는 이력 불완전 상황, When 호출, Then HTTP 200 + `data_completeness_warning` 필드에 비어 있지 않은 경고 문자열 포함.
|
||||
10. Given 인증 토큰 없음, When 호출, Then HTTP 401 반환.
|
||||
11. Given 구현 완료, When OpenAPI `/docs` 또는 `openapi.json` 확인, Then `GET /v1/portfolios/{portfolio_id}/holdings/cost-basis` 경로가 응답 스키마(`data_completeness_warning`, `cost_basis_per_unit`, `total_cost_basis` 포함)와 함께 문서화되어 있음.
|
||||
|
||||
## Impacted Area
|
||||
- Docs:
|
||||
- `docs/runtime-flows.md` — `GET /v1/portfolios/{id}/holdings/cost-basis` 흐름 추가
|
||||
- `docs/project-structure.md` — app/api/v1_portfolios.py 및 app/schemas/v1.py 책임 갱신
|
||||
- Modules:
|
||||
- `app/api/v1_portfolios.py` — GET 엔드포인트 추가 (소유권 확인 분기: user token vs service token)
|
||||
- `app/domain/pnl.py` — WAC-only(매도 결과 수집 없이 running_wac만 반환하는) 신규 함수 추가, 또는 `calculate_realized_pnl` 반환값에서 running_wac 직접 추출하도록 확장 (구현 단계에서 결정)
|
||||
- `app/repositories/postgres.py` — `get_all_trades_for_pnl` 재사용 (신규 SQL 메서드 불필요할 수 있음 — 구현 단계 확인)
|
||||
- `app/repositories/portfolio_repository.py` — 신규 abstract method 추가 여부 구현 단계 판단
|
||||
- `app/schemas/v1.py` — `HoldingsCostBasisResponse` 스키마 신규 추가
|
||||
- Tests:
|
||||
- `tests/test_v1_portfolios_holdings.py` 신규 — AC-1~11 커버
|
||||
|
||||
## Notes
|
||||
- **근거 Discovery**: [dcy-008_20260625_current-holding-wac-cost-basis-read](../../discovery/dcy-008_20260625_current-holding-wac-cost-basis-read.md) — 사장님 confirmed 2026-06-25. OQ-1/OQ-2/OQ-3 전부 Answered. 본 REQ는 dcy-008의 파생 요구사항이며 양방향 링크(dcy-008 "생성된 REQ 참조: REQ-043" ↔ 본 REQ "근거 Discovery: dcy-008").
|
||||
- **선행 의존(upstream blocker)**: trex-core dcy-034 FR-7. 이 REQ가 Implemented가 되기 전에 dcy-034 구현이 완료될 수 없다. batch 계획 시 trex-core 팀에 의존 관계 고지 필요.
|
||||
- **OQ-1(service token 소유권 확인 정책) — 해소(팀장 confirm-req, 2026-06-25)**: service token 소유권 정책을 `daemon_id == portfolio.managed_by_daemon_id` 일치 시만 허용, 불일치/null은 HTTP 403으로 **확정**한다. 해소 근거: trex-core cold-start는 `snapshot_scheduler.run_once`에서 `whoami → get_portfolios(daemon_id)` 순으로 이미 자기 daemon에 배정된 포트폴리오만 조회한다. 그 포트폴리오들은 `managed_by_daemon_id == 호출 daemon`이 성립하므로 소유권 정책(REQ-029 선례)이 그대로 적용된다. `managed_by_daemon_id=null` 미배정 케이스는 cold-start 경로에 없어 blocker 아님. 구현 시 추가 완화 불필요.
|
||||
- **OQ-2(domain 함수 확장 vs 신규) — 구현 단계 위임(팀장 confirm-req, 2026-06-25)**: `calculate_current_wac` 신규 함수 분리(단일책임 원칙) 를 **권고하되**, 구현 옵션((a) 기존 함수 파라미터 확장 vs (b) 신규 함수 분리) 선택은 **구현 단계(design/implementation) 재량으로 위임**한다. REQ에서 강제하지 않는다. domain 계층 순수성 유지는 필수.
|
||||
- **current_quantity 출처**: `portfolios.asset_quantity`(테이블 컬럼)를 직접 읽는다. 이 값은 ledger_trade 기록 시 `asset_quantity_after`로 동기화되므로 신뢰 가능하다. WAC는 ledger replay로 독립 계산하므로, 두 값의 수량 불일치 시 `data_completeness_warning` 조건이 트리거된다.
|
||||
- **GET /pnl/realized와의 차이**: REQ-040은 지정 기간 내 **매도 완료된** 실현 손익을 반환. 이 REQ는 **현재 보유 중인** 미매도분의 현재 평단을 반환. 개념적으로 보완 관계이며 상호 독립적이다.
|
||||
- Source: trex-core dcy-034 FR-7 (cold-start cost basis seed 선행 의존). 근거 Discovery: dcy-008 (portfolio-hub측 정식화).
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260625-02
|
||||
- Date: 2026-06-25
|
||||
- Author: Justin Ko (portfolio-hub-dev)
|
||||
|
||||
#### Change Summary
|
||||
- 팀장 confirm-req 결정 반영 — 상태 `Proposed` → `Approved`.
|
||||
|
||||
#### Intent
|
||||
- dcy-008 근거 Discovery 양방향 링크 명시. OQ-1(service token 소유권 blocker) 해소 확정(trex-core cold-start 경로 분석으로 `managed_by_daemon_id=null` 케이스 cold-start 경로에 없음 확인, daemon scope 소유권 정책 유지). OQ-2(domain 함수) 구현 단계 재량 위임 확정.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- No change (AC-1~11 유지).
|
||||
|
||||
#### Impacted Area Delta
|
||||
- No change.
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: none (미정)
|
||||
- Releases: none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: Approved
|
||||
- Follow-up Action: suggest-batch-reqs → batch 계획 편입.
|
||||
|
||||
#### Validation Plan
|
||||
- 이전 CHG-20260625-01 validation plan 유지 (pytest TestClient AC-1~10, AC-11 OpenAPI 확인).
|
||||
|
||||
---
|
||||
|
||||
### CHG-20260625-01
|
||||
- Date: 2026-06-25
|
||||
- Author: Justin Ko (portfolio-hub-dev)
|
||||
|
||||
#### Change Summary
|
||||
- 최초 생성 — trex-core dcy-034 cold-start onboarding(FR-7) 선행 의존으로 draft-req 실행. 현재 미매도 보유 평단(WAC) read 엔드포인트 신설.
|
||||
|
||||
#### Intent
|
||||
- trex-core가 실계좌 보유 종목을 슬롯 onboarding 시 portfolio-hub에서 평단을 seed받기 위한 Interface REQ 신설. REQ-040/041의 WAC·경고 패턴 재사용. service token 소유권 정책은 REQ-029 선례 따름.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1~11 최초 작성
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs: docs/runtime-flows.md, docs/project-structure.md — 최초 지정
|
||||
- Modules: app/api/v1_portfolios.py, app/domain/pnl.py, app/repositories/postgres.py, app/schemas/v1.py — 최초 지정
|
||||
- Tests: tests/test_v1_portfolios_holdings.py — 최초 지정
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: none (미정)
|
||||
- Releases: none (미정)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 후 batch 계획 편입. OQ-1(service token 정책) 팀장 확인 필요. dcy-034 trex-core 팀 의존 관계 고지 필요.
|
||||
|
||||
#### Validation Plan
|
||||
- pytest TestClient로 AC-1~10 검증(DB 불필요 단위 + mock repo). AC-11 OpenAPI 확인.
|
||||
+4
-6
@@ -74,7 +74,7 @@
|
||||
|
||||
## Next Requirement ID
|
||||
|
||||
- Current: `REQ-043`
|
||||
- Current: `REQ-044`
|
||||
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
||||
|
||||
## Requirement Register
|
||||
@@ -123,14 +123,12 @@
|
||||
| REQ-040 | GET /v1/portfolios/{id}/pnl/realized 실현 PnL 집계 엔드포인트 신설 | Interface | Implemented | High | Justin Ko | [req-040](./Interface/req-040_realized-pnl-query-endpoint.md) |
|
||||
| REQ-041 | WAC 기반 cost_basis 산출 및 실현 PnL 계산 로직 (service/domain 계층 격리) | Integration | Implemented | High | Justin Ko | [req-041](./Integration/req-041_wac-cost-basis-pnl-calculation.md) |
|
||||
| REQ-042 | 체결 내역/실현 PnL API 테스트 커버리지 (WAC 시나리오 기댓값 대조, 날짜 필터, 422) | Testing | Implemented | High | Justin Ko | [req-042](./Testing/req-042_trade-pnl-api-test-coverage.md) |
|
||||
| REQ-043 | GET /v1/portfolios/{id}/holdings/cost-basis 현재 보유 평단(WAC) read 엔드포인트 신설 | Interface | Approved | High | Justin Ko | [req-043](./Interface/req-043_holdings-cost-basis-read-endpoint.md) |
|
||||
|
||||
## Recent Change Log Summary
|
||||
|
||||
- 2026-06-25: REQ-043 `Proposed` → `Approved` (confirm-req 팀장 승인) — dcy-008 근거 Discovery 양방향 링크 확정. OQ-1(service token 소유권 blocker) 해소: cold-start는 `managed_by_daemon_id=daemon` 포트폴리오만 조회하므로 null 케이스 blocker 아님, daemon scope 소유권 정책(REQ-029 선례) 유지 확정. OQ-2(domain 함수) 구현 단계 재량 위임. AC-1~11 유지.
|
||||
- 2026-06-25: REQ-043 신규 (Proposed) — trex-core dcy-034 cold-start 평단 seed(FR-7) 선행 의존으로 draft-req 실행. GET /v1/portfolios/{id}/holdings/cost-basis 현재 보유 WAC read 엔드포인트 신설(Interface). OQ-1=service token 소유권 정책(daemon_id 일치 여부), OQ-2=domain 함수 확장 vs 신규 분리 — confirm-req에서 결정 필요.
|
||||
- 2026-06-18: REQ-039/040/041/042 `Approved` → `Implemented` (BAT-017 release-candidate, 독립 QC PASS-WITH-NOTES, 50 passed/전체 427 회귀없음, WAC 199,300 검증). 잔여 비차단: symbol 필터 edge 테스트·HTTP_422 deprecated.
|
||||
- 2026-06-18: REQ-039/040/041/042 `Proposed` → `Approved` (confirm-req 일괄 승인, dcy-007 결정 정합·R-1 완화 반영·blocker 없음, M1 fast-track 동일 batch).
|
||||
- 2026-06-18: REQ-039/040/041/042 신규 (Proposed) — dcy-007(일일 거래내역 + 실현 PnL 조회 API) draft-req 산출물. REQ-039 체결 내역 조회(Interface), REQ-040 실현 PnL 집계(Interface), REQ-041 WAC cost_basis 산출 로직(Integration), REQ-042 테스트 커버리지(Testing). OQ-1=WAC(사장님 결정), OQ-2=Out of Scope. R-1 경고 필드(data_completeness_warning + cost_basis_from)가 REQ-040 AC-5/AC-6 및 REQ-042 AC-1/AC-3에 반영.
|
||||
- 2026-06-05: REQ-036/037/038 Approved → Implemented (CHG-20260605-04/04/03) — BAT-016 confirm-batch-verification 완료. 전체 376 passed(실 DB 통합 3건 포함). verify 단계에서 hypertable ON CONFLICT 불가 + 공유 connection close 결함 2건 발견·수정, REQ-036/037 CHG-20260605-03으로 idempotency를 DB partial unique → application-level(SELECT-before-insert under FOR UPDATE)로 재정의. BAT-016 release-candidate. REL-003 staging 이연: alembic fresh-DB 적용 + 정밀도/action 분포.
|
||||
- 2026-06-05: REQ-036/037/038 Proposed → Approved (CHG-20260605-02 각각) — confirm-req 무인자 일괄 모드 실행, 승인 게이트(필수 필드/AC 구체성/Change Log) 모두 충족, blocker 없음. dcy-006 산출물 3건 모두 Approved. REQ-036(마이그레이션) 선행 의존성 명시.
|
||||
- 2026-06-05: REQ-036/037/038 신규 (Proposed) — dcy-006(ledger trade write 회계 책임 단일화) draft-req 산출물. REQ-036 portfolio_ledger/ledger_trades 통합 마이그레이션(Migration), REQ-037 신 path 회계 책임 단일화 + 응답에 cash_balance_after/asset_quantity_after 포함(Interface), REQ-038 레거시 buy/sell deprecated 표기(Interface). 3건 모두 별도 REL(REL-003 등) 분리 배포 예정(dcy-006 A-4).
|
||||
- 2026-06-05: REQ-033/034/035 Approved → Implemented (CHG-20260601-03 각각) — BAT-015 confirm-batch-verification 완료(2026-06-05). 전체 372 passed + 로컬 smoke 5건 통과. REQ-033 AC-1/AC-4 실 DB evidence는 REL-002 staging 이연(blocker 아님). BAT-015 status: release-candidate.
|
||||
- 2026-06-01: REQ-033/034/035 Proposed → Approved (CHG-20260601-02 각각) — confirm-req 무인자 일괄 모드 실행, 승인 게이트(필수 필드/AC 구체성/Change Log) 모두 충족, blocker 없음. dcy-005 산출물 3건 모두 Approved.
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
"""Tests for GET /v1/portfolios/{portfolio_id}/holdings/cost-basis (REQ-043).
|
||||
|
||||
Uses TestClient with MagicMock repo injection — no DB required.
|
||||
Covers AC-1~11 from REQ-043.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.domain.models import LedgerEntry, Portfolio, TokenPrincipal
|
||||
from app.main import create_app
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ENC_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
|
||||
_USER_ID = "user-001"
|
||||
_OTHER_USER_ID = "user-002"
|
||||
_PORTFOLIO_ID = "pf-holdings-001"
|
||||
_DAEMON_ID = "daemon-001"
|
||||
_OTHER_DAEMON_ID = "daemon-002"
|
||||
_PATH = f"/v1/portfolios/{_PORTFOLIO_ID}/holdings/cost-basis"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_settings() -> Settings:
|
||||
get_settings.cache_clear()
|
||||
return Settings(
|
||||
db_enabled=True,
|
||||
credential_encryption_key=_ENC_KEY,
|
||||
allowed_origins_raw="http://localhost:3000",
|
||||
)
|
||||
|
||||
|
||||
def _make_client(settings: Settings) -> TestClient:
|
||||
app = create_app(settings_factory=lambda: settings)
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _make_portfolio(
|
||||
*,
|
||||
owner: str = _USER_ID,
|
||||
symbol: str = "005930",
|
||||
asset_quantity: float = 0.0,
|
||||
managed_by_daemon_id: str | None = _DAEMON_ID,
|
||||
) -> Portfolio:
|
||||
return Portfolio(
|
||||
portfolio_id=_PORTFOLIO_ID,
|
||||
broker="kis",
|
||||
status="enabled",
|
||||
name="test-holdings-portfolio",
|
||||
symbol=symbol,
|
||||
asset_id="asset-001",
|
||||
owner_user_id=owner,
|
||||
cash_balance=1_000_000.0,
|
||||
asset_quantity=asset_quantity,
|
||||
tag_ids=[],
|
||||
managed_by_daemon_id=managed_by_daemon_id,
|
||||
)
|
||||
|
||||
|
||||
def _make_entry(
|
||||
*,
|
||||
ledger_entry_id: int,
|
||||
side: str,
|
||||
quantity: float,
|
||||
price: float,
|
||||
executed_at: datetime,
|
||||
) -> LedgerEntry:
|
||||
return LedgerEntry(
|
||||
ledger_entry_id=ledger_entry_id,
|
||||
portfolio_id=_PORTFOLIO_ID,
|
||||
order_id=f"ord-{ledger_entry_id:03d}",
|
||||
execution_id=f"exec-{ledger_entry_id:03d}",
|
||||
side=side,
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
fee_amount=0.0,
|
||||
tax_amount=0.0,
|
||||
executed_at=executed_at,
|
||||
recorded_at=executed_at,
|
||||
)
|
||||
|
||||
|
||||
def _make_service_repo(
|
||||
*,
|
||||
daemon_id: str | None = _DAEMON_ID,
|
||||
portfolio: Portfolio | None = None,
|
||||
trades: list[LedgerEntry] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Repo mock with service token principal."""
|
||||
repo = MagicMock()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t-svc",
|
||||
user_id=_USER_ID,
|
||||
permission="read",
|
||||
token_type="service",
|
||||
runtime_id="rt-1",
|
||||
daemon_id=daemon_id,
|
||||
)
|
||||
repo.get_token_principal.return_value = principal
|
||||
repo.get.return_value = portfolio or _make_portfolio()
|
||||
repo.get_all_trades_for_pnl.return_value = trades if trades is not None else []
|
||||
return repo
|
||||
|
||||
|
||||
def _make_user_repo(
|
||||
*,
|
||||
user_id: str = _USER_ID,
|
||||
permission: str = "read",
|
||||
portfolio: Portfolio | None = None,
|
||||
trades: list[LedgerEntry] | None = None,
|
||||
) -> MagicMock:
|
||||
"""Repo mock with user (DB) token principal."""
|
||||
repo = MagicMock()
|
||||
principal = TokenPrincipal(
|
||||
token_id="t-usr",
|
||||
user_id=user_id,
|
||||
permission=permission,
|
||||
token_type="db",
|
||||
runtime_id=None,
|
||||
daemon_id=None,
|
||||
)
|
||||
repo.get_token_principal.return_value = principal
|
||||
repo.get.return_value = portfolio or _make_portfolio()
|
||||
repo.get_all_trades_for_pnl.return_value = trades if trades is not None else []
|
||||
return repo
|
||||
|
||||
|
||||
def _with_repo(repo: MagicMock) -> ExitStack:
|
||||
stack = ExitStack()
|
||||
stack.enter_context(
|
||||
patch("app.api.v1_portfolios.get_portfolio_repository_from_settings", return_value=repo)
|
||||
)
|
||||
stack.enter_context(
|
||||
patch("app.core.auth.get_portfolio_repository_from_settings", return_value=repo)
|
||||
)
|
||||
return stack
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-1: service token + buy 100@10,000 + buy 100@12,000 → WAC=11,000, qty=200
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAC1WacBuysOnly:
|
||||
"""AC-1: buy-only portfolio → WAC = weighted average, no warning."""
|
||||
|
||||
def _make_trades(self) -> list[LedgerEntry]:
|
||||
return [
|
||||
_make_entry(
|
||||
ledger_entry_id=1, side="buy", quantity=100, price=10_000,
|
||||
executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
_make_entry(
|
||||
ledger_entry_id=2, side="buy", quantity=100, price=12_000,
|
||||
executed_at=datetime(2026, 6, 5, 9, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
def _make_portfolio_with_qty(self) -> Portfolio:
|
||||
return _make_portfolio(asset_quantity=200.0)
|
||||
|
||||
def test_status_200(self) -> None:
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo(
|
||||
portfolio=self._make_portfolio_with_qty(),
|
||||
trades=self._make_trades(),
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_current_quantity_200(self) -> None:
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo(
|
||||
portfolio=self._make_portfolio_with_qty(),
|
||||
trades=self._make_trades(),
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.json()["current_quantity"] == pytest.approx(200.0)
|
||||
|
||||
def test_cost_basis_per_unit_is_11000(self) -> None:
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo(
|
||||
portfolio=self._make_portfolio_with_qty(),
|
||||
trades=self._make_trades(),
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.json()["cost_basis_per_unit"] == pytest.approx(11_000.0)
|
||||
|
||||
def test_total_cost_basis_is_2_200_000(self) -> None:
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo(
|
||||
portfolio=self._make_portfolio_with_qty(),
|
||||
trades=self._make_trades(),
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.json()["total_cost_basis"] == pytest.approx(2_200_000.0)
|
||||
|
||||
def test_data_completeness_warning_is_null(self) -> None:
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo(
|
||||
portfolio=self._make_portfolio_with_qty(),
|
||||
trades=self._make_trades(),
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.json()["data_completeness_warning"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-2: buy 200@10,000 + sell 150 → remaining qty=50, WAC unchanged=10,000
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAC2PartialSellWacUnchanged:
|
||||
"""AC-2: partial sell → WAC unchanged, quantity reduced."""
|
||||
|
||||
def _make_trades(self) -> list[LedgerEntry]:
|
||||
return [
|
||||
_make_entry(
|
||||
ledger_entry_id=1, side="buy", quantity=200, price=10_000,
|
||||
executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
_make_entry(
|
||||
ledger_entry_id=2, side="sell", quantity=150, price=12_000,
|
||||
executed_at=datetime(2026, 6, 10, 9, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
def _make_portfolio_with_qty(self) -> Portfolio:
|
||||
return _make_portfolio(asset_quantity=50.0)
|
||||
|
||||
def test_current_quantity_50(self) -> None:
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo(
|
||||
portfolio=self._make_portfolio_with_qty(),
|
||||
trades=self._make_trades(),
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["current_quantity"] == pytest.approx(50.0)
|
||||
|
||||
def test_wac_unchanged_at_10000(self) -> None:
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo(
|
||||
portfolio=self._make_portfolio_with_qty(),
|
||||
trades=self._make_trades(),
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.json()["cost_basis_per_unit"] == pytest.approx(10_000.0)
|
||||
|
||||
def test_total_cost_basis_is_500_000(self) -> None:
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo(
|
||||
portfolio=self._make_portfolio_with_qty(),
|
||||
trades=self._make_trades(),
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.json()["total_cost_basis"] == pytest.approx(500_000.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-3: user read_token (owner) → 200 + normal response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ac3_user_owner_read_token_returns_200() -> None:
|
||||
"""AC-3: portfolio owner with user read token → 200."""
|
||||
settings = _make_settings()
|
||||
trades = [
|
||||
_make_entry(
|
||||
ledger_entry_id=1, side="buy", quantity=100, price=10_000,
|
||||
executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
repo = _make_user_repo(
|
||||
user_id=_USER_ID,
|
||||
portfolio=_make_portfolio(owner=_USER_ID, asset_quantity=100.0),
|
||||
trades=trades,
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["current_quantity"] == pytest.approx(100.0)
|
||||
assert body["cost_basis_per_unit"] == pytest.approx(10_000.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-4: user read_token non-owner → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ac4_user_non_owner_returns_403() -> None:
|
||||
"""AC-4: non-owner user token → 403."""
|
||||
settings = _make_settings()
|
||||
repo = _make_user_repo(
|
||||
user_id=_OTHER_USER_ID,
|
||||
portfolio=_make_portfolio(owner=_USER_ID),
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-5: service token daemon_id != managed_by_daemon_id → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ac5_daemon_mismatch_returns_403() -> None:
|
||||
"""AC-5: service token daemon_id != portfolio.managed_by_daemon_id → 403."""
|
||||
settings = _make_settings()
|
||||
# Portfolio is assigned to DAEMON_ID, but token is for OTHER_DAEMON_ID
|
||||
repo = _make_service_repo(
|
||||
daemon_id=_OTHER_DAEMON_ID,
|
||||
portfolio=_make_portfolio(managed_by_daemon_id=_DAEMON_ID),
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-6: service token, portfolio.managed_by_daemon_id=null → 403
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ac6_null_daemon_returns_403() -> None:
|
||||
"""AC-6: managed_by_daemon_id=null → 403 (daemon 미배정)."""
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo(
|
||||
daemon_id=_DAEMON_ID,
|
||||
portfolio=_make_portfolio(managed_by_daemon_id=None),
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-7: non-existent portfolio_id → 404
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ac7_nonexistent_portfolio_returns_404() -> None:
|
||||
"""AC-7: portfolio not found → 404."""
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo()
|
||||
repo.get.side_effect = ValueError("포트폴리오를 찾을 수 없습니다")
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-8: no buy/sell history (current_quantity=0) → 200 + null fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ac8_empty_ledger_returns_200_null_fields() -> None:
|
||||
"""AC-8: no history → 200 + current_quantity=0 + null cost basis fields."""
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo(
|
||||
portfolio=_make_portfolio(asset_quantity=0.0),
|
||||
trades=[],
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["current_quantity"] == pytest.approx(0.0)
|
||||
assert body["cost_basis_per_unit"] is None
|
||||
assert body["total_cost_basis"] is None
|
||||
assert body["data_completeness_warning"] is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-9: sell without prior buy → data_completeness_warning non-null
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ac9_sell_without_buy_triggers_warning() -> None:
|
||||
"""AC-9: sell before any buy → data_completeness_warning non-null."""
|
||||
settings = _make_settings()
|
||||
trades = [
|
||||
_make_entry(
|
||||
ledger_entry_id=1, side="sell", quantity=50, price=15_000,
|
||||
executed_at=datetime(2026, 6, 5, 9, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
repo = _make_service_repo(
|
||||
portfolio=_make_portfolio(asset_quantity=0.0),
|
||||
trades=trades,
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["data_completeness_warning"] is not None
|
||||
assert len(body["data_completeness_warning"]) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-10: no auth token → 401
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ac10_no_auth_token_returns_401() -> None:
|
||||
"""AC-10: missing Authorization header → 401."""
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo()
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC-11: endpoint documented in OpenAPI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ac11_endpoint_in_openapi() -> None:
|
||||
"""AC-11: GET /v1/portfolios/{portfolio_id}/holdings/cost-basis is in OpenAPI spec."""
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo()
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get("/openapi.json")
|
||||
assert resp.status_code == 200
|
||||
openapi = resp.json()
|
||||
paths = openapi.get("paths", {})
|
||||
endpoint_path = "/v1/portfolios/{portfolio_id}/holdings/cost-basis"
|
||||
assert endpoint_path in paths, f"Expected {endpoint_path!r} in OpenAPI paths"
|
||||
assert "get" in paths[endpoint_path]
|
||||
# Verify response schema contains expected fields
|
||||
get_op = paths[endpoint_path]["get"]
|
||||
assert "responses" in get_op
|
||||
assert "200" in get_op["responses"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional: response contains all required fields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_response_contains_all_required_fields() -> None:
|
||||
"""Response payload contains all REQ-043 specified fields."""
|
||||
settings = _make_settings()
|
||||
trades = [
|
||||
_make_entry(
|
||||
ledger_entry_id=1, side="buy", quantity=100, price=10_000,
|
||||
executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
repo = _make_service_repo(
|
||||
portfolio=_make_portfolio(asset_quantity=100.0),
|
||||
trades=trades,
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
for field in (
|
||||
"portfolio_id",
|
||||
"symbol",
|
||||
"current_quantity",
|
||||
"cost_basis_per_unit",
|
||||
"total_cost_basis",
|
||||
"cost_basis_from",
|
||||
"data_completeness_warning",
|
||||
):
|
||||
assert field in body, f"Missing field in response: {field}"
|
||||
|
||||
|
||||
def test_cost_basis_from_is_first_buy_date() -> None:
|
||||
"""cost_basis_from reflects the earliest buy executed_at."""
|
||||
settings = _make_settings()
|
||||
first_buy = datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)
|
||||
trades = [
|
||||
_make_entry(
|
||||
ledger_entry_id=1, side="buy", quantity=100, price=10_000,
|
||||
executed_at=first_buy,
|
||||
),
|
||||
_make_entry(
|
||||
ledger_entry_id=2, side="buy", quantity=50, price=12_000,
|
||||
executed_at=datetime(2026, 6, 5, 9, 0, tzinfo=timezone.utc),
|
||||
),
|
||||
]
|
||||
repo = _make_service_repo(
|
||||
portfolio=_make_portfolio(asset_quantity=150.0),
|
||||
trades=trades,
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
body = resp.json()
|
||||
assert body["cost_basis_from"] is not None
|
||||
|
||||
|
||||
def test_portfolio_id_in_response_matches_request() -> None:
|
||||
"""portfolio_id in response equals the path parameter."""
|
||||
settings = _make_settings()
|
||||
repo = _make_service_repo(
|
||||
portfolio=_make_portfolio(asset_quantity=0.0),
|
||||
trades=[],
|
||||
)
|
||||
with _with_repo(repo):
|
||||
client = _make_client(settings)
|
||||
resp = client.get(_PATH, headers={"Authorization": "Bearer tok"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["portfolio_id"] == _PORTFOLIO_ID
|
||||
Reference in New Issue
Block a user