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
+351
View File
@@ -0,0 +1,351 @@
"""CLI `report` 서브커맨드 WAC/degrade 통합 테스트 (REQ-110, REQ-111, dcy-035).
DB 및 네트워크를 monkeypatch로 대체하여 격리된 유닛 테스트로 실행한다.
실제 psycopg/httpx 연결이 필요하지 않다.
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from trex_core.application.portfolio import (
PortfolioHubUnavailableError,
RealizedPnlItem,
RealizedPnlResult,
)
from trex_core.runtime import cli
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _make_db_rows(holdings=None, pending=None, trades=None):
"""Mock cursor.fetchall() return values for DB queries."""
holdings = holdings or []
pending = pending or []
trades = trades or []
return [holdings, pending, trades]
def _fake_connect(db_rows):
"""Return a context-manager mock that yields a cursor serving fixed rows."""
mock_conn = MagicMock()
mock_cur = MagicMock()
fetchall_returns = iter(db_rows)
mock_cur.fetchall.side_effect = lambda: next(fetchall_returns)
mock_cur.__enter__ = lambda s: s
mock_cur.__exit__ = MagicMock(return_value=False)
mock_conn.__enter__ = lambda s: s
mock_conn.__exit__ = MagicMock(return_value=False)
mock_conn.cursor.return_value = mock_cur
return mock_conn
@pytest.fixture(autouse=True)
def _env(monkeypatch):
monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y")
monkeypatch.setenv("TREX_BROKER", "paper")
monkeypatch.delenv("TREX_KIS_ALLOW_LIVE", raising=False)
monkeypatch.delenv("TREX_PORTFOLIO_HUB_BASE_URL", raising=False)
monkeypatch.delenv("TREX_PORTFOLIO_HUB_TOKEN", raising=False)
# ---------------------------------------------------------------------------
# REQ-111 AC-4: base_url 미설정 → degrade
# ---------------------------------------------------------------------------
class TestReportDegradeNoBaseUrl:
"""portfolio-hub base_url 미설정 → REQ-111 degrade."""
def test_text_shows_unavailable(self, capsys, monkeypatch):
"""REQ-111 AC-4: base_url 미설정 → text 실현섹션 에러."""
db_rows = [
[("005930", "pf-1", 10, 70000.0)], # holdings
[], # pending
[], # trades
]
with patch("psycopg.connect", return_value=_fake_connect(db_rows)):
rc = cli.main(["report"])
assert rc == 0
out = capsys.readouterr().out
assert "portfolio-hub unavailable" in out
assert "현재 보유" in out
assert "005930" in out
def test_json_shows_null_wac_and_error(self, capsys, monkeypatch):
"""REQ-111 AC-4: base_url 미설정 → json에 null WAC + 에러 필드."""
db_rows = [
[("005930", "pf-1", 10, 70000.0)],
[],
[],
]
with patch("psycopg.connect", return_value=_fake_connect(db_rows)):
rc = cli.main(["report", "--json"])
assert rc == 0
j = json.loads(capsys.readouterr().out)
assert j["realized_pnl_wac"] is None
assert "realized_pnl_wac_error" in j
assert "unavailable" in j["realized_pnl_wac_error"]
# 타섹션 정상
assert j["holdings"][0]["symbol"] == "005930"
# ---------------------------------------------------------------------------
# REQ-110/111: portfolio-hub 설정 있을 때 WAC 경로 + degrade
# ---------------------------------------------------------------------------
class TestReportWacPath:
"""portfolio-hub 설정 + get_realized_pnl mock → WAC 경로."""
@pytest.fixture(autouse=True)
def _with_base_url(self, monkeypatch):
monkeypatch.setenv("TREX_PORTFOLIO_HUB_BASE_URL", "http://pf.local")
monkeypatch.setenv("TREX_PORTFOLIO_HUB_TOKEN", "test-tok")
def _mock_hub(self, result: RealizedPnlResult):
"""Return a mock PortfolioHubReadAdapter that returns fixed result."""
mock_adapter = MagicMock()
mock_adapter.get_realized_pnl.return_value = result
return mock_adapter
def test_wac_values_in_text(self, capsys):
"""REQ-110: WAC 경로 → text에 WAC 실현 PnL 표시."""
db_rows = [
[("005930", "pf-1", 10, 70000.0)], # holdings
[], # pending
[("pf-1", "sell", 10, 75000.0, 100.0, 225.0)], # trades
]
wac_result = RealizedPnlResult(
items=[RealizedPnlItem(
symbol="005930", realized_pnl=49675.0, sell_qty=10.0,
cost_basis_per_unit=70000.0, fee_amount=100.0, tax_amount=225.0,
)],
cost_basis_from="2026-06-01",
data_completeness_warning=False,
total_realized_pnl=49675.0,
)
mock_adapter = self._mock_hub(wac_result)
with (
patch("psycopg.connect", return_value=_fake_connect(db_rows)),
patch(
"trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter",
return_value=mock_adapter,
),
):
rc = cli.main(["report"])
assert rc == 0
out = capsys.readouterr().out
assert "WAC 실현 PnL" in out
assert "49,675" in out
# 에러 없음
assert "unavailable" not in out
def test_wac_json_has_all_four_fields(self, capsys):
"""REQ-110 AC-6: --json에 4 WAC 필드 포함."""
db_rows = [
[("005930", "pf-1", 10, 70000.0)],
[],
[],
]
wac_result = RealizedPnlResult(
items=[],
cost_basis_from=None,
data_completeness_warning=False,
total_realized_pnl=0.0,
)
mock_adapter = self._mock_hub(wac_result)
with (
patch("psycopg.connect", return_value=_fake_connect(db_rows)),
patch(
"trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter",
return_value=mock_adapter,
),
):
rc = cli.main(["report", "--json"])
assert rc == 0
j = json.loads(capsys.readouterr().out)
for field in ("realized_pnl_wac", "realized_pnl_wac_by_asset",
"buy_fee_only_assets", "data_completeness_warning"):
assert field in j, f"missing field: {field}"
def test_completeness_warning_in_text_and_json(self, capsys):
"""REQ-110 AC-4: completeness:true → text·json 경고 표면화."""
wac_result = RealizedPnlResult(
items=[],
cost_basis_from=None,
data_completeness_warning=True,
total_realized_pnl=0.0,
)
mock_adapter = self._mock_hub(wac_result)
# text 검사
db_rows_text = [
[("005930", "pf-1", 10, 70000.0)],
[],
[],
]
with (
patch("psycopg.connect", return_value=_fake_connect(db_rows_text)),
patch(
"trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter",
return_value=mock_adapter,
),
):
cli.main(["report"])
out_text = capsys.readouterr().out
assert "이력 불완전 경고" in out_text
# json 검사 (별도 DB 커넥션)
db_rows_json = [
[("005930", "pf-1", 10, 70000.0)],
[],
[],
]
with (
patch("psycopg.connect", return_value=_fake_connect(db_rows_json)),
patch(
"trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter",
return_value=mock_adapter,
),
):
cli.main(["report", "--json"])
out_json = capsys.readouterr().out
j = json.loads(out_json)
assert j["data_completeness_warning"] is True
def test_unavailable_error_degrade(self, capsys):
"""REQ-111 AC-1: get_realized_pnl Unavailable → text 에러섹션 + 타섹션 정상."""
db_rows = [
[("005930", "pf-1", 10, 70000.0)],
[("000660", "pf-2", "pending_buy")],
[],
]
mock_adapter = MagicMock()
mock_adapter.get_realized_pnl.side_effect = PortfolioHubUnavailableError("503")
with (
patch("psycopg.connect", return_value=_fake_connect(db_rows)),
patch(
"trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter",
return_value=mock_adapter,
),
):
rc = cli.main(["report"])
assert rc == 0
out = capsys.readouterr().out
assert "portfolio-hub unavailable" in out
# 타섹션 정상
assert "005930" in out
assert "현재 보유" in out
assert "대기 주문" in out
def test_unavailable_json_null_wac_other_sections_present(self, capsys):
"""REQ-111 AC-2: json 에러표기 + 타섹션 정상."""
db_rows = [
[("005930", "pf-1", 10, 70000.0)],
[],
[("pf-1", "sell", 5, 71000.0, 0.0, 0.0)],
]
mock_adapter = MagicMock()
mock_adapter.get_realized_pnl.side_effect = PortfolioHubUnavailableError("timeout")
with (
patch("psycopg.connect", return_value=_fake_connect(db_rows)),
patch(
"trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter",
return_value=mock_adapter,
),
):
rc = cli.main(["report", "--json"])
assert rc == 0
j = json.loads(capsys.readouterr().out)
assert j["realized_pnl_wac"] is None
assert "realized_pnl_wac_error" in j
# 타섹션
assert j["fills_count"] == 1
assert j["holdings"][0]["symbol"] == "005930"
def test_buy_only_asset_fee_displayed(self, capsys):
"""REQ-110 AC-3: 매수만 → 실현 0 + buy_fee_only_assets 표시."""
db_rows = [
[("000660", "pf-2", 5, 50000.0)], # holdings (보유 중)
[],
[("pf-2", "buy", 5, 50000.0, 250.0, 0.0)], # trades: 매수만
]
# get_realized_pnl: 당일 매도 없으므로 items=[], total=0
wac_result = RealizedPnlResult(
items=[],
cost_basis_from=None,
data_completeness_warning=False,
total_realized_pnl=0.0,
)
mock_adapter = self._mock_hub(wac_result)
with (
patch("psycopg.connect", return_value=_fake_connect(db_rows)),
patch(
"trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter",
return_value=mock_adapter,
),
):
rc = cli.main(["report"])
assert rc == 0
out = capsys.readouterr().out
assert "매수 전용 자산 수수료" in out
assert "pf-2" in out
def test_no_fallback_to_daily_net(self, capsys):
"""REQ-111 AC-3: degrade 시 당일 net 폴백 안 함.
holdings가 있어 target_pids가 비지 않고, get_realized_pnl이 Unavailable을
raise → wac_unavailable=True → realized_pnl_wac=null.
당일 net(realized_pnl)은 기존 필드로만 존재해야 한다.
"""
db_rows = [
[("005930", "pf-1", 0, 0.0)], # holdings: pf-1 존재
[],
[("pf-1", "sell", 1, 99999.0, 0.0, 0.0)], # 당일 sell net 큰 값
]
mock_adapter = MagicMock()
mock_adapter.get_realized_pnl.side_effect = PortfolioHubUnavailableError("down")
with (
patch("psycopg.connect", return_value=_fake_connect(db_rows)),
patch(
"trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter",
return_value=mock_adapter,
),
):
rc = cli.main(["report", "--json"])
j = json.loads(capsys.readouterr().out)
# WAC 필드는 null (당일 net 99999로 채워지지 않음)
assert j["realized_pnl_wac"] is None
# 당일 net은 기존 필드에 유지됨 (NFR-2)
assert j["realized_pnl"] == 99999.0
# ---------------------------------------------------------------------------
# REQ-111 AC-5: 예외 데몬 비전파 — _cmd_report는 예외를 반환값(0)으로 처리
# ---------------------------------------------------------------------------
class TestReportIsolation:
"""REQ-111 AC-5: report 예외가 데몬 루프로 전파되지 않음."""
@pytest.fixture(autouse=True)
def _with_base_url(self, monkeypatch):
monkeypatch.setenv("TREX_PORTFOLIO_HUB_BASE_URL", "http://pf.local")
def test_unavailable_does_not_raise(self):
"""PortfolioHubUnavailableError가 _cmd_report 밖으로 전파되지 않는다."""
db_rows = [[], [], []]
mock_adapter = MagicMock()
mock_adapter.get_realized_pnl.side_effect = PortfolioHubUnavailableError("down")
with (
patch("psycopg.connect", return_value=_fake_connect(db_rows)),
patch(
"trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter",
return_value=mock_adapter,
),
):
# 예외 전파 시 여기서 실패함
rc = cli.main(["report"])
assert rc == 0 # 정상 종료