diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index 32d9049..dc8d886 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -859,6 +859,7 @@ def get_holdings_cost_basis( "side": e.side, "quantity": e.quantity, "price": e.price, + "fee_amount": e.fee_amount, "executed_at": e.executed_at, } for e in ledger_entries diff --git a/app/domain/pnl.py b/app/domain/pnl.py index 4ebe684..2a0663b 100644 --- a/app/domain/pnl.py +++ b/app/domain/pnl.py @@ -84,9 +84,10 @@ def calculate_realized_pnl( WacPnlResult with per-sell PnL items, total, cost_basis_from, and data_completeness_warning when history is incomplete. - WAC Rules (REQ-041): - buy: running_wac = (running_wac * running_qty + price * qty) / (running_qty + qty) + WAC Rules (REQ-041, buy-fee-in-cost-basis): + buy: running_wac = (running_wac * running_qty + price * qty + buy_fee) / (running_qty + qty) running_qty += qty + (buy_fee = fee_amount of the buy row, incorporated into cost basis) sell: realized_pnl = (sell_price - running_wac) * qty - fee - tax running_qty -= qty Incomplete history (running_qty < sell_qty or sell with running_qty=0): @@ -114,10 +115,10 @@ def calculate_realized_pnl( execution_id: str | None = row.get("execution_id") if side == "buy": - # Update running WAC + # Update running WAC — include buy fee in cost basis (매수 수수료 편입) new_total_qty = running_qty + qty if new_total_qty > 0: - running_wac = (running_wac * running_qty + price * qty) / new_total_qty + running_wac = (running_wac * running_qty + price * qty + fee) / new_total_qty running_qty = new_total_qty # Track earliest buy for cost_basis_from if cost_basis_from is None and executed_at is not None: @@ -204,9 +205,10 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult: - 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) + WAC Rules (same as calculate_realized_pnl, REQ-041, buy-fee-in-cost-basis): + buy: running_wac = (running_wac * running_qty + price * qty + buy_fee) / (running_qty + qty) running_qty += qty + (buy_fee = fee_amount of the buy row, incorporated into cost basis) 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. @@ -228,12 +230,14 @@ def calculate_current_wac(rows: list[dict[str, Any]]) -> CurrentWacResult: side = str(row.get("side", "")).lower() qty = float(row.get("quantity", 0.0)) price = float(row.get("price", 0.0)) + fee = float(row.get("fee_amount", 0.0)) executed_at: datetime | None = row.get("executed_at") if side == "buy": + # Include buy fee in cost basis (매수 수수료 편입) new_total_qty = running_qty + qty if new_total_qty > 0: - running_wac = (running_wac * running_qty + price * qty) / new_total_qty + running_wac = (running_wac * running_qty + price * qty + fee) / 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 diff --git a/docs/srs/Integration/req-041_wac-cost-basis-pnl-calculation.md b/docs/srs/Integration/req-041_wac-cost-basis-pnl-calculation.md index e14c3a5..7eb9301 100644 --- a/docs/srs/Integration/req-041_wac-cost-basis-pnl-calculation.md +++ b/docs/srs/Integration/req-041_wac-cost-basis-pnl-calculation.md @@ -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 diff --git a/docs/srs/Interface/req-040_realized-pnl-query-endpoint.md b/docs/srs/Interface/req-040_realized-pnl-query-endpoint.md index 04b4501..fdbe0e1 100644 --- a/docs/srs/Interface/req-040_realized-pnl-query-endpoint.md +++ b/docs/srs/Interface/req-040_realized-pnl-query-endpoint.md @@ -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 단위만. 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 index 7989bd2..f04ee99 100644 --- a/docs/srs/Interface/req-043_holdings-cost-basis-read-endpoint.md +++ b/docs/srs/Interface/req-043_holdings-cost-basis-read-endpoint.md @@ -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 diff --git a/docs/srs/index.md b/docs/srs/index.md index d06f3b3..90cd449 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -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). diff --git a/tests/test_v1_portfolios_holdings.py b/tests/test_v1_portfolios_holdings.py index 4eafdc4..8ee0aec 100644 --- a/tests/test_v1_portfolios_holdings.py +++ b/tests/test_v1_portfolios_holdings.py @@ -78,6 +78,7 @@ def _make_entry( quantity: float, price: float, executed_at: datetime, + fee_amount: float = 0.0, ) -> LedgerEntry: return LedgerEntry( ledger_entry_id=ledger_entry_id, @@ -87,7 +88,7 @@ def _make_entry( side=side, quantity=quantity, price=price, - fee_amount=0.0, + fee_amount=fee_amount, tax_amount=0.0, executed_at=executed_at, recorded_at=executed_at, @@ -535,3 +536,68 @@ def test_portfolio_id_in_response_matches_request() -> None: resp = client.get(_PATH, headers={"Authorization": "Bearer tok"}) assert resp.status_code == 200 assert resp.json()["portfolio_id"] == _PORTFOLIO_ID + + +# --------------------------------------------------------------------------- +# QC보정: buy fee>0 케이스 — cost_basis_per_unit이 fee 편입값인지 확인 +# buy 10@1000 fee=5 → cost_basis_per_unit = (10*1000 + 5) / 10 = 1000.5 +# --------------------------------------------------------------------------- + +def test_cost_basis_per_unit_includes_buy_fee() -> None: + """buy fee>0 케이스: cost_basis_per_unit이 매수 fee 편입값을 반환한다. + + buy 10주 @ 1,000원, fee=5원 → WAC = (10*1000 + 5) / 10 = 1000.5 + REQ-043 Notes: 매수 fee를 cost basis에 포함. + """ + settings = _make_settings() + trades = [ + _make_entry( + ledger_entry_id=1, + side="buy", + quantity=10, + price=1_000, + fee_amount=5.0, + executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc), + ), + ] + repo = _make_service_repo( + portfolio=_make_portfolio(asset_quantity=10.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() + # (10 * 1000 + 5) / 10 = 1000.5 + assert body["cost_basis_per_unit"] == pytest.approx(1000.5) + assert body["total_cost_basis"] == pytest.approx(10_005.0) + + +def test_cost_basis_per_unit_zero_fee_unchanged() -> None: + """buy fee=0 케이스: fee 편입 추가 후에도 fee=0이면 기존 값 그대로. + + buy 10주 @ 1,000원, fee=0 → WAC = 1000.0 (회귀 확인) + """ + settings = _make_settings() + trades = [ + _make_entry( + ledger_entry_id=1, + side="buy", + quantity=10, + price=1_000, + fee_amount=0.0, + executed_at=datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc), + ), + ] + repo = _make_service_repo( + portfolio=_make_portfolio(asset_quantity=10.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["cost_basis_per_unit"] == pytest.approx(1000.0) + assert body["total_cost_basis"] == pytest.approx(10_000.0) diff --git a/tests/test_wac_calculation.py b/tests/test_wac_calculation.py index 26fffd6..5f58125 100644 --- a/tests/test_wac_calculation.py +++ b/tests/test_wac_calculation.py @@ -2,6 +2,9 @@ These tests have NO DB dependency and must pass without any database container. All inputs are plain Python dicts; no psycopg/DB imports are used. + +Note: WAC formula includes buy fee in cost basis (매수 수수료 편입, 2026-06-26). + buy: running_wac = (running_wac*qty + price*qty + buy_fee) / new_total_qty """ from __future__ import annotations @@ -10,7 +13,7 @@ from datetime import datetime, timezone import pytest -from app.domain.pnl import WacPnlResult, calculate_realized_pnl +from app.domain.pnl import WacPnlResult, calculate_current_wac, calculate_realized_pnl # --------------------------------------------------------------------------- # Fixtures @@ -255,6 +258,149 @@ class TestSymbolFilter: assert len(result.items) == 2 +# --------------------------------------------------------------------------- +# 매수 수수료 WAC 편입 — 단위 테스트 (2026-06-26 변경) +# --------------------------------------------------------------------------- + +class TestBuyFeeInCostBasis: + """buy fee를 WAC cost basis에 편입하는 공식 검증. + + 공식: running_wac = (running_wac*running_qty + price*qty + buy_fee) / new_total_qty + """ + + def test_single_buy_with_fee_realized_pnl_957(self) -> None: + """기준 케이스: buy 10@1000 fee=5, sell 10@1100 fee=5 tax=33. + + 편입 WAC = (1000*10 + 5)/10 = 1000.5 + realized = (1100 - 1000.5)*10 - 5 - 33 = 995 - 38 = 957 + """ + rows = [ + _row("buy", 10, 1000, fee=5.0, executed_at=_T0), + _row("sell", 10, 1100, fee=5.0, tax=33.0, executed_at=_T1), + ] + result = calculate_realized_pnl(rows) + assert len(result.items) == 1 + assert result.items[0].cost_basis_per_unit == pytest.approx(1000.5) + assert result.items[0].realized_pnl == pytest.approx(957.0) + assert result.total_realized_pnl == pytest.approx(957.0) + + def test_single_buy_without_fee_is_unchanged(self) -> None: + """buy fee=0이면 기존 공식과 동일 결과.""" + rows = [ + _row("buy", 100, 10_000, fee=0.0, executed_at=_T0), + _row("buy", 100, 12_000, fee=0.0, executed_at=_T1), + _row("sell", 50, 15_000, fee=500, tax=200, executed_at=_T2), + ] + result = calculate_realized_pnl(rows) + assert result.items[0].cost_basis_per_unit == pytest.approx(11_000.0) + assert result.total_realized_pnl == pytest.approx(199_300.0) + + def test_multi_buy_fee_weighted_average(self) -> None: + """다회 매수 시 fee가 WAC에 가중평균으로 반영된다. + + buy 100@10,000 fee=1,000 → WAC_1 = (0 + 10000*100 + 1000) / 100 = 10010.0 + buy 100@12,000 fee=1,200 → WAC_2 = (10010*100 + 12000*100 + 1200) / 200 + = (1001000 + 1200000 + 1200) / 200 + = 2202200 / 200 = 11011.0 + sell 50@15,000 fee=500 tax=200 + realized = (15000 - 11011)*50 - 500 - 200 = 199450 - 700 = 198750 + """ + rows = [ + _row("buy", 100, 10_000, fee=1_000.0, executed_at=_T0), + _row("buy", 100, 12_000, fee=1_200.0, executed_at=_T1), + _row("sell", 50, 15_000, fee=500.0, tax=200.0, executed_at=_T2), + ] + result = calculate_realized_pnl(rows) + assert len(result.items) == 1 + assert result.items[0].cost_basis_per_unit == pytest.approx(11_011.0) + assert result.total_realized_pnl == pytest.approx(198_750.0) + + def test_buy_fee_in_cost_basis_no_warning(self) -> None: + """매수 fee 편입 시에도 이력 완전한 경우 warning 없음.""" + rows = [ + _row("buy", 10, 1000, fee=5.0, executed_at=_T0), + _row("sell", 10, 1100, fee=5.0, tax=33.0, executed_at=_T1), + ] + result = calculate_realized_pnl(rows) + assert result.data_completeness_warning is None + + def test_partial_sell_cost_basis_per_unit_reflects_buy_fee(self) -> None: + """부분 매도 후 cost_basis_per_unit이 매수 fee 포함 WAC를 반영한다. + + buy 100@10,000 fee=500 → WAC = (10000*100 + 500)/100 = 10005.0 + sell 30@12,000 fee=100 tax=50 + realized = (12000 - 10005)*30 - 100 - 50 = 59850 - 150 = 59700 + """ + rows = [ + _row("buy", 100, 10_000, fee=500.0, executed_at=_T0), + _row("sell", 30, 12_000, fee=100.0, tax=50.0, executed_at=_T1), + ] + result = calculate_realized_pnl(rows) + assert result.items[0].cost_basis_per_unit == pytest.approx(10_005.0) + assert result.items[0].realized_pnl == pytest.approx(59_700.0) + + +class TestCurrentWacBuyFeeInCostBasis: + """calculate_current_wac도 매수 fee를 WAC에 편입한다.""" + + def test_buy_only_with_fee(self) -> None: + """buy 10@1000 fee=5 → current_wac = 1000.5.""" + from datetime import datetime, timezone + + rows = [ + {"side": "buy", "quantity": 10, "price": 1000, "fee_amount": 5.0, + "executed_at": datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)}, + ] + result = calculate_current_wac(rows) + assert result.current_quantity == pytest.approx(10.0) + assert result.current_wac == pytest.approx(1000.5) + + def test_buy_only_no_fee_unchanged(self) -> None: + """buy fee=0이면 calculate_current_wac 결과 변화 없음.""" + from datetime import datetime, timezone + + rows = [ + {"side": "buy", "quantity": 100, "price": 10_000, "fee_amount": 0.0, + "executed_at": datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)}, + {"side": "buy", "quantity": 100, "price": 12_000, "fee_amount": 0.0, + "executed_at": datetime(2026, 6, 5, 9, 0, tzinfo=timezone.utc)}, + ] + result = calculate_current_wac(rows) + assert result.current_wac == pytest.approx(11_000.0) + + def test_multi_buy_with_fee_current_wac(self) -> None: + """다회 매수 fee가 current_wac에 가중평균 반영된다. + + buy 100@10,000 fee=1,000 → WAC = 10010.0 + buy 100@12,000 fee=1,200 → WAC = 11011.0 + sell 150주 → remaining=50, WAC 변화 없음 = 11011.0 + """ + from datetime import datetime, timezone + + rows = [ + {"side": "buy", "quantity": 100, "price": 10_000, "fee_amount": 1_000.0, + "executed_at": datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)}, + {"side": "buy", "quantity": 100, "price": 12_000, "fee_amount": 1_200.0, + "executed_at": datetime(2026, 6, 5, 9, 0, tzinfo=timezone.utc)}, + {"side": "sell", "quantity": 150, "price": 13_000, "fee_amount": 0.0, + "executed_at": datetime(2026, 6, 10, 9, 0, tzinfo=timezone.utc)}, + ] + result = calculate_current_wac(rows) + assert result.current_quantity == pytest.approx(50.0) + assert result.current_wac == pytest.approx(11_011.0) + + def test_current_wac_missing_fee_amount_key_defaults_to_zero(self) -> None: + """fee_amount 키가 없으면 0으로 폴백 — 기존 동작 호환.""" + from datetime import datetime, timezone + + rows = [ + {"side": "buy", "quantity": 100, "price": 10_000, + "executed_at": datetime(2026, 6, 1, 9, 0, tzinfo=timezone.utc)}, + ] + result = calculate_current_wac(rows) + assert result.current_wac == pytest.approx(10_000.0) + + # --------------------------------------------------------------------------- # REQ-041 AC-7: no psycopg import in domain module (import-time check) # ---------------------------------------------------------------------------