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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user