From d93c0bcaf52b593b33fed95df721213bc762b304 Mon Sep 17 00:00:00 2001 From: Jinhwan Ko Date: Fri, 26 Jun 2026 00:07:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(holdings):=20REQ-043=20=ED=98=84=EC=9E=AC?= =?UTF-8?q?=20=EB=B3=B4=EC=9C=A0=20=ED=8F=89=EB=8B=A8(WAC)=20read=20?= =?UTF-8?q?=E2=80=94=20GET=20/v1/portfolios/{id}/holdings/cost-basis=20(BA?= =?UTF-8?q?T-018,=20PR=20#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5 --- .gitignore | 4 +- app/api/v1_portfolios.py | 91 ++- app/domain/pnl.py | 102 +++- app/schemas/v1.py | 14 + .../implementation.md | 33 ++ .../index.md | 36 ++ .../planning.md | 60 ++ .../verification.md | 60 ++ docs/batches/index.md | 1 + ...625_current-holding-wac-cost-basis-read.md | 161 ++++++ docs/discovery/index.md | 1 + ...q-043_holdings-cost-basis-read-endpoint.md | 128 +++++ docs/srs/index.md | 10 +- tests/test_v1_portfolios_holdings.py | 537 ++++++++++++++++++ 14 files changed, 1229 insertions(+), 9 deletions(-) create mode 100644 docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/implementation.md create mode 100644 docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/index.md create mode 100644 docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/planning.md create mode 100644 docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/verification.md create mode 100644 docs/discovery/dcy-008_20260625_current-holding-wac-cost-basis-read.md create mode 100644 docs/srs/Interface/req-043_holdings-cost-basis-read-endpoint.md create mode 100644 tests/test_v1_portfolios_holdings.py diff --git a/.gitignore b/.gitignore index 3d36bfe..b6d0a84 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,6 @@ htmlcov/ # OS junk .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db +# Agent worktree 격리 디렉토리 (커밋 금지) +.claude/worktrees/ diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index 4295e2e..32d9049 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -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, + ) diff --git a/app/domain/pnl.py b/app/domain/pnl.py index 27d7b4b..4ebe684 100644 --- a/app/domain/pnl.py +++ b/app/domain/pnl.py @@ -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. diff --git a/app/schemas/v1.py b/app/schemas/v1.py index a975469..f116465 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -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 diff --git a/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/implementation.md b/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/implementation.md new file mode 100644 index 0000000..9bf6a5a --- /dev/null +++ b/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/implementation.md @@ -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 diff --git a/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/index.md b/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/index.md new file mode 100644 index 0000000..159fb4e --- /dev/null +++ b/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/index.md @@ -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 추출로 구현 결정이 단순함. diff --git a/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/planning.md b/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/planning.md new file mode 100644 index 0000000..f48dc67 --- /dev/null +++ b/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/planning.md @@ -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 분기는 핸들러 내 명시적 분기로 별도 처리. | diff --git a/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/verification.md b/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/verification.md new file mode 100644 index 0000000..df9ef95 --- /dev/null +++ b/docs/batches/bat-018_20260625_holdings-cost-basis-endpoint/verification.md @@ -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` 대기. diff --git a/docs/batches/index.md b/docs/batches/index.md index 722e603..a7ef079 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -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/) | diff --git a/docs/discovery/dcy-008_20260625_current-holding-wac-cost-basis-read.md b/docs/discovery/dcy-008_20260625_current-holding-wac-cost-basis-read.md new file mode 100644 index 0000000..ae1c9da --- /dev/null +++ b/docs/discovery/dcy-008_20260625_current-holding-wac-cost-basis-read.md @@ -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 플래그를 갱신한다. diff --git a/docs/discovery/index.md b/docs/discovery/index.md index cbbc575..265819d 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -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 | diff --git a/docs/srs/Interface/req-043_holdings-cost-basis-read-endpoint.md b/docs/srs/Interface/req-043_holdings-cost-basis-read-endpoint.md new file mode 100644 index 0000000..3bfdcaa --- /dev/null +++ b/docs/srs/Interface/req-043_holdings-cost-basis-read-endpoint.md @@ -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 확인. diff --git a/docs/srs/index.md b/docs/srs/index.md index c507ff8..2b2e701 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -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. diff --git a/tests/test_v1_portfolios_holdings.py b/tests/test_v1_portfolios_holdings.py new file mode 100644 index 0000000..4eafdc4 --- /dev/null +++ b/tests/test_v1_portfolios_holdings.py @@ -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