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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -666,18 +666,22 @@ def _cmd_verify(args, cfg) -> int:
|
||||
|
||||
|
||||
def _cmd_report(args, cfg) -> int:
|
||||
"""REQ-088: holdings / today's fills / realized PnL / pending — direct-DB."""
|
||||
"""REQ-088/110/111: holdings / today's fills / WAC realized PnL / pending — direct-DB."""
|
||||
import psycopg
|
||||
from datetime import date, timedelta
|
||||
|
||||
from trex_core.application.operator_report import (
|
||||
Holding,
|
||||
PendingOrder,
|
||||
TradeRow,
|
||||
apply_wac_to_report,
|
||||
build_report,
|
||||
buy_fee_by_asset,
|
||||
kst_day_bounds_utc,
|
||||
render_json,
|
||||
render_text,
|
||||
)
|
||||
from trex_core.application.portfolio import PortfolioHubUnavailableError
|
||||
|
||||
start_utc, end_utc = kst_day_bounds_utc()
|
||||
holdings: list[Holding] = []
|
||||
@@ -709,6 +713,66 @@ def _cmd_report(args, cfg) -> int:
|
||||
float(fee or 0), float(tax or 0)))
|
||||
|
||||
rep = build_report(holdings, pending, trades)
|
||||
|
||||
# REQ-110: WAC 실현손익 통합 (portfolio-hub 설정 있으면 WAC 경로)
|
||||
# REQ-111: 미가용 시 degrade (폴백 없음)
|
||||
if cfg.portfolio_hub and cfg.portfolio_hub.base_url:
|
||||
try:
|
||||
from trex_core.adapters.portfolio.hub_read import PortfolioHubReadAdapter
|
||||
|
||||
hub = PortfolioHubReadAdapter(
|
||||
cfg.portfolio_hub.base_url, token=cfg.portfolio_hub.token
|
||||
)
|
||||
# OQ-2: 오늘(KST 일 경계) 기본값
|
||||
_KST = __import__("datetime").timezone(
|
||||
__import__("datetime").timedelta(hours=9)
|
||||
)
|
||||
from_date = start_utc.astimezone(_KST).date()
|
||||
from_dt_str = from_date.isoformat()
|
||||
to_dt_str = from_date.isoformat() # 당일
|
||||
|
||||
# OQ-4: portfolio_id별 N콜
|
||||
target_pids = (
|
||||
{h.portfolio_id for h in holdings}
|
||||
| {p.portfolio_id for p in pending}
|
||||
)
|
||||
|
||||
wac_total = 0.0
|
||||
wac_by_asset_accum: dict[str, float] = {}
|
||||
any_warning = False
|
||||
for pid in sorted(target_pids):
|
||||
result = hub.get_realized_pnl(pid, from_dt_str, to_dt_str)
|
||||
wac_total += result.total_realized_pnl
|
||||
if result.data_completeness_warning:
|
||||
any_warning = True
|
||||
for item in result.items:
|
||||
key = item.symbol or pid
|
||||
wac_by_asset_accum[key] = wac_by_asset_accum.get(key, 0.0) + item.realized_pnl
|
||||
|
||||
# OQ-3: buy_fee_only_assets = 당일 매수 거래 fee 합산 (매도 없는 자산 대상)
|
||||
sell_assets = {t.asset_id for t in trades if t.side == "sell"}
|
||||
buy_fee_only = buy_fee_by_asset(trades, exclude_symbols=sell_assets)
|
||||
|
||||
apply_wac_to_report(
|
||||
rep,
|
||||
wac_total=wac_total,
|
||||
wac_by_asset=wac_by_asset_accum,
|
||||
buy_fee_only_assets=buy_fee_only,
|
||||
data_completeness_warning=any_warning,
|
||||
)
|
||||
|
||||
except PortfolioHubUnavailableError as exc:
|
||||
rep.wac_unavailable = True
|
||||
rep.wac_unavailable_reason = (
|
||||
f"portfolio-hub unavailable — realized PnL unavailable ({exc})"
|
||||
)
|
||||
else:
|
||||
# REQ-111 AC-4: base_url 미설정도 동일 degrade
|
||||
rep.wac_unavailable = True
|
||||
rep.wac_unavailable_reason = (
|
||||
"portfolio-hub unavailable — realized PnL unavailable (base_url not configured)"
|
||||
)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
print(_json.dumps(render_json(rep), ensure_ascii=False))
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user