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
+56 -1
View File
@@ -47,8 +47,38 @@ class PortfolioHubUnavailableError(PortfolioHubReadError):
"""portfolio-hub unreachable / 5xx / timeout — degraded signal upstream."""
# ---------------------------------------------------------------- realized PnL (REQ-109)
@dataclass(frozen=True)
class RealizedPnlItem:
"""Single sell-trade realized PnL item from portfolio-hub WAC response.
Maps to portfolio-hub RealizedPnlItem schema (REQ-040).
Minimum required: symbol, realized_pnl, sell_qty.
Additional fields preserved for completeness.
"""
symbol: Optional[str]
realized_pnl: float
sell_qty: float
cost_basis_per_unit: float = 0.0
fee_amount: float = 0.0
tax_amount: float = 0.0
@dataclass(frozen=True)
class RealizedPnlResult:
"""Aggregated WAC realized PnL result for one portfolio (REQ-109).
Returned by get_realized_pnl port method.
"""
items: list[RealizedPnlItem]
cost_basis_from: Optional[str] # ISO date string or None
data_completeness_warning: bool # True when portfolio-hub signals incomplete history
total_realized_pnl: float
class PortfolioHubReadPort(Protocol):
"""Read-side contract for portfolio-hub universe (REQ-027)."""
"""Read-side contract for portfolio-hub universe (REQ-027) + realized PnL (REQ-109)."""
def get_portfolios(self, daemon_id: str) -> list[PortfolioItem]:
"""Return all portfolios where `managed_by_daemon_id == daemon_id`."""
@@ -62,6 +92,31 @@ class PortfolioHubReadPort(Protocol):
"""
...
def get_realized_pnl(
self,
portfolio_id: str,
from_dt: str,
to_dt: str,
*,
symbol: Optional[str] = None,
) -> "RealizedPnlResult":
"""GET /v1/portfolios/{portfolio_id}/pnl/realized — WAC realized PnL (REQ-109).
Args:
portfolio_id: target portfolio.
from_dt: ISO date string (YYYY-MM-DD), sell-date inclusive start.
to_dt: ISO date string (YYYY-MM-DD), sell-date inclusive end.
symbol: optional symbol filter.
Returns:
RealizedPnlResult with per-sell items, total, cost_basis_from,
data_completeness_warning.
Raises:
PortfolioHubUnavailableError: on network error or non-2xx response.
"""
...
class DegradedReason(str, Enum):
"""Machine-readable degraded reason codes (REQ-028 AC-2, dcy-005 OQ-5)."""