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
+140
View File
@@ -102,3 +102,143 @@ def test_get_portfolios_network_error_raises_unavailable():
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
with pytest.raises(PortfolioHubUnavailableError):
adapter.get_portfolios("d-7")
# ---------------------------------------------------------------------------
# REQ-109: get_realized_pnl
# ---------------------------------------------------------------------------
def test_get_realized_pnl_200_parses_correctly():
"""REQ-109 AC-1: 200 응답 → 올바른 RealizedPnlResult."""
captured = {}
def handler(req: httpx.Request) -> httpx.Response:
captured["url"] = str(req.url)
captured["params"] = dict(req.url.params)
return httpx.Response(200, json={
"total_realized_pnl": 49675.0,
"items": [
{
"symbol": "005930",
"executed_at": "2026-06-26T10:00:00+09:00",
"quantity": 10.0,
"sell_price": 75000.0,
"cost_basis_per_unit": 70000.0,
"fee_amount": 100.0,
"tax_amount": 225.0,
"realized_pnl": 49675.0,
"order_id": "ORD-1",
"execution_id": "EX-1",
}
],
"cost_basis_from": "2026-06-01T00:00:00+09:00",
"data_completeness_warning": None,
})
adapter = PortfolioHubReadAdapter("https://pf.test", token="tok",
client=_client(handler))
result = adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26")
assert captured["url"].startswith("https://pf.test/v1/portfolios/pf-1/pnl/realized")
assert captured["params"]["from"] == "2026-06-26"
assert captured["params"]["to"] == "2026-06-26"
assert result.total_realized_pnl == 49675.0
assert len(result.items) == 1
item = result.items[0]
assert item.symbol == "005930"
assert item.realized_pnl == 49675.0
assert item.sell_qty == 10.0
assert item.cost_basis_per_unit == 70000.0
assert item.fee_amount == 100.0
assert item.tax_amount == 225.0
assert result.data_completeness_warning is False
assert result.cost_basis_from is not None
def test_get_realized_pnl_symbol_param_included():
"""REQ-109 AC-2: symbol 제공 시 ?symbol= 포함."""
captured = {}
def handler(req: httpx.Request) -> httpx.Response:
captured["params"] = dict(req.url.params)
return httpx.Response(200, json={
"total_realized_pnl": 0.0,
"items": [],
"cost_basis_from": None,
"data_completeness_warning": None,
})
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26", symbol="005930")
assert captured["params"].get("symbol") == "005930"
def test_get_realized_pnl_no_symbol_param_absent():
"""REQ-109 AC-2: symbol 미제공 시 ?symbol= 없음."""
captured = {}
def handler(req: httpx.Request) -> httpx.Response:
captured["params"] = dict(req.url.params)
return httpx.Response(200, json={
"total_realized_pnl": 0.0,
"items": [],
"cost_basis_from": None,
"data_completeness_warning": None,
})
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26")
assert "symbol" not in captured["params"]
def test_get_realized_pnl_network_error_raises_unavailable():
"""REQ-109 AC-3: 네트워크 오류 → PortfolioHubUnavailableError."""
def handler(req):
raise httpx.ConnectError("network down")
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
with pytest.raises(PortfolioHubUnavailableError):
adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26")
def test_get_realized_pnl_non_2xx_raises_unavailable():
"""REQ-109 AC-3: 비2xx 응답 → PortfolioHubUnavailableError."""
def handler(req):
return httpx.Response(503, text="service unavailable")
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
with pytest.raises(PortfolioHubUnavailableError):
adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26")
def test_get_realized_pnl_data_completeness_warning_true():
"""REQ-109 AC-6: data_completeness_warning:true 반영."""
def handler(req):
return httpx.Response(200, json={
"total_realized_pnl": 0.0,
"items": [],
"cost_basis_from": None,
"data_completeness_warning": "이력 불완전: 보유 수량보다 많은 매도",
})
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
result = adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26")
assert result.data_completeness_warning is True
def test_get_realized_pnl_fallback_total_from_items_sum():
"""total_realized_pnl 필드 없으면 items 합산."""
def handler(req):
return httpx.Response(200, json={
# total_realized_pnl 필드 없음
"items": [
{"symbol": "A", "quantity": 1.0, "sell_price": 1000.0,
"cost_basis_per_unit": 900.0, "fee_amount": 5.0, "tax_amount": 0.0,
"realized_pnl": 95.0},
{"symbol": "B", "quantity": 2.0, "sell_price": 500.0,
"cost_basis_per_unit": 400.0, "fee_amount": 2.0, "tax_amount": 0.0,
"realized_pnl": 198.0},
],
"cost_basis_from": None,
"data_completeness_warning": None,
})
adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler))
result = adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26")
assert abs(result.total_realized_pnl - (95.0 + 198.0)) < 1e-6
+42 -2
View File
@@ -1,8 +1,8 @@
"""StubPortfolioRead tests (REQ-027 AC-2)."""
"""StubPortfolioRead tests (REQ-027 AC-2; REQ-109 AC-4 stub)."""
from __future__ import annotations
from trex_core.adapters.portfolio.stub_read import StubPortfolioRead
from trex_core.application.portfolio import PortfolioItem
from trex_core.application.portfolio import PortfolioItem, RealizedPnlResult
def test_stub_returns_fixed_universe_without_network():
@@ -32,3 +32,43 @@ def test_stub_items_are_mutable_for_tests():
PortfolioItem(portfolio_id="y", symbol="Y", cash_balance=2.0, asset_quantity=0)
)
assert len(stub.get_portfolios("d")) == 2
# ---------------------------------------------------------------------------
# REQ-109 AC-4: get_realized_pnl stub — 무네트워크 고정반환
# ---------------------------------------------------------------------------
def test_stub_get_realized_pnl_returns_fixed_result_without_network():
"""REQ-109 AC-4: stub은 네트워크 없이 고정 RealizedPnlResult 반환."""
stub = StubPortfolioRead()
result = stub.get_realized_pnl("pf-005930", "2026-06-26", "2026-06-26")
assert isinstance(result, RealizedPnlResult)
assert result.total_realized_pnl == 50_000.0
assert len(result.items) == 1
assert result.items[0].symbol == "005930"
assert result.data_completeness_warning is False
def test_stub_get_realized_pnl_with_symbol_param_still_works():
"""REQ-109 AC-4: symbol 인자 전달해도 stub은 동일 고정값 반환."""
stub = StubPortfolioRead()
result = stub.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26", symbol="005930")
assert isinstance(result, RealizedPnlResult)
def test_stub_realized_pnl_result_overridable():
"""REQ-109 AC-4: 테스트에서 realized_pnl_result 교체 가능."""
from trex_core.application.portfolio import RealizedPnlItem
custom = RealizedPnlResult(
items=[
RealizedPnlItem(symbol="000660", realized_pnl=12345.0, sell_qty=5.0),
],
cost_basis_from="2026-01-01",
data_completeness_warning=True,
total_realized_pnl=12345.0,
)
stub = StubPortfolioRead(realized_pnl_result=custom)
result = stub.get_realized_pnl("pf-2", "2026-06-26", "2026-06-26")
assert result.total_realized_pnl == 12345.0
assert result.data_completeness_warning is True
assert result.items[0].symbol == "000660"