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
@@ -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-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/) |