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:
+11
-7
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user