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
+72 -4
View File
@@ -47,8 +47,17 @@ class OperatorReport:
holdings: list[Holding] = field(default_factory=list)
pending: list[PendingOrder] = field(default_factory=list)
fills_count: int = 0
# REQ-088 기존 당일 net (유지 — NFR-2 회귀 보존)
realized_pnl: float = 0.0
realized_pnl_by_asset: dict[str, float] = field(default_factory=dict)
# REQ-110 WAC 실현손익 (portfolio-hub 통합)
realized_pnl_wac: float = 0.0
realized_pnl_wac_by_asset: dict[str, float] = field(default_factory=dict)
buy_fee_only_assets: dict[str, float] = field(default_factory=dict)
data_completeness_warning: bool = False
# REQ-111 degrade flag
wac_unavailable: bool = False
wac_unavailable_reason: str = ""
def kst_day_bounds_utc(now: datetime | None = None) -> tuple[datetime, datetime]:
@@ -90,6 +99,39 @@ def build_report(
)
def apply_wac_to_report(
rep: OperatorReport,
*,
wac_total: float,
wac_by_asset: dict[str, float],
buy_fee_only_assets: dict[str, float],
data_completeness_warning: bool,
) -> None:
"""REQ-110: WAC 실현손익을 OperatorReport에 in-place로 적용한다.
순수 도메인 계층에서는 이 헬퍼가 아닌 build_report를 사용하고,
CLI(_cmd_report)에서 portfolio-hub 응답을 받아 이 함수로 주입한다.
"""
rep.realized_pnl_wac = wac_total
rep.realized_pnl_wac_by_asset = dict(wac_by_asset)
rep.buy_fee_only_assets = dict(buy_fee_only_assets)
rep.data_completeness_warning = data_completeness_warning
def buy_fee_by_asset(trades: list[TradeRow], *, exclude_symbols: set[str] | None = None) -> dict[str, float]:
"""OQ-3: 당일 매수 거래 fee 합산, 자산별 (매도 없는 자산 대상).
exclude_symbols: 매도 실현손익이 이미 WAC에 반영된 자산 집합.
"""
fee_map: dict[str, float] = {}
for t in trades:
if t.side == "buy":
fee_map[t.asset_id] = fee_map.get(t.asset_id, 0.0) + t.fee
if exclude_symbols:
return {k: v for k, v in fee_map.items() if k not in exclude_symbols}
return fee_map
def render_text(rep: OperatorReport) -> str:
"""사람용 표 요약."""
lines: list[str] = []
@@ -99,15 +141,30 @@ def render_text(rep: OperatorReport) -> str:
lines.append(f"=== 대기 주문 ({len(rep.pending)}) ===")
for p in rep.pending:
lines.append(f" {p.symbol} pf={p.portfolio_id} status={p.status}")
lines.append(f"=== 오늘 체결 {rep.fills_count}건 / 실현 PnL {rep.realized_pnl:,.0f} ===")
for asset, pnl in sorted(rep.realized_pnl_by_asset.items()):
lines.append(f" {asset}: {pnl:,.0f}")
lines.append(f"=== 오늘 체결 {rep.fills_count}건 ===")
# REQ-110 WAC 실현손익 섹션 (portfolio-hub 통합)
if rep.wac_unavailable:
lines.append(
f"=== WAC 실현 PnL: portfolio-hub unavailable — realized PnL unavailable ==="
)
if rep.wac_unavailable_reason:
lines.append(f" ({rep.wac_unavailable_reason})")
else:
lines.append(f"=== WAC 실현 PnL {rep.realized_pnl_wac:,.0f} ===")
for asset, pnl in sorted(rep.realized_pnl_wac_by_asset.items()):
lines.append(f" {asset}: {pnl:,.0f}")
if rep.data_completeness_warning:
lines.append(" [!] 이력 불완전 경고: cost basis 산출이 부정확할 수 있습니다.")
if rep.buy_fee_only_assets:
lines.append("=== 매수 전용 자산 수수료 ===")
for asset, fee in sorted(rep.buy_fee_only_assets.items()):
lines.append(f" {asset}: -{fee:,.0f}")
return "\n".join(lines)
def render_json(rep: OperatorReport) -> dict:
"""머신 파싱용(`--json`)."""
return {
base: dict = {
"holdings": [
{"symbol": h.symbol, "portfolio_id": h.portfolio_id,
"qty": h.qty, "avg_price": h.avg_price}
@@ -120,4 +177,15 @@ def render_json(rep: OperatorReport) -> dict:
"fills_count": rep.fills_count,
"realized_pnl": rep.realized_pnl,
"realized_pnl_by_asset": rep.realized_pnl_by_asset,
# REQ-110 신규 WAC 필드
"realized_pnl_wac": None if rep.wac_unavailable else rep.realized_pnl_wac,
"realized_pnl_wac_by_asset": None if rep.wac_unavailable else rep.realized_pnl_wac_by_asset,
"buy_fee_only_assets": None if rep.wac_unavailable else rep.buy_fee_only_assets,
"data_completeness_warning": None if rep.wac_unavailable else rep.data_completeness_warning,
}
# REQ-111: degrade 표기
if rep.wac_unavailable:
base["realized_pnl_wac_error"] = (
rep.wac_unavailable_reason or "portfolio-hub unavailable — realized PnL unavailable"
)
return base
+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)."""