feat(bat-052): WAC 실현손익 리포트 통합 (REQ-109/110/111, dcy-035)

- portfolio.py: RealizedPnlItem/RealizedPnlResult 데이터클래스 추가;
  PortfolioHubReadPort에 get_realized_pnl 포트 메서드 추가
- hub_read.py: GET /v1/portfolios/{id}/pnl/realized 어댑터 구현
  (from/to/symbol query, 비2xx/네트워크→PortfolioHubUnavailableError)
- stub_read.py: get_realized_pnl 고정 결과 stub 추가 (무네트워크)
- operator_report.py: OperatorReport에 WAC 4필드+degrade 2필드 추가;
  apply_wac_to_report/buy_fee_by_asset 헬퍼; render_text/render_json WAC 섹션
- cli.py: _cmd_report에 WAC 경로(portfolio-hub 설정 시) + degrade 경로 통합
  (REQ-111: base_url 미설정/Unavailable → 식별가능 에러, 폴백 없음)
- tests: 51개 신규 테스트 (domain sweep, hub_read, stub_read, operator_report,
  cli_report_wac); 전체 717 passed, 47 skipped

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:35:17 +09:00
co-authored by Claude Sonnet 4.6
parent b9ee8edf8f
commit 73d3b35ab2
15 changed files with 1385 additions and 11 deletions
+280 -1
View File
@@ -1,11 +1,14 @@
"""운영 조회 리포트 집계 단위 테스트 (REQ-088, dcy-029 FR-5)."""
"""운영 조회 리포트 집계 단위 테스트 (REQ-088, dcy-029 FR-5; REQ-110/111, dcy-035)."""
from datetime import datetime, timezone
from trex_core.application.operator_report import (
Holding,
OperatorReport,
PendingOrder,
TradeRow,
apply_wac_to_report,
build_report,
buy_fee_by_asset,
kst_day_bounds_utc,
realized_pnl,
render_json,
@@ -74,3 +77,279 @@ class TestKstBounds:
assert start == datetime(2026, 6, 15, 15, 0, tzinfo=timezone.utc)
assert end == datetime(2026, 6, 16, 15, 0, tzinfo=timezone.utc)
assert start <= now < end
# ---------------------------------------------------------------------------
# REQ-110: WAC 실현손익 통합 (apply_wac_to_report, buy_fee_by_asset)
# ---------------------------------------------------------------------------
class TestApplyWacToReport:
"""REQ-110 AC-1/2/3/4/5/6: apply_wac_to_report 및 render_text/render_json"""
def _base_rep(self) -> OperatorReport:
return build_report(
holdings=[Holding("005930", "pf-1", 10, 70000)],
pending=[],
trades=[
TradeRow("pf-1", "buy", 10, 70000, fee=100),
TradeRow("pf-1", "sell", 10, 75000, fee=100, tax=225),
],
)
def test_wac_fields_applied(self):
"""REQ-110 AC-1: WAC 값이 report에 정확히 반영된다."""
rep = self._base_rep()
apply_wac_to_report(
rep,
wac_total=49675.0, # (75000-70000)*10 - 100 - 225
wac_by_asset={"005930": 49675.0},
buy_fee_only_assets={},
data_completeness_warning=False,
)
assert rep.realized_pnl_wac == 49675.0
assert rep.realized_pnl_wac_by_asset == {"005930": 49675.0}
assert rep.data_completeness_warning is False
assert rep.wac_unavailable is False
def test_wac_differs_from_net_when_prior_day_buy(self):
"""REQ-110 AC-1: 전일 매수 + 당일 매도 → WAC != 당일 net.
당일 net은 매도 대금만 반영(매수 없음), WAC는 취득원가 기준.
"""
rep = build_report(
holdings=[],
pending=[],
trades=[
# 당일 체결: 매도만 (전일 매수는 ledger_outbox에 없음)
TradeRow("pf-1", "sell", 10, 75000, fee=100, tax=225),
],
)
# 당일 net = 75000*10 - 100 - 225 = 749675
assert rep.realized_pnl == 749675.0
# WAC(전일 매수 70000 기준): (75000-70000)*10 - 100 - 225 = 49675
apply_wac_to_report(
rep,
wac_total=49675.0,
wac_by_asset={"005930": 49675.0},
buy_fee_only_assets={},
data_completeness_warning=False,
)
assert rep.realized_pnl_wac == 49675.0
assert rep.realized_pnl_wac != rep.realized_pnl # WAC != 당일 net
def test_wac_equals_net_when_same_day_buy_sell(self):
"""REQ-110 AC-2: 동일날 매수+매도(WAC=당일매수가) → WAC ≈ 당일 net.
일중 진입·청산 시 WAC 매수가 = 당일 net 매수 대금이므로 결과가 일치한다.
"""
rep = build_report(
holdings=[],
pending=[],
trades=[
TradeRow("A", "buy", 10, 1000, fee=5),
TradeRow("A", "sell", 10, 1100, fee=5, tax=33),
],
)
# 당일 net = (11000-5-33) - (10000+5) = 10962 - 10005 = 957
assert rep.realized_pnl == 957.0
# WAC = (1100-1000)*10 - 5 - 33 = 962 (fee for sell subtracted here)
# 단, 당일 매수 fee(5)는 buy_fee_only_assets 아닌 실현PnL에 반영됨
# 사실 WAC 계산: realized = (sell_price - wac) * qty - fee - tax
# wac = 1000 (동일날 매수가), qty=10, sell=1100, fee=5, tax=33
# realized = (1100-1000)*10 - 5 - 33 = 962
# 당일 net = sell대금 - buy대금 = (11000-5-33)-(10000+5) = 10962-10005 = 957
# 미세한 차이는 buy_fee 처리 방식 차이 — 여기서는 WAC 동일날 시나리오 확인
apply_wac_to_report(
rep,
wac_total=962.0,
wac_by_asset={"A": 962.0},
buy_fee_only_assets={},
data_completeness_warning=False,
)
# NFR-2: 당일 net(기존) 필드는 유지되고 독립적이다
assert rep.realized_pnl == 957.0
assert rep.realized_pnl_wac == 962.0
def test_buy_only_assets_has_fee(self):
"""REQ-110 AC-3: 매수만 있는 자산 → realized 0 + buy_fee_only_assets 표시."""
rep = build_report(
holdings=[Holding("000660", "pf-2", 10, 50000)],
pending=[],
trades=[TradeRow("pf-2", "buy", 10, 50000, fee=250)],
)
# 매도 없음 → sell_assets = {}
fee_map = buy_fee_by_asset(rep.holdings.__class__([]), # type: ignore
exclude_symbols=set())
fee_map2 = buy_fee_by_asset(
[TradeRow("pf-2", "buy", 10, 50000, fee=250)],
exclude_symbols=set(),
)
assert fee_map2 == {"pf-2": 250.0}
apply_wac_to_report(
rep,
wac_total=0.0,
wac_by_asset={},
buy_fee_only_assets={"pf-2": 250.0},
data_completeness_warning=False,
)
assert rep.realized_pnl_wac == 0.0
assert rep.buy_fee_only_assets == {"pf-2": 250.0}
# render_text에 buy_fee_only_assets 섹션 포함
text = render_text(rep)
assert "매수 전용 자산 수수료" in text
assert "pf-2" in text
def test_completeness_warning_in_text_and_json(self):
"""REQ-110 AC-4: completeness:true → text·json 모두 경고 표면화."""
rep = build_report([], [], [])
apply_wac_to_report(
rep,
wac_total=0.0,
wac_by_asset={},
buy_fee_only_assets={},
data_completeness_warning=True,
)
text = render_text(rep)
assert "이력 불완전 경고" in text
j = render_json(rep)
assert j["data_completeness_warning"] is True
def test_per_asset_breakdown_in_text(self):
"""REQ-110 AC-5: 자산별 분해."""
rep = build_report([], [], [])
apply_wac_to_report(
rep,
wac_total=100.0,
wac_by_asset={"005930": 80.0, "000660": 20.0},
buy_fee_only_assets={},
data_completeness_warning=False,
)
text = render_text(rep)
assert "005930" in text and "000660" in text
def test_render_json_has_all_four_wac_fields(self):
"""REQ-110 AC-6: --json에 신규 4필드 포함."""
rep = build_report([], [], [])
apply_wac_to_report(
rep,
wac_total=5000.0,
wac_by_asset={"X": 5000.0},
buy_fee_only_assets={},
data_completeness_warning=False,
)
j = render_json(rep)
assert "realized_pnl_wac" in j
assert "realized_pnl_wac_by_asset" in j
assert "buy_fee_only_assets" in j
assert "data_completeness_warning" in j
assert j["realized_pnl_wac"] == 5000.0
# ---------------------------------------------------------------------------
# REQ-111: portfolio-hub 미가용 degrade
# ---------------------------------------------------------------------------
class TestWacDegrade:
"""REQ-111 AC-1/2/3/4: wac_unavailable degrade."""
def test_degrade_text_shows_error_section(self):
"""REQ-111 AC-1: Unavailable → text 실현섹션 에러 + 타섹션 정상."""
rep = build_report(
holdings=[Holding("005930", "pf-1", 5, 70000)],
pending=[PendingOrder("000660", "pf-2", "pending_buy")],
trades=[],
)
rep.wac_unavailable = True
rep.wac_unavailable_reason = "portfolio-hub unavailable — realized PnL unavailable"
text = render_text(rep)
assert "portfolio-hub unavailable — realized PnL unavailable" in text
# 타섹션 정상 출력
assert "현재 보유" in text and "005930" in text
assert "대기 주문" in text and "000660" in text
# WAC 값은 없어야 함 (degrade 중)
assert "WAC 실현 PnL" in text # 에러 섹션 헤더
assert "이력 불완전" not in text # 경고는 없어야
def test_degrade_json_has_error_field_and_null_wac(self):
"""REQ-111 AC-2: json 에러표기 + 타섹션 정상."""
rep = build_report(
holdings=[Holding("005930", "pf-1", 5, 70000)],
pending=[],
trades=[TradeRow("pf-1", "sell", 5, 71000)],
)
rep.wac_unavailable = True
rep.wac_unavailable_reason = "portfolio-hub unavailable — realized PnL unavailable"
j = render_json(rep)
# WAC 필드는 null
assert j["realized_pnl_wac"] is None
assert j["realized_pnl_wac_by_asset"] is None
assert j["buy_fee_only_assets"] is None
assert j["data_completeness_warning"] is None
# 에러 필드 존재
assert "realized_pnl_wac_error" in j
assert "unavailable" in j["realized_pnl_wac_error"]
# 타섹션 정상
assert j["fills_count"] == 1
assert j["holdings"][0]["symbol"] == "005930"
def test_degrade_does_not_output_net_as_wac(self):
"""REQ-111 AC-3: 폴백 미발생 — 당일 net 값을 WAC 위치에 출력하지 않음."""
rep = build_report([], [], [TradeRow("A", "sell", 1, 1000)])
rep.wac_unavailable = True
rep.wac_unavailable_reason = "portfolio-hub unavailable — realized PnL unavailable"
text = render_text(rep)
j = render_json(rep)
# 당일 net(1000)이 WAC 섹션에 없어야 함
assert j["realized_pnl_wac"] is None
# text에서 WAC 실현 PnL 1,000 패턴 없어야 함
assert "WAC 실현 PnL 1,000" not in text
def test_wac_error_field_contains_unavailable_message(self):
"""REQ-111 AC-4: base_url 미설정 degrade도 동일 에러 구조."""
rep = build_report([], [], [])
rep.wac_unavailable = True
rep.wac_unavailable_reason = (
"portfolio-hub unavailable — realized PnL unavailable (base_url not configured)"
)
j = render_json(rep)
assert j["realized_pnl_wac_error"] is not None
assert "unavailable" in j["realized_pnl_wac_error"]
# ---------------------------------------------------------------------------
# NFR-2 회귀: 기존 realized_pnl / realized_pnl_by_asset 유지
# ---------------------------------------------------------------------------
class TestNfr2Regression:
"""NFR-2: 기존 당일 net 함수 및 필드 삭제 안 됨."""
def test_realized_pnl_function_still_works(self):
trades = [
TradeRow("A", "buy", 10, 1000, fee=5),
TradeRow("A", "sell", 10, 1100, fee=5, tax=33),
]
total, by_asset = realized_pnl(trades)
assert total == 957.0
assert by_asset == {"A": 957.0}
def test_build_report_still_populates_realized_pnl(self):
rep = build_report(
[],
[],
[TradeRow("A", "sell", 10, 1100, fee=5, tax=33)],
)
assert rep.realized_pnl == 1100 * 10 - 5 - 33
assert "A" in rep.realized_pnl_by_asset
def test_render_json_still_has_legacy_fields(self):
rep = build_report([], [], [TradeRow("A", "buy", 1, 100)])
j = render_json(rep)
assert "realized_pnl" in j
assert "realized_pnl_by_asset" in j