# REQ-041: WAC 기반 cost_basis 산출 및 실현 PnL 계산 로직 (service/domain 계층 격리) - Status: Implemented - Type: Integration - Priority: High - Owner: Justin Ko ## Intent - portfolio_ledger의 buy/sell 기록에서 weighted average cost(WAC, 가중평균 취득단가) 방식으로 cost_basis를 계산하고, 매도 실현 손익 = (매도가 − 평균취득가) × 수량 − 수수료 − 세금 공식을 service 또는 domain 계층에 격리 구현한다(dcy-007 FR-3, OQ-1=WAC 확정). - repository(SQL) 계층과 PnL 계산 로직을 분리하여 단위 테스트가 DB 없이 가능하도록 한다(dcy-007 NFR-3). - R-1(이력 완전성) 완화를 위해 cost_basis 산출 시작 시점과 이력 불완전 감지 조건을 이 계층에서 판단하고 경고 신호를 반환한다. ## Requirement - 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 계산 규칙 (**매수 수수료 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 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 누적 가중평균(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: - `docs/project-structure.md` — app/services/ 또는 app/domain/ 책임 갱신(WAC PnL 산출 로직 추가 반영) - Modules: - `app/services/portfolio_service.py` 또는 `app/domain/pnl.py`(신규) — WAC cost_basis 계산 함수 구현 - `app/repositories/postgres.py` — WAC 산출을 위한 buy/sell 이력 조회 메서드(`get_all_trades_for_pnl(portfolio_id, from_, to_, symbol)`) 추가 — service 계층이 consume - Tests: - `tests/test_wac_calculation.py` 신규 — AC-1~8 순수 단위 테스트(DB 불필요) ## Notes - 구현 위치 결정(app/services/ vs app/domain/): 도메인 규칙으로서 domain 계층이 적절하나, 현행 코드 패턴(app/domain/services.py)을 따르는 것이 권장. 구현 단계에서 팀장 확인 필요. - repository는 PnL 집계 기간(`from`~`to`) 이전의 buy 이력도 cost_basis 산출을 위해 조회해야 한다. 즉, 집계 기간 내 매도가 있을 경우 그 이전 buy 이력 전부를 시간 순으로 함께 전달해야 WAC가 정확하다. 이 쿼리 범위 설계는 구현 단계에서 확인. - 이력 불완전 감지 조건은 현재 AC-3/AC-4의 2가지로 정의. 추가 조건은 구현 단계에서 확인. - FIFO 방식은 OQ-1에서 명시적으로 제외(WAC 확정). 이 REQ는 WAC 전용이다. - Source: dcy-007 FR-3, NFR-3, R-1, OQ-1(WAC 확정). ## 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 #### Change Summary - 최초 생성 — dcy-007 confirm-discovery 후 draft-req 실행. WAC PnL 산출 로직 격리 산출물. #### Intent - dcy-007 FR-3(WAC cost_basis 계산) + NFR-3(service/domain 계층 격리, 단위 테스트 가능)을 Integration REQ로 정규화. R-1 완화(경고 필드 조건)를 AC-3/AC-4에 반영. #### Acceptance Criteria Delta - Added: - AC-1~8 최초 작성 #### Impacted Area Delta - Docs: docs/project-structure.md — 최초 지정 - Modules: app/services/portfolio_service.py 또는 app/domain/pnl.py, app/repositories/postgres.py — 최초 지정 - Tests: tests/test_wac_calculation.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 계획 편입; REQ-040이 이 REQ에 의존하므로 동일 batch 편입 권장 #### Validation Plan - pytest tests/test_wac_calculation.py (DB 불필요 단위 테스트) + AC-7 grep 검증 ### CHG-20260618-02 - Date: 2026-06-18 - Author: 팀장 (Claude) #### Change Summary - confirm-req: `Proposed` → `Approved`. WAC 공식 확정(FIFO 제외), NFR-3 격리 AC-7/AC-8·R-1 완화 AC-3/AC-4 확인, blocker 없음. 구현 위치(domain vs service)는 구현 단계 결정. M1 fast-track. ### CHG-20260618-03 - Date: 2026-06-18 - Author: 팀장 (Claude) #### Change Summary - confirm-req-implemented: `Approved` → `Implemented`. Evidence: app/domain/pnl.py 구현, WAC 누적 정확성+199,300 검증, NFR-3 격리(psycopg import 0) QC 확인, R-1 경고 동작, test_wac_calculation.py 20 passed.