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:
@@ -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 # 정상 종료
|
||||
Reference in New Issue
Block a user