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:
2026-06-26 20:34:03 +09:00
co-authored by Claude Sonnet 4.6
parent bd8ff1bf45
commit 73414817e0
6 changed files with 213 additions and 15 deletions
+147 -1
View File
@@ -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)
# ---------------------------------------------------------------------------