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
+77
View File
@@ -22,6 +22,8 @@ import httpx
from trex_core.application.portfolio import (
PortfolioHubUnavailableError,
PortfolioItem,
RealizedPnlItem,
RealizedPnlResult,
)
@@ -120,6 +122,81 @@ class PortfolioHubReadAdapter:
))
return out
def get_realized_pnl(
self,
portfolio_id: str,
from_dt: str,
to_dt: str,
*,
symbol: Optional[str] = None,
) -> RealizedPnlResult:
"""REQ-109: GET /v1/portfolios/{id}/pnl/realized — WAC realized PnL.
Query params: from (alias for from_dt), to (alias for to_dt), symbol (optional).
Raises PortfolioHubUnavailableError on network error or non-2xx.
"""
url = f"{self._base_url}/v1/portfolios/{portfolio_id}/pnl/realized"
params: dict = {"from": from_dt, "to": to_dt}
if symbol is not None:
params["symbol"] = symbol
try:
resp = self._get(url, params=params)
except httpx.HTTPError as exc:
raise PortfolioHubUnavailableError(
f"get_realized_pnl network error: {exc.__class__.__name__}"
) from exc
if not (200 <= resp.status_code < 300):
raise PortfolioHubUnavailableError(
f"get_realized_pnl {resp.status_code}: {resp.text[:120]}"
)
try:
body = resp.json()
except ValueError as exc:
raise PortfolioHubUnavailableError("get_realized_pnl response not JSON") from exc
if not isinstance(body, dict):
raise PortfolioHubUnavailableError("get_realized_pnl response shape unexpected")
# Parse items
raw_items = body.get("items") or []
items: list[RealizedPnlItem] = []
for raw in raw_items:
if not isinstance(raw, dict):
continue
items.append(RealizedPnlItem(
symbol=raw.get("symbol") or None,
realized_pnl=float(raw.get("realized_pnl") or 0.0),
sell_qty=float(raw.get("quantity") or 0.0),
cost_basis_per_unit=float(raw.get("cost_basis_per_unit") or 0.0),
fee_amount=float(raw.get("fee_amount") or 0.0),
tax_amount=float(raw.get("tax_amount") or 0.0),
))
# total_realized_pnl: use response field if present, else sum items
raw_total = body.get("total_realized_pnl")
if raw_total is not None:
try:
total = float(raw_total)
except (TypeError, ValueError):
total = sum(it.realized_pnl for it in items)
else:
total = sum(it.realized_pnl for it in items)
# cost_basis_from: datetime str or None
cbf_raw = body.get("cost_basis_from")
cost_basis_from: Optional[str] = str(cbf_raw) if cbf_raw is not None else None
# data_completeness_warning: str|None → bool
dcw_raw = body.get("data_completeness_warning")
data_completeness_warning: bool = bool(dcw_raw)
return RealizedPnlResult(
items=items,
cost_basis_from=cost_basis_from,
data_completeness_warning=data_completeness_warning,
total_realized_pnl=total,
)
def get_cost_basis(self, portfolio_id: str) -> "Optional[float]":
"""REQ-105 FR-7: GET /v1/portfolios/{id}/holdings/cost-basis -> avg_cost (WAC).
+39 -2
View File
@@ -1,7 +1,13 @@
"""Deterministic stub for PortfolioHubReadPort (REQ-027 AC-2)."""
"""Deterministic stub for PortfolioHubReadPort (REQ-027 AC-2; REQ-109 stub)."""
from __future__ import annotations
from trex_core.application.portfolio import PortfolioItem
from typing import Optional
from trex_core.application.portfolio import (
PortfolioItem,
RealizedPnlItem,
RealizedPnlResult,
)
class StubPortfolioRead:
@@ -9,12 +15,16 @@ class StubPortfolioRead:
`whoami()` returns the configured daemon_id; tests can override the
universe by mutating `self.items`.
REQ-109: `get_realized_pnl` returns a deterministic fixed RealizedPnlResult.
Tests can override `self.realized_pnl_result` to inject custom fixtures.
"""
def __init__(
self,
daemon_id: str = "stub-daemon",
items: list[PortfolioItem] | None = None,
realized_pnl_result: Optional[RealizedPnlResult] = None,
) -> None:
self._daemon_id = daemon_id
self.items: list[PortfolioItem] = list(items or [
@@ -35,6 +45,22 @@ class StubPortfolioRead:
strategy_params={"window": 20},
),
])
# REQ-109: fixed realized PnL result for tests (overridable)
self.realized_pnl_result: RealizedPnlResult = realized_pnl_result or RealizedPnlResult(
items=[
RealizedPnlItem(
symbol="005930",
realized_pnl=50_000.0,
sell_qty=10.0,
cost_basis_per_unit=70_000.0,
fee_amount=500.0,
tax_amount=1_500.0,
),
],
cost_basis_from="2026-06-01",
data_completeness_warning=False,
total_realized_pnl=50_000.0,
)
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
# Stub does not filter by daemon_id — every test universe is scoped.
@@ -42,3 +68,14 @@ class StubPortfolioRead:
def whoami(self) -> str:
return self._daemon_id
def get_realized_pnl(
self,
portfolio_id: str,
from_dt: str,
to_dt: str,
*,
symbol: Optional[str] = None,
) -> RealizedPnlResult:
"""REQ-109 AC-4: deterministic fixed result, no network required."""
return self.realized_pnl_result