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
+3 -1
View File
@@ -43,4 +43,6 @@ htmlcov/
# OS junk # OS junk
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Agent worktree 격리 디렉토리 (커밋 금지)
.claude/worktrees/
+90 -1
View File
@@ -3,6 +3,7 @@
REQ-005, REQ-025~031: Portfolio CRUD, tags, comments. REQ-005, REQ-025~031: Portfolio CRUD, tags, comments.
REQ-039: GET /v1/portfolios/{id}/trades — trade history with date filter. REQ-039: GET /v1/portfolios/{id}/trades — trade history with date filter.
REQ-040: GET /v1/portfolios/{id}/pnl/realized — WAC realized PnL. 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 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.core.auth import AuthContext, Permission, require_read, require_trade_write
from app.domain.models import Portfolio, PortfolioComment 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.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import ( from app.schemas.v1 import (
LedgerTradeRequest, LedgerTradeRequest,
@@ -30,6 +31,7 @@ from app.schemas.v1 import (
PortfolioListResponse, PortfolioListResponse,
PortfolioTagListResponse, PortfolioTagListResponse,
PortfolioUpdateRequest, PortfolioUpdateRequest,
HoldingsCostBasisResponse,
RealizedPnlItem, RealizedPnlItem,
RealizedPnlResponse, RealizedPnlResponse,
TagApplyRequest, TagApplyRequest,
@@ -794,3 +796,90 @@ def get_realized_pnl(
cost_basis_from=result.cost_basis_from, cost_basis_from=result.cost_basis_from,
data_completeness_warning=result.data_completeness_warning, 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
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. 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. 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 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( def calculate_realized_pnl(
rows: list[dict[str, Any]], 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: def _normalize_tz(dt: datetime, reference: datetime) -> datetime:
"""Align timezone-awareness of dt to match reference. """Align timezone-awareness of dt to match reference.
+14
View File
@@ -296,3 +296,17 @@ class RealizedPnlResponse(BaseModel):
items: list[RealizedPnlItem] items: list[RealizedPnlItem]
cost_basis_from: datetime | None = None cost_basis_from: datetime | None = None
data_completeness_warning: str | 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` 대기.
+1
View File
@@ -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-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-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-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 플래그를 갱신한다.
+1
View File
@@ -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-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-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-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
View File
@@ -74,7 +74,7 @@
## Next Requirement ID ## Next Requirement ID
- Current: `REQ-043` - Current: `REQ-044`
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
## Requirement Register ## 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-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-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-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 ## 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 `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` → `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-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.
+537
View File
@@ -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