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