fix(pnl): 매수 수수료를 WAC cost basis에 편입 (REQ-041 CHG-20260626-01)
WAC 공식에 buy row의 fee_amount를 분자에 포함해 실현 PnL이 당일 net과 정확히 일치하도록 수정. calculate_realized_pnl + calculate_current_wac 양 함수 동일 적용. 신규 단위 테스트 9개(TestBuyFeeInCostBasis 5 + TestCurrentWacBuyFeeInCostBasis 4) 추가. change-req: REQ-041 AC-9/AC-10 추가, REQ-040/REQ-043 Notes 갱신, index 갱신. 전체 456 passed / 0 failed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -14,22 +14,26 @@
|
||||
- WAC cost_basis 계산 함수는 `app/services/` 또는 `app/domain/` 계층에 위치해야 하며, repository(psycopg/SQL) 직접 의존성 없이 순수 Python으로 구현해야 한다.
|
||||
- 입력: portfolio_ledger 행 목록(side, quantity, price, fee_amount, tax_amount, executed_at, order_id, execution_id 포함) — 시간 순 정렬.
|
||||
- 출력: 건별 매도에 대한 실현 PnL 계산 결과 목록, cost_basis_from(첫 buy 기록 시점), data_completeness_warning(이력 불완전 감지 시 경고 문자열, 없으면 None).
|
||||
- WAC 계산 규칙:
|
||||
- buy 시: `running_wac = (running_wac × running_qty + price × qty) / (running_qty + qty)`, `running_qty += qty`.
|
||||
- sell 시: `realized_pnl = (sell_price − running_wac) × qty − fee − tax`, `running_qty -= qty`.
|
||||
- WAC 계산 규칙 (**매수 수수료 cost basis 편입 포함, 2026-06-26 확정**):
|
||||
- buy 시: `running_wac = (running_wac × running_qty + price × qty + buy_fee) / (running_qty + qty)`, `running_qty += qty`.
|
||||
- `buy_fee`는 해당 buy 행의 `fee_amount`. 매수 수수료를 cost basis에 포함함으로써 실현 PnL이 "당일 net"과 정확히 일치한다.
|
||||
- sell 시: `realized_pnl = (sell_price − running_wac) × qty − sell_fee − tax`, `running_qty -= qty`.
|
||||
- sell fee는 running_wac에 편입하지 않고 sell 시 차감한다(기존 방식 유지).
|
||||
- 매도 수량이 보유 수량을 초과(running_qty < qty)하는 경우: 이력 불완전으로 판단하고 `data_completeness_warning` 반환 + 최선 추정으로 계속 계산(중단하지 않음).
|
||||
- cost_basis_from은 입력 행 목록에서 가장 이른 buy 기록의 executed_at으로 설정. buy 기록이 없으면 None.
|
||||
- 이 함수는 repository를 통해 얻은 원시 행 목록을 받으므로, repository는 관련 buy/sell 이력을 충분한 범위로 조회해 전달해야 한다(repository와 계층 경계 명시).
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given buy 100주@10,000 → buy 100주@12,000 → sell 50주@15,000 fee=500 tax=200 순 행 목록 입력, When WAC 계산 함수 호출, Then cost_basis_per_unit=11,000(WAC = (100×10,000 + 100×12,000) / 200) + realized_pnl=199,300((15,000-11,000)×50 - 500 - 200) 반환.
|
||||
1. Given buy 100주@10,000 fee=0 → buy 100주@12,000 fee=0 → sell 50주@15,000 fee=500 tax=200 순 행 목록 입력(buy fee=0이므로 기존 수치 유지), When WAC 계산 함수 호출, Then cost_basis_per_unit=11,000(WAC = (100×10,000 + 0 + 100×12,000 + 0) / 200) + realized_pnl=199,300((15,000-11,000)×50 - 500 - 200) 반환.
|
||||
2. Given buy 100주@10,000만 이력이 있고 sell 없음, When 함수 호출, Then items=[] + total_realized_pnl=0 + cost_basis_from=buy 시각 + data_completeness_warning=None 반환.
|
||||
3. Given 이력이 sell부터 시작(buy 이력 없음 = running_qty=0에서 sell 발생), When 함수 호출, Then data_completeness_warning에 비어 있지 않은 경고 문자열 반환 + 계산은 중단하지 않고 최선 추정 계속.
|
||||
4. Given 매도 수량이 현재 running_qty를 초과하는 경우, When 함수 호출, Then data_completeness_warning에 비어 있지 않은 경고 문자열 반환.
|
||||
5. Given 동일 포트폴리오에 여러 번 buy/sell 반복(시간 순 정렬 입력 10건), When 함수 호출, Then 각 sell 시점 WAC가 해당 시점까지의 buy 누적 가중평균을 정확히 반영.
|
||||
5. Given 동일 포트폴리오에 여러 번 buy/sell 반복(시간 순 정렬 입력 10건), When 함수 호출, Then 각 sell 시점 WAC가 해당 시점까지의 buy 누적 가중평균(buy fee 포함)을 정확히 반영.
|
||||
6. Given 입력 행 목록이 빈 배열, When 함수 호출, Then items=[] + total_realized_pnl=0 + cost_basis_from=None + data_completeness_warning=None 반환.
|
||||
7. Given 이 계산 함수가 `app/services/` 또는 `app/domain/` 계층에 위치, When `grep -rn "psycopg\|asyncpg\|psycopg2" app/services/ app/domain/` 실행, Then WAC 계산 함수 파일에서 DB 라이브러리 직접 import가 0건(순수 Python 격리 확인, NFR-3 반영).
|
||||
8. Given 계산 함수가 구현된 모듈, When 단위 테스트(`tests/test_wac_calculation.py`)를 pytest로 실행, Then DB 컨테이너 없이 통과(--no-db 마커 또는 DB 의존성 없는 pytest 실행).
|
||||
9. Given buy 10주@1,000 fee=5, sell 10주@1,100 fee=5 tax=33(일중 닫힌 거래), When WAC 계산 함수 호출, Then cost_basis_per_unit=1,000.5(WAC = (1000×10+5)/10) + realized_pnl=957((1100-1000.5)×10-5-33). 이 값이 "당일 net"(= 1100×10 - 1000×10 - 5 - 5 - 33 = 957)과 정확히 일치.
|
||||
10. Given calculate_current_wac 함수에 buy 10주@1,000 fee=5 입력, When 호출, Then current_wac=1,000.5(buy fee 편입) + current_quantity=10.
|
||||
|
||||
## Impacted Area
|
||||
- Docs:
|
||||
@@ -49,6 +53,48 @@
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260626-01
|
||||
- Date: 2026-06-26
|
||||
- Author: Justin Ko (portfolio-hub-dev)
|
||||
|
||||
#### Change Summary
|
||||
- WAC 공식에 **매수 수수료 cost basis 편입** 적용. 사장님 결정(2026-06-26): 매수 fee를 WAC에 포함해 실현 PnL이 "당일 net"과 정확히 일치하도록.
|
||||
|
||||
#### Intent
|
||||
- 기존 공식 `running_wac = (running_wac*qty + price*qty) / new_total_qty`에서 buy row의 `fee_amount`를 분자에 추가.
|
||||
신 공식: `running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty`
|
||||
- `calculate_realized_pnl`과 `calculate_current_wac` 양 함수 모두 동일하게 적용해 WAC 정의 일관성 유지.
|
||||
- trex-core dcy-035 멀티데이 운영리포트가 이 API를 소비하며, 일중 닫힌 거래에서 실현 PnL = 당일 net이 정확히 성립한다.
|
||||
- sell 분기의 `realized_pnl = (price - running_wac)*qty - sell_fee - tax` 공식 자체는 변경 없음. sell fee/tax는 그대로 차감 유지.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Changed:
|
||||
- AC-1: WAC 계산에 buy fee=0 명시 (기존 buy fee 0 가정을 명문화해 기댓값 수치 199,300 유지)
|
||||
- AC-5: "buy 누적 가중평균(buy fee 포함)을 정확히 반영"으로 명시
|
||||
- Added:
|
||||
- AC-9: 일중 닫힌 거래 기준 케이스 — buy 10@1000 fee=5, sell 10@1100 fee=5 tax=33 → realized=957, 당일 net=957 일치
|
||||
- AC-10: calculate_current_wac buy fee 편입 — buy 10@1000 fee=5 → current_wac=1000.5
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Modules:
|
||||
- `app/domain/pnl.py` — `calculate_realized_pnl` buy 분기 WAC 공식 갱신(fee 편입). `calculate_current_wac` buy 분기에 fee_amount 읽기 추가 및 동일 편입.
|
||||
- Tests:
|
||||
- `tests/test_wac_calculation.py` — `TestBuyFeeInCostBasis`(5개) + `TestCurrentWacBuyFeeInCostBasis`(4개) 신규 클래스 추가. 기존 테스트 기댓값 변경 없음(기존 케이스 모두 buy fee=0).
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches: 직접 batch 없음 — 사장님 지시 핫픽스 적용
|
||||
- Releases: none (미배포)
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: yes (기존 구현 공식이 바뀌므로 재검증)
|
||||
- Existing Implementation Invalidated: yes (매수 fee>0인 실제 거래에서 기존 realized_pnl 값이 부정확했음)
|
||||
- Status Recommendation: `Implemented` 유지 — 기존 테스트 기댓값은 모두 buy fee=0 케이스라 기존 테스트 PASS 그대로 유지. 신규 AC-9/AC-10 테스트 통과 확인됨(pytest 456 passed / 0 failed, 2026-06-26 실행).
|
||||
- Follow-up Action: REQ-040 Notes 갱신(WAC 정의 변경 참조 추가), REQ-043 Notes 갱신(동일). docs/srs/index.md Recent Change Log 갱신.
|
||||
|
||||
#### Validation Plan
|
||||
- pytest tests/test_wac_calculation.py — 29 passed (신규 TestBuyFeeInCostBasis 5개 + TestCurrentWacBuyFeeInCostBasis 4개 포함). DB 불필요.
|
||||
- pytest 전체 스위트 — 456 passed / 0 failed (2026-06-26 실행 확인).
|
||||
|
||||
### CHG-20260618-01
|
||||
- Date: 2026-06-18
|
||||
- Author: Justin Ko
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
- `tests/test_v1_portfolios_pnl.py` 신규 — AC-1~10 커버
|
||||
|
||||
## Notes
|
||||
- AC-1의 수치 근거: WAC = (100×10,000 + 100×12,000) / 200 = 11,000. 실현 PnL = (15,000 - 11,000) × 50 - 500 - 200 = 200,000 - 700 = 199,300.
|
||||
- AC-1의 수치 근거: WAC = (100×10,000 + 0 + 100×12,000 + 0) / 200 = 11,000(buy fee=0이므로 기존과 동일). 실현 PnL = (15,000 - 11,000) × 50 - 500 - 200 = 200,000 - 700 = 199,300.
|
||||
- **WAC 정의 변경(2026-06-26)**: REQ-041 CHG-20260626-01에 따라 WAC 공식에 매수 수수료가 편입됨. `running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty`. 이 엔드포인트가 반환하는 `cost_basis_per_unit` 및 `realized_pnl`은 해당 정의를 따른다. AC-1 수치는 buy fee=0인 케이스라 기댓값 불변.
|
||||
- `data_completeness_warning` 트리거 조건(이력 불완전 감지 기준)은 REQ-041 구현 단계에서 구체화. 현재 가정: portfolio 생성 시점보다 이른 buy 이력이 없거나, cost_basis 산출 대상 buy 합산 수량이 매도 수량보다 적을 경우.
|
||||
- 미실현 PnL(현재가 연동)은 Out of Scope(dcy-007 범위 경계 확인).
|
||||
- 전체 portfolio 집계 PnL 뷰(OQ-2 결정: Out of Scope) — 단일 portfolio 단위만.
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
- **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는 **현재 보유 중인** 미매도분의 현재 평단을 반환. 개념적으로 보완 관계이며 상호 독립적이다.
|
||||
- **WAC 정의 변경(2026-06-26)**: REQ-041 CHG-20260626-01에 따라 WAC 공식에 매수 수수료가 편입됨. `running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty`. `calculate_current_wac` 함수도 동일 정의를 따르므로 이 엔드포인트의 `cost_basis_per_unit` 역시 매수 fee 포함값이다. AC-1/AC-2의 기댓값은 buy fee=0인 케이스라 불변.
|
||||
- Source: trex-core dcy-034 FR-7 (cold-start cost basis seed 선행 의존). 근거 Discovery: dcy-008 (portfolio-hub측 정식화).
|
||||
|
||||
## Change Log
|
||||
|
||||
+1
-1
@@ -127,8 +127,8 @@
|
||||
|
||||
## Recent Change Log Summary
|
||||
|
||||
- 2026-06-26: REQ-041 WAC 공식 change-req(CHG-20260626-01) — 매수 수수료 cost basis 편입(사장님 결정). 신 공식: `running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty`. `calculate_realized_pnl`과 `calculate_current_wac` 양 함수 적용. AC-9/AC-10 추가. 신규 테스트 9개 포함 456 passed / 0 failed. REQ-040/REQ-043 Notes 참조 갱신.
|
||||
- 2026-06-26: REQ-043 `Approved` → `Implemented` — BAT-018 PR #14 squash-merge(d93c0bc) 팀장 QC PASS 후 origin/main 머지 완료. AC-1~11 전부 PASS(447 passed / 0 failed). 배포 별도(release-candidate).
|
||||
- 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).
|
||||
|
||||
Reference in New Issue
Block a user