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
+65 -1
View File
@@ -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: