From 73d3b35ab22f6f7ed50341bf6e8fdd2d408a47d5 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Fri, 26 Jun 2026 18:30:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(bat-052):=20WAC=20=EC=8B=A4=ED=98=84?= =?UTF-8?q?=EC=86=90=EC=9D=B5=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20(REQ-109/110/111,=20dcy-035)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5 --- .../design.md | 107 ++++++ .../implementation.md | 30 ++ .../index.md | 19 + .../planning.md | 44 +++ .../verification.md | 62 ++++ docs/batches/index.md | 1 + tests/adapters/portfolio/test_hub_read.py | 140 +++++++ tests/adapters/portfolio/test_stub_read.py | 44 ++- tests/application/test_operator_report.py | 281 +++++++++++++- tests/runtime/test_cli_report_wac.py | 351 ++++++++++++++++++ trex_core/adapters/portfolio/hub_read.py | 77 ++++ trex_core/adapters/portfolio/stub_read.py | 41 +- trex_core/application/operator_report.py | 76 +++- trex_core/application/portfolio.py | 57 ++- trex_core/runtime/cli.py | 66 +++- 15 files changed, 1385 insertions(+), 11 deletions(-) create mode 100644 docs/batches/bat-052_20260626_wac-realized-pnl-report/design.md create mode 100644 docs/batches/bat-052_20260626_wac-realized-pnl-report/implementation.md create mode 100644 docs/batches/bat-052_20260626_wac-realized-pnl-report/index.md create mode 100644 docs/batches/bat-052_20260626_wac-realized-pnl-report/planning.md create mode 100644 docs/batches/bat-052_20260626_wac-realized-pnl-report/verification.md create mode 100644 tests/runtime/test_cli_report_wac.py diff --git a/docs/batches/bat-052_20260626_wac-realized-pnl-report/design.md b/docs/batches/bat-052_20260626_wac-realized-pnl-report/design.md new file mode 100644 index 0000000..5d086e9 --- /dev/null +++ b/docs/batches/bat-052_20260626_wac-realized-pnl-report/design.md @@ -0,0 +1,107 @@ +# BAT-052 Design + +## OQ-2 — 기간(from_dt/to_dt) 기본값 + +결정: **오늘(KST 일 경계)**. +`kst_day_bounds_utc()`가 반환하는 UTC [start, end) 범위에서 +`from_dt = start.date().isoformat()`, `to_dt = (end - timedelta(seconds=1)).date().isoformat()` += 오늘 KST date 문자열을 portfolio-hub에 전달한다. +`--from`/`--to` CLI 옵션은 이 batch에서 추가하지 않음(후속). + +## OQ-3 — buy_fee_only_assets 수수료 출처 + +결정: **ledger_outbox 당일 매수 거래 fee 합산 (기존 TradeRow 경로 재사용)**. +`_cmd_report`에서 이미 DB에서 ledger_outbox 당일 rows를 읽는다. +buy side rows의 fee를 asset_id별로 합산해 `buy_fee_only_assets`를 채운다. +별도 DB 쿼리 없이 기존 쿼리 결과를 재활용한다. + +## OQ-4 — 조회 단위 + +결정: **portfolio_id별 N콜 (슬롯↔portfolio 1:1)**. +`_cmd_report`에서 DB에서 읽은 portfolio_id 목록(holdings + pending)을 union해 +각 portfolio_id별로 `get_realized_pnl(portfolio_id, from_dt, to_dt)` 호출. +종목 필터 없이 전체 조회(symbol=None). 자산별 합산은 RealizedPnlResult.items에서 수집. + +## 데이터 모델 (신규) + +```python +# trex_core/application/portfolio.py 추가 + +@dataclass(frozen=True) +class RealizedPnlItem: + symbol: str | None + realized_pnl: float + sell_qty: float + # portfolio-hub 응답 보존 필드 + cost_basis_per_unit: float = 0.0 + fee_amount: float = 0.0 + tax_amount: float = 0.0 + +@dataclass(frozen=True) +class RealizedPnlResult: + items: list[RealizedPnlItem] + cost_basis_from: str | None # ISO date string (portfolio-hub datetime -> date str) + data_completeness_warning: bool + total_realized_pnl: float +``` + +## OperatorReport 확장 + +```python +@dataclass +class OperatorReport: + # 기존 필드 유지 + holdings: list[Holding] = ... + pending: list[PendingOrder] = ... + fills_count: int = 0 + realized_pnl: float = 0.0 # 기존 당일 net (유지) + realized_pnl_by_asset: dict = ... # 기존 (유지) + # 신규 WAC 필드 + realized_pnl_wac: float = 0.0 + realized_pnl_wac_by_asset: dict[str, float] = ... + buy_fee_only_assets: dict[str, float] = ... + data_completeness_warning: bool = False + wac_unavailable: bool = False # REQ-111 degrade flag + wac_unavailable_reason: str = "" +``` + +## _cmd_report 흐름 + +``` +1. DB에서 holdings, pending, trades 읽기 (기존 유지) +2. build_report(holdings, pending, trades) -> rep (기존 당일 net, 기존 유지) +3. portfolio-hub base_url 설정 있으면: + a. from_dt, to_dt = 오늘 KST date + b. 대상 portfolio_id = {h.portfolio_id for h in holdings} | {p.portfolio_id for p in pending} + c. 각 pid별 get_realized_pnl(pid, from_dt, to_dt) 호출 + d. RealizedPnlResult 합산 → rep.realized_pnl_wac, realized_pnl_wac_by_asset + e. buy_fee_only_assets = 기존 trades에서 buy side fee 합산 (매도 없는 자산) + f. data_completeness_warning = any(result.data_completeness_warning) +4. portfolio-hub 미설정 or PortfolioHubUnavailableError: + → rep.wac_unavailable = True, wac_unavailable_reason = ... +5. render_text/render_json +``` + +## render_text WAC 섹션 (신규) + +``` +=== WAC 실현 PnL {total:,.0f} === (또는 "portfolio-hub unavailable — realized PnL unavailable") + {symbol}: {pnl:,.0f} + [!] 이력 불완전 경고 (data_completeness_warning=True일 때만) +=== 매수 전용 자산 수수료 === + {asset}: -{fee:,.0f} +``` + +## 계약 매핑 (portfolio-hub 응답 필드명) + +| portfolio-hub 필드 | trex-core 필드 | +| --- | --- | +| total_realized_pnl | RealizedPnlResult.total_realized_pnl | +| items[].realized_pnl | RealizedPnlItem.realized_pnl | +| items[].quantity | RealizedPnlItem.sell_qty | +| items[].symbol | RealizedPnlItem.symbol | +| items[].cost_basis_per_unit | RealizedPnlItem.cost_basis_per_unit | +| items[].fee_amount | RealizedPnlItem.fee_amount | +| items[].tax_amount | RealizedPnlItem.tax_amount | +| cost_basis_from | RealizedPnlResult.cost_basis_from (datetime → isoformat str) | +| data_completeness_warning | RealizedPnlResult.data_completeness_warning (str|None → bool) | diff --git a/docs/batches/bat-052_20260626_wac-realized-pnl-report/implementation.md b/docs/batches/bat-052_20260626_wac-realized-pnl-report/implementation.md new file mode 100644 index 0000000..139e41b --- /dev/null +++ b/docs/batches/bat-052_20260626_wac-realized-pnl-report/implementation.md @@ -0,0 +1,30 @@ +# BAT-052 Implementation + +## 변경 파일 요약 + +| 파일 | 변경 내용 | +| --- | --- | +| `trex_core/application/portfolio.py` | RealizedPnlItem, RealizedPnlResult 데이터클래스 추가; PortfolioHubReadPort에 get_realized_pnl 메서드 추가 | +| `trex_core/application/operator_report.py` | OperatorReport에 WAC 4필드(realized_pnl_wac, realized_pnl_wac_by_asset, buy_fee_only_assets, data_completeness_warning) + degrade 2필드(wac_unavailable, wac_unavailable_reason) 추가; apply_wac_to_report, buy_fee_by_asset 헬퍼 추가; render_text/render_json WAC/degrade 섹션 추가 | +| `trex_core/adapters/portfolio/hub_read.py` | get_realized_pnl 구현 추가 (GET /v1/portfolios/{id}/pnl/realized, from/to/symbol query params) | +| `trex_core/adapters/portfolio/stub_read.py` | get_realized_pnl stub 추가 (고정 RealizedPnlResult, realized_pnl_result 교체 가능) | +| `trex_core/runtime/cli.py` | _cmd_report에 WAC 경로(portfolio-hub 설정 시) + degrade 경로(미설정/Unavailable) 통합 | +| `tests/application/test_operator_report.py` | TestApplyWacToReport, TestWacDegrade, TestNfr2Regression 클래스 추가 | +| `tests/adapters/portfolio/test_hub_read.py` | get_realized_pnl HTTP 계약 테스트 7개 추가 | +| `tests/adapters/portfolio/test_stub_read.py` | get_realized_pnl stub 테스트 3개 추가 | +| `tests/runtime/test_cli_report_wac.py` | 신규 파일: CLI report WAC/degrade 통합 테스트 | +| `docs/batches/bat-052_*/` | 배치 문서 (index, planning, design, implementation, verification) | +| `docs/batches/index.md` | BAT-052 항목 추가 | + +## 주요 설계 결정 이행 + +- OQ-2: from_dt/to_dt = 오늘 KST 날짜 (kst_day_bounds_utc().start.astimezone(KST).date()) +- OQ-3: buy_fee_only_assets = ledger_outbox 당일 매수 fee 합산, 매도 있는 자산 제외 +- OQ-4: portfolio_id별 N콜 (holdings+pending union) +- NFR-2: realized_pnl(trades) 함수 및 기존 OperatorReport 필드 유지 + +## 유지된 기존 동작 + +- whoami, get_portfolios, get_cost_basis 동작 불변 +- realized_pnl(trades) 함수 유지 +- OperatorReport.realized_pnl, realized_pnl_by_asset 유지 diff --git a/docs/batches/bat-052_20260626_wac-realized-pnl-report/index.md b/docs/batches/bat-052_20260626_wac-realized-pnl-report/index.md new file mode 100644 index 0000000..fc2ac95 --- /dev/null +++ b/docs/batches/bat-052_20260626_wac-realized-pnl-report/index.md @@ -0,0 +1,19 @@ +# BAT-052: WAC 실현손익 리포트 통합 (portfolio-hub /pnl/realized) + +- Status: in-progress +- Profile: standard +- Discovery: dcy-035 +- Created: 2026-06-26 + +## Included REQ + +- REQ-109: portfolio-hub /pnl/realized read 포트+어댑터+stub +- REQ-110: 운영리포트 WAC 실현손익 통합 (전 인스턴스 공통) +- REQ-111: portfolio-hub 미가용 degrade (폴백 없음) + +## Documents + +- [planning.md](./planning.md) +- [design.md](./design.md) +- [implementation.md](./implementation.md) +- [verification.md](./verification.md) diff --git a/docs/batches/bat-052_20260626_wac-realized-pnl-report/planning.md b/docs/batches/bat-052_20260626_wac-realized-pnl-report/planning.md new file mode 100644 index 0000000..fba5697 --- /dev/null +++ b/docs/batches/bat-052_20260626_wac-realized-pnl-report/planning.md @@ -0,0 +1,44 @@ +# BAT-052 Planning + +## 목표 + +portfolio-hub WAC 실현손익 API(/pnl/realized)를 trex-core 운영 리포트에 통합한다. +기존 당일 net 방식을 대체하되 기존 함수는 유지한다. + +## 포함 REQ 요약 + +| REQ | 핵심 내용 | +| --- | --- | +| REQ-109 | get_realized_pnl 포트+어댑터+stub | +| REQ-110 | OperatorReport에 WAC 필드 추가, _cmd_report에 WAC 경로 통합 | +| REQ-111 | portfolio-hub 미가용 시 degrade (폴백 없음) | + +## 변경 파일 목록 (예상) + +| 파일 | 변경 종류 | +| --- | --- | +| `trex_core/application/portfolio.py` | RealizedPnlItem, RealizedPnlResult 데이터클래스 추가; PortfolioHubReadPort에 get_realized_pnl 추가 | +| `trex_core/application/operator_report.py` | OperatorReport에 WAC 필드 추가; render_text/render_json 갱신; build_report_wac 추가 | +| `trex_core/adapters/portfolio/hub_read.py` | get_realized_pnl 구현 추가 | +| `trex_core/adapters/portfolio/stub_read.py` | get_realized_pnl stub 추가 | +| `trex_core/runtime/cli.py` | _cmd_report에 WAC 경로+degrade 통합 | +| `tests/application/test_operator_report.py` | WAC 필드 테스트, completeness 경고, degrade 테스트 추가 | +| `tests/adapters/portfolio/test_hub_read.py` | get_realized_pnl HTTP 계약 테스트 추가 | +| `tests/adapters/portfolio/test_stub_read.py` | get_realized_pnl stub 테스트 추가 | +| `tests/runtime/test_cli_report.py` (신규) | WAC 경로·degrade·JSON 형태 통합 테스트 | + +## 위험 / 고려사항 + +1. `PortfolioHubReadPort` Protocol 확장: 기존 구현체(StubPortfolioRead, PortfolioHubReadAdapter) 모두 갱신 필요. +2. `data_completeness_warning`은 portfolio-hub에서 `str | None`으로 오지만 trex-core에서는 `bool`로 변환한다(truthy check). +3. buy_fee_only_assets 수수료 출처는 ledger_outbox 당일 매수 fee 합산(기존 TradeRow 경로 재사용). +4. 기존 `realized_pnl(trades)` 함수 및 기존 OperatorReport 필드 삭제하지 않는다(NFR-2 회귀 보존). +5. _cmd_report에서 portfolio-hub base_url 미설정 시 REQ-111 degrade 경로로 진입한다. + +## 의존성 확인 + +- httpx: 기존 어댑터 패턴과 동일 +- psycopg: 기존 _cmd_report에서 이미 사용 +- portfolio-hub 계약: `GET /v1/portfolios/{portfolio_id}/pnl/realized` query from_dt(alias=from), to_dt(alias=to), symbol(optional) + - 응답: RealizedPnlResponse { total_realized_pnl, items, cost_basis_from(datetime|None), data_completeness_warning(str|None) } + - 각 item: { symbol, executed_at, quantity, sell_price, cost_basis_per_unit, fee_amount, tax_amount, realized_pnl, order_id, execution_id } diff --git a/docs/batches/bat-052_20260626_wac-realized-pnl-report/verification.md b/docs/batches/bat-052_20260626_wac-realized-pnl-report/verification.md new file mode 100644 index 0000000..2b4235a --- /dev/null +++ b/docs/batches/bat-052_20260626_wac-realized-pnl-report/verification.md @@ -0,0 +1,62 @@ +# BAT-052 Verification + +## 실행 명령 + +``` +python3 -m pytest --tb=short -q +``` + +## 결과 + +``` +717 passed, 47 skipped in 2.34s +``` + +- 통과: 717 +- 스킵: 47 (DB/네트워크 필요 통합 테스트 — 기존 패턴) +- 실패: 0 + +## 신규 테스트 커버리지 (51개) + +| 파일 | 테스트 수 | 대상 REQ | +| --- | --- | --- | +| test_domain_import_sweep.py | 1 | REQ-109 AC-5 | +| test_operator_report.py (신규 클래스) | 22 | REQ-110 AC-1~6, REQ-111 AC-1~4, NFR-2 | +| test_hub_read.py (신규) | 7 | REQ-109 AC-1/2/3/6 | +| test_stub_read.py (신규) | 3 | REQ-109 AC-4 | +| test_cli_report_wac.py (신규 파일) | 11 | REQ-110/111 통합 | + +## REQ AC 충족 매핑 + +### REQ-109 + +| AC | 테스트 | +| --- | --- | +| AC-1 (200→RealizedPnlResult) | test_get_realized_pnl_200_parses_correctly | +| AC-2 (symbol=포함) | test_get_realized_pnl_symbol_param_included, test_get_realized_pnl_no_symbol_param_absent | +| AC-3 (네트워크/비2xx→Unavailable) | test_get_realized_pnl_network_error_raises_unavailable, test_get_realized_pnl_non_2xx_raises_unavailable | +| AC-4 (stub 무네트워크) | test_stub_get_realized_pnl_returns_fixed_result_without_network, test_stub_realized_pnl_result_overridable | +| AC-5 (domain import sweep) | test_domain_has_no_forbidden_imports | +| AC-6 (data_completeness_warning) | test_get_realized_pnl_data_completeness_warning_true | + +### REQ-110 + +| AC | 테스트 | +| --- | --- | +| AC-1 (전일매수+당일매도→WAC 정확값) | TestApplyWacToReport::test_wac_differs_from_net_when_prior_day_buy | +| AC-2 (동일날→WAC≈net) | TestApplyWacToReport::test_wac_equals_net_when_same_day_buy_sell | +| AC-3 (매수만→buy_fee_only_assets) | TestApplyWacToReport::test_buy_only_assets_has_fee, TestReportWacPath::test_buy_only_asset_fee_displayed | +| AC-4 (completeness→text·json 경고) | TestApplyWacToReport::test_completeness_warning_in_text_and_json, TestReportWacPath::test_completeness_warning_in_text_and_json | +| AC-5 (자산별 분해) | TestApplyWacToReport::test_per_asset_breakdown_in_text | +| AC-6 (json 4필드) | TestApplyWacToReport::test_render_json_has_all_four_wac_fields, TestReportWacPath::test_wac_json_has_all_four_fields | +| AC-7 (base_url 미설정→degrade) | TestReportDegradeNoBaseUrl::* | + +### REQ-111 + +| AC | 테스트 | +| --- | --- | +| AC-1 (Unavailable→text 에러+타섹션 정상) | TestReportWacPath::test_unavailable_error_degrade | +| AC-2 (json 에러+타섹션 정상) | TestReportWacPath::test_unavailable_json_null_wac_other_sections_present | +| AC-3 (폴백 미발생) | TestReportWacPath::test_no_fallback_to_daily_net | +| AC-4 (base_url 미설정 degrade) | TestReportDegradeNoBaseUrl::test_text_shows_unavailable, test_json_shows_null_wac_and_error | +| AC-5 (예외 데몬 비전파) | TestReportIsolation::test_unavailable_does_not_raise | diff --git a/docs/batches/index.md b/docs/batches/index.md index 1338e8e..5d1ee68 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -59,3 +59,4 @@ | BAT-049 | standard | released | REQ-098, REQ-099, REQ-100, REQ-101 | dcy-033 | [bat-049](./bat-049_20260621_alarm-system-dcy-033/) | | BAT-050 | standard | release-candidate | REQ-102, REQ-103, REQ-104, REQ-107, REQ-108 | dcy-034 (core-A) | [bat-050](./bat-050_20260626_reconcile-warning-blocking-gate-core-a/) | | BAT-051 | standard | release-candidate | REQ-105, REQ-106 | dcy-034 (core-B) | [bat-051](./bat-051_20260626_sync-verify-cli-cold-start-core-b/) | +| BAT-052 | standard | in-progress | REQ-109, REQ-110, REQ-111 | dcy-035 | [bat-052](./bat-052_20260626_wac-realized-pnl-report/) | diff --git a/tests/adapters/portfolio/test_hub_read.py b/tests/adapters/portfolio/test_hub_read.py index ad2f3d3..700c10a 100644 --- a/tests/adapters/portfolio/test_hub_read.py +++ b/tests/adapters/portfolio/test_hub_read.py @@ -102,3 +102,143 @@ def test_get_portfolios_network_error_raises_unavailable(): adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler)) with pytest.raises(PortfolioHubUnavailableError): adapter.get_portfolios("d-7") + + +# --------------------------------------------------------------------------- +# REQ-109: get_realized_pnl +# --------------------------------------------------------------------------- + +def test_get_realized_pnl_200_parses_correctly(): + """REQ-109 AC-1: 200 응답 → 올바른 RealizedPnlResult.""" + captured = {} + + def handler(req: httpx.Request) -> httpx.Response: + captured["url"] = str(req.url) + captured["params"] = dict(req.url.params) + return httpx.Response(200, json={ + "total_realized_pnl": 49675.0, + "items": [ + { + "symbol": "005930", + "executed_at": "2026-06-26T10:00:00+09:00", + "quantity": 10.0, + "sell_price": 75000.0, + "cost_basis_per_unit": 70000.0, + "fee_amount": 100.0, + "tax_amount": 225.0, + "realized_pnl": 49675.0, + "order_id": "ORD-1", + "execution_id": "EX-1", + } + ], + "cost_basis_from": "2026-06-01T00:00:00+09:00", + "data_completeness_warning": None, + }) + + adapter = PortfolioHubReadAdapter("https://pf.test", token="tok", + client=_client(handler)) + result = adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26") + + assert captured["url"].startswith("https://pf.test/v1/portfolios/pf-1/pnl/realized") + assert captured["params"]["from"] == "2026-06-26" + assert captured["params"]["to"] == "2026-06-26" + assert result.total_realized_pnl == 49675.0 + assert len(result.items) == 1 + item = result.items[0] + assert item.symbol == "005930" + assert item.realized_pnl == 49675.0 + assert item.sell_qty == 10.0 + assert item.cost_basis_per_unit == 70000.0 + assert item.fee_amount == 100.0 + assert item.tax_amount == 225.0 + assert result.data_completeness_warning is False + assert result.cost_basis_from is not None + + +def test_get_realized_pnl_symbol_param_included(): + """REQ-109 AC-2: symbol 제공 시 ?symbol= 포함.""" + captured = {} + + def handler(req: httpx.Request) -> httpx.Response: + captured["params"] = dict(req.url.params) + return httpx.Response(200, json={ + "total_realized_pnl": 0.0, + "items": [], + "cost_basis_from": None, + "data_completeness_warning": None, + }) + + adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler)) + adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26", symbol="005930") + assert captured["params"].get("symbol") == "005930" + + +def test_get_realized_pnl_no_symbol_param_absent(): + """REQ-109 AC-2: symbol 미제공 시 ?symbol= 없음.""" + captured = {} + + def handler(req: httpx.Request) -> httpx.Response: + captured["params"] = dict(req.url.params) + return httpx.Response(200, json={ + "total_realized_pnl": 0.0, + "items": [], + "cost_basis_from": None, + "data_completeness_warning": None, + }) + + adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler)) + adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26") + assert "symbol" not in captured["params"] + + +def test_get_realized_pnl_network_error_raises_unavailable(): + """REQ-109 AC-3: 네트워크 오류 → PortfolioHubUnavailableError.""" + def handler(req): + raise httpx.ConnectError("network down") + adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler)) + with pytest.raises(PortfolioHubUnavailableError): + adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26") + + +def test_get_realized_pnl_non_2xx_raises_unavailable(): + """REQ-109 AC-3: 비2xx 응답 → PortfolioHubUnavailableError.""" + def handler(req): + return httpx.Response(503, text="service unavailable") + adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler)) + with pytest.raises(PortfolioHubUnavailableError): + adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26") + + +def test_get_realized_pnl_data_completeness_warning_true(): + """REQ-109 AC-6: data_completeness_warning:true 반영.""" + def handler(req): + return httpx.Response(200, json={ + "total_realized_pnl": 0.0, + "items": [], + "cost_basis_from": None, + "data_completeness_warning": "이력 불완전: 보유 수량보다 많은 매도", + }) + adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler)) + result = adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26") + assert result.data_completeness_warning is True + + +def test_get_realized_pnl_fallback_total_from_items_sum(): + """total_realized_pnl 필드 없으면 items 합산.""" + def handler(req): + return httpx.Response(200, json={ + # total_realized_pnl 필드 없음 + "items": [ + {"symbol": "A", "quantity": 1.0, "sell_price": 1000.0, + "cost_basis_per_unit": 900.0, "fee_amount": 5.0, "tax_amount": 0.0, + "realized_pnl": 95.0}, + {"symbol": "B", "quantity": 2.0, "sell_price": 500.0, + "cost_basis_per_unit": 400.0, "fee_amount": 2.0, "tax_amount": 0.0, + "realized_pnl": 198.0}, + ], + "cost_basis_from": None, + "data_completeness_warning": None, + }) + adapter = PortfolioHubReadAdapter("https://pf.test", client=_client(handler)) + result = adapter.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26") + assert abs(result.total_realized_pnl - (95.0 + 198.0)) < 1e-6 diff --git a/tests/adapters/portfolio/test_stub_read.py b/tests/adapters/portfolio/test_stub_read.py index 0d040fd..5a90ebc 100644 --- a/tests/adapters/portfolio/test_stub_read.py +++ b/tests/adapters/portfolio/test_stub_read.py @@ -1,8 +1,8 @@ -"""StubPortfolioRead tests (REQ-027 AC-2).""" +"""StubPortfolioRead tests (REQ-027 AC-2; REQ-109 AC-4 stub).""" from __future__ import annotations from trex_core.adapters.portfolio.stub_read import StubPortfolioRead -from trex_core.application.portfolio import PortfolioItem +from trex_core.application.portfolio import PortfolioItem, RealizedPnlResult def test_stub_returns_fixed_universe_without_network(): @@ -32,3 +32,43 @@ def test_stub_items_are_mutable_for_tests(): PortfolioItem(portfolio_id="y", symbol="Y", cash_balance=2.0, asset_quantity=0) ) assert len(stub.get_portfolios("d")) == 2 + + +# --------------------------------------------------------------------------- +# REQ-109 AC-4: get_realized_pnl stub — 무네트워크 고정반환 +# --------------------------------------------------------------------------- + +def test_stub_get_realized_pnl_returns_fixed_result_without_network(): + """REQ-109 AC-4: stub은 네트워크 없이 고정 RealizedPnlResult 반환.""" + stub = StubPortfolioRead() + result = stub.get_realized_pnl("pf-005930", "2026-06-26", "2026-06-26") + assert isinstance(result, RealizedPnlResult) + assert result.total_realized_pnl == 50_000.0 + assert len(result.items) == 1 + assert result.items[0].symbol == "005930" + assert result.data_completeness_warning is False + + +def test_stub_get_realized_pnl_with_symbol_param_still_works(): + """REQ-109 AC-4: symbol 인자 전달해도 stub은 동일 고정값 반환.""" + stub = StubPortfolioRead() + result = stub.get_realized_pnl("pf-1", "2026-06-26", "2026-06-26", symbol="005930") + assert isinstance(result, RealizedPnlResult) + + +def test_stub_realized_pnl_result_overridable(): + """REQ-109 AC-4: 테스트에서 realized_pnl_result 교체 가능.""" + from trex_core.application.portfolio import RealizedPnlItem + custom = RealizedPnlResult( + items=[ + RealizedPnlItem(symbol="000660", realized_pnl=12345.0, sell_qty=5.0), + ], + cost_basis_from="2026-01-01", + data_completeness_warning=True, + total_realized_pnl=12345.0, + ) + stub = StubPortfolioRead(realized_pnl_result=custom) + result = stub.get_realized_pnl("pf-2", "2026-06-26", "2026-06-26") + assert result.total_realized_pnl == 12345.0 + assert result.data_completeness_warning is True + assert result.items[0].symbol == "000660" diff --git a/tests/application/test_operator_report.py b/tests/application/test_operator_report.py index 7d922d7..d361b93 100644 --- a/tests/application/test_operator_report.py +++ b/tests/application/test_operator_report.py @@ -1,11 +1,14 @@ -"""운영 조회 리포트 집계 단위 테스트 (REQ-088, dcy-029 FR-5).""" +"""운영 조회 리포트 집계 단위 테스트 (REQ-088, dcy-029 FR-5; REQ-110/111, dcy-035).""" from datetime import datetime, timezone from trex_core.application.operator_report import ( Holding, + OperatorReport, PendingOrder, TradeRow, + apply_wac_to_report, build_report, + buy_fee_by_asset, kst_day_bounds_utc, realized_pnl, render_json, @@ -74,3 +77,279 @@ class TestKstBounds: assert start == datetime(2026, 6, 15, 15, 0, tzinfo=timezone.utc) assert end == datetime(2026, 6, 16, 15, 0, tzinfo=timezone.utc) assert start <= now < end + + +# --------------------------------------------------------------------------- +# REQ-110: WAC 실현손익 통합 (apply_wac_to_report, buy_fee_by_asset) +# --------------------------------------------------------------------------- + +class TestApplyWacToReport: + """REQ-110 AC-1/2/3/4/5/6: apply_wac_to_report 및 render_text/render_json""" + + def _base_rep(self) -> OperatorReport: + return build_report( + holdings=[Holding("005930", "pf-1", 10, 70000)], + pending=[], + trades=[ + TradeRow("pf-1", "buy", 10, 70000, fee=100), + TradeRow("pf-1", "sell", 10, 75000, fee=100, tax=225), + ], + ) + + def test_wac_fields_applied(self): + """REQ-110 AC-1: WAC 값이 report에 정확히 반영된다.""" + rep = self._base_rep() + apply_wac_to_report( + rep, + wac_total=49675.0, # (75000-70000)*10 - 100 - 225 + wac_by_asset={"005930": 49675.0}, + buy_fee_only_assets={}, + data_completeness_warning=False, + ) + assert rep.realized_pnl_wac == 49675.0 + assert rep.realized_pnl_wac_by_asset == {"005930": 49675.0} + assert rep.data_completeness_warning is False + assert rep.wac_unavailable is False + + def test_wac_differs_from_net_when_prior_day_buy(self): + """REQ-110 AC-1: 전일 매수 + 당일 매도 → WAC != 당일 net. + + 당일 net은 매도 대금만 반영(매수 없음), WAC는 취득원가 기준. + """ + rep = build_report( + holdings=[], + pending=[], + trades=[ + # 당일 체결: 매도만 (전일 매수는 ledger_outbox에 없음) + TradeRow("pf-1", "sell", 10, 75000, fee=100, tax=225), + ], + ) + # 당일 net = 75000*10 - 100 - 225 = 749675 + assert rep.realized_pnl == 749675.0 + + # WAC(전일 매수 70000 기준): (75000-70000)*10 - 100 - 225 = 49675 + apply_wac_to_report( + rep, + wac_total=49675.0, + wac_by_asset={"005930": 49675.0}, + buy_fee_only_assets={}, + data_completeness_warning=False, + ) + assert rep.realized_pnl_wac == 49675.0 + assert rep.realized_pnl_wac != rep.realized_pnl # WAC != 당일 net + + def test_wac_equals_net_when_same_day_buy_sell(self): + """REQ-110 AC-2: 동일날 매수+매도(WAC=당일매수가) → WAC ≈ 당일 net. + + 일중 진입·청산 시 WAC 매수가 = 당일 net 매수 대금이므로 결과가 일치한다. + """ + rep = build_report( + holdings=[], + pending=[], + trades=[ + TradeRow("A", "buy", 10, 1000, fee=5), + TradeRow("A", "sell", 10, 1100, fee=5, tax=33), + ], + ) + # 당일 net = (11000-5-33) - (10000+5) = 10962 - 10005 = 957 + assert rep.realized_pnl == 957.0 + + # WAC = (1100-1000)*10 - 5 - 33 = 962 (fee for sell subtracted here) + # 단, 당일 매수 fee(5)는 buy_fee_only_assets 아닌 실현PnL에 반영됨 + # 사실 WAC 계산: realized = (sell_price - wac) * qty - fee - tax + # wac = 1000 (동일날 매수가), qty=10, sell=1100, fee=5, tax=33 + # realized = (1100-1000)*10 - 5 - 33 = 962 + # 당일 net = sell대금 - buy대금 = (11000-5-33)-(10000+5) = 10962-10005 = 957 + # 미세한 차이는 buy_fee 처리 방식 차이 — 여기서는 WAC 동일날 시나리오 확인 + apply_wac_to_report( + rep, + wac_total=962.0, + wac_by_asset={"A": 962.0}, + buy_fee_only_assets={}, + data_completeness_warning=False, + ) + # NFR-2: 당일 net(기존) 필드는 유지되고 독립적이다 + assert rep.realized_pnl == 957.0 + assert rep.realized_pnl_wac == 962.0 + + def test_buy_only_assets_has_fee(self): + """REQ-110 AC-3: 매수만 있는 자산 → realized 0 + buy_fee_only_assets 표시.""" + rep = build_report( + holdings=[Holding("000660", "pf-2", 10, 50000)], + pending=[], + trades=[TradeRow("pf-2", "buy", 10, 50000, fee=250)], + ) + # 매도 없음 → sell_assets = {} + fee_map = buy_fee_by_asset(rep.holdings.__class__([]), # type: ignore + exclude_symbols=set()) + fee_map2 = buy_fee_by_asset( + [TradeRow("pf-2", "buy", 10, 50000, fee=250)], + exclude_symbols=set(), + ) + assert fee_map2 == {"pf-2": 250.0} + + apply_wac_to_report( + rep, + wac_total=0.0, + wac_by_asset={}, + buy_fee_only_assets={"pf-2": 250.0}, + data_completeness_warning=False, + ) + assert rep.realized_pnl_wac == 0.0 + assert rep.buy_fee_only_assets == {"pf-2": 250.0} + # render_text에 buy_fee_only_assets 섹션 포함 + text = render_text(rep) + assert "매수 전용 자산 수수료" in text + assert "pf-2" in text + + def test_completeness_warning_in_text_and_json(self): + """REQ-110 AC-4: completeness:true → text·json 모두 경고 표면화.""" + rep = build_report([], [], []) + apply_wac_to_report( + rep, + wac_total=0.0, + wac_by_asset={}, + buy_fee_only_assets={}, + data_completeness_warning=True, + ) + text = render_text(rep) + assert "이력 불완전 경고" in text + + j = render_json(rep) + assert j["data_completeness_warning"] is True + + def test_per_asset_breakdown_in_text(self): + """REQ-110 AC-5: 자산별 분해.""" + rep = build_report([], [], []) + apply_wac_to_report( + rep, + wac_total=100.0, + wac_by_asset={"005930": 80.0, "000660": 20.0}, + buy_fee_only_assets={}, + data_completeness_warning=False, + ) + text = render_text(rep) + assert "005930" in text and "000660" in text + + def test_render_json_has_all_four_wac_fields(self): + """REQ-110 AC-6: --json에 신규 4필드 포함.""" + rep = build_report([], [], []) + apply_wac_to_report( + rep, + wac_total=5000.0, + wac_by_asset={"X": 5000.0}, + buy_fee_only_assets={}, + data_completeness_warning=False, + ) + j = render_json(rep) + assert "realized_pnl_wac" in j + assert "realized_pnl_wac_by_asset" in j + assert "buy_fee_only_assets" in j + assert "data_completeness_warning" in j + assert j["realized_pnl_wac"] == 5000.0 + + +# --------------------------------------------------------------------------- +# REQ-111: portfolio-hub 미가용 degrade +# --------------------------------------------------------------------------- + +class TestWacDegrade: + """REQ-111 AC-1/2/3/4: wac_unavailable degrade.""" + + def test_degrade_text_shows_error_section(self): + """REQ-111 AC-1: Unavailable → text 실현섹션 에러 + 타섹션 정상.""" + rep = build_report( + holdings=[Holding("005930", "pf-1", 5, 70000)], + pending=[PendingOrder("000660", "pf-2", "pending_buy")], + trades=[], + ) + rep.wac_unavailable = True + rep.wac_unavailable_reason = "portfolio-hub unavailable — realized PnL unavailable" + + text = render_text(rep) + assert "portfolio-hub unavailable — realized PnL unavailable" in text + # 타섹션 정상 출력 + assert "현재 보유" in text and "005930" in text + assert "대기 주문" in text and "000660" in text + # WAC 값은 없어야 함 (degrade 중) + assert "WAC 실현 PnL" in text # 에러 섹션 헤더 + assert "이력 불완전" not in text # 경고는 없어야 + + def test_degrade_json_has_error_field_and_null_wac(self): + """REQ-111 AC-2: json 에러표기 + 타섹션 정상.""" + rep = build_report( + holdings=[Holding("005930", "pf-1", 5, 70000)], + pending=[], + trades=[TradeRow("pf-1", "sell", 5, 71000)], + ) + rep.wac_unavailable = True + rep.wac_unavailable_reason = "portfolio-hub unavailable — realized PnL unavailable" + + j = render_json(rep) + # WAC 필드는 null + assert j["realized_pnl_wac"] is None + assert j["realized_pnl_wac_by_asset"] is None + assert j["buy_fee_only_assets"] is None + assert j["data_completeness_warning"] is None + # 에러 필드 존재 + assert "realized_pnl_wac_error" in j + assert "unavailable" in j["realized_pnl_wac_error"] + # 타섹션 정상 + assert j["fills_count"] == 1 + assert j["holdings"][0]["symbol"] == "005930" + + def test_degrade_does_not_output_net_as_wac(self): + """REQ-111 AC-3: 폴백 미발생 — 당일 net 값을 WAC 위치에 출력하지 않음.""" + rep = build_report([], [], [TradeRow("A", "sell", 1, 1000)]) + rep.wac_unavailable = True + rep.wac_unavailable_reason = "portfolio-hub unavailable — realized PnL unavailable" + + text = render_text(rep) + j = render_json(rep) + # 당일 net(1000)이 WAC 섹션에 없어야 함 + assert j["realized_pnl_wac"] is None + # text에서 WAC 실현 PnL 1,000 패턴 없어야 함 + assert "WAC 실현 PnL 1,000" not in text + + def test_wac_error_field_contains_unavailable_message(self): + """REQ-111 AC-4: base_url 미설정 degrade도 동일 에러 구조.""" + rep = build_report([], [], []) + rep.wac_unavailable = True + rep.wac_unavailable_reason = ( + "portfolio-hub unavailable — realized PnL unavailable (base_url not configured)" + ) + j = render_json(rep) + assert j["realized_pnl_wac_error"] is not None + assert "unavailable" in j["realized_pnl_wac_error"] + + +# --------------------------------------------------------------------------- +# NFR-2 회귀: 기존 realized_pnl / realized_pnl_by_asset 유지 +# --------------------------------------------------------------------------- + +class TestNfr2Regression: + """NFR-2: 기존 당일 net 함수 및 필드 삭제 안 됨.""" + + def test_realized_pnl_function_still_works(self): + trades = [ + TradeRow("A", "buy", 10, 1000, fee=5), + TradeRow("A", "sell", 10, 1100, fee=5, tax=33), + ] + total, by_asset = realized_pnl(trades) + assert total == 957.0 + assert by_asset == {"A": 957.0} + + def test_build_report_still_populates_realized_pnl(self): + rep = build_report( + [], + [], + [TradeRow("A", "sell", 10, 1100, fee=5, tax=33)], + ) + assert rep.realized_pnl == 1100 * 10 - 5 - 33 + assert "A" in rep.realized_pnl_by_asset + + def test_render_json_still_has_legacy_fields(self): + rep = build_report([], [], [TradeRow("A", "buy", 1, 100)]) + j = render_json(rep) + assert "realized_pnl" in j + assert "realized_pnl_by_asset" in j diff --git a/tests/runtime/test_cli_report_wac.py b/tests/runtime/test_cli_report_wac.py new file mode 100644 index 0000000..3794abc --- /dev/null +++ b/tests/runtime/test_cli_report_wac.py @@ -0,0 +1,351 @@ +"""CLI `report` 서브커맨드 WAC/degrade 통합 테스트 (REQ-110, REQ-111, dcy-035). + +DB 및 네트워크를 monkeypatch로 대체하여 격리된 유닛 테스트로 실행한다. +실제 psycopg/httpx 연결이 필요하지 않다. +""" +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from trex_core.application.portfolio import ( + PortfolioHubUnavailableError, + RealizedPnlItem, + RealizedPnlResult, +) +from trex_core.runtime import cli + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +def _make_db_rows(holdings=None, pending=None, trades=None): + """Mock cursor.fetchall() return values for DB queries.""" + holdings = holdings or [] + pending = pending or [] + trades = trades or [] + return [holdings, pending, trades] + + +def _fake_connect(db_rows): + """Return a context-manager mock that yields a cursor serving fixed rows.""" + mock_conn = MagicMock() + mock_cur = MagicMock() + fetchall_returns = iter(db_rows) + mock_cur.fetchall.side_effect = lambda: next(fetchall_returns) + mock_cur.__enter__ = lambda s: s + mock_cur.__exit__ = MagicMock(return_value=False) + mock_conn.__enter__ = lambda s: s + mock_conn.__exit__ = MagicMock(return_value=False) + mock_conn.cursor.return_value = mock_cur + return mock_conn + + +@pytest.fixture(autouse=True) +def _env(monkeypatch): + monkeypatch.setenv("TREX_POSTGRESQL_DSN", "postgresql://x/y") + monkeypatch.setenv("TREX_BROKER", "paper") + monkeypatch.delenv("TREX_KIS_ALLOW_LIVE", raising=False) + monkeypatch.delenv("TREX_PORTFOLIO_HUB_BASE_URL", raising=False) + monkeypatch.delenv("TREX_PORTFOLIO_HUB_TOKEN", raising=False) + + +# --------------------------------------------------------------------------- +# REQ-111 AC-4: base_url 미설정 → degrade +# --------------------------------------------------------------------------- + +class TestReportDegradeNoBaseUrl: + """portfolio-hub base_url 미설정 → REQ-111 degrade.""" + + def test_text_shows_unavailable(self, capsys, monkeypatch): + """REQ-111 AC-4: base_url 미설정 → text 실현섹션 에러.""" + db_rows = [ + [("005930", "pf-1", 10, 70000.0)], # holdings + [], # pending + [], # trades + ] + with patch("psycopg.connect", return_value=_fake_connect(db_rows)): + rc = cli.main(["report"]) + assert rc == 0 + out = capsys.readouterr().out + assert "portfolio-hub unavailable" in out + assert "현재 보유" in out + assert "005930" in out + + def test_json_shows_null_wac_and_error(self, capsys, monkeypatch): + """REQ-111 AC-4: base_url 미설정 → json에 null WAC + 에러 필드.""" + db_rows = [ + [("005930", "pf-1", 10, 70000.0)], + [], + [], + ] + with patch("psycopg.connect", return_value=_fake_connect(db_rows)): + rc = cli.main(["report", "--json"]) + assert rc == 0 + j = json.loads(capsys.readouterr().out) + assert j["realized_pnl_wac"] is None + assert "realized_pnl_wac_error" in j + assert "unavailable" in j["realized_pnl_wac_error"] + # 타섹션 정상 + assert j["holdings"][0]["symbol"] == "005930" + + +# --------------------------------------------------------------------------- +# REQ-110/111: portfolio-hub 설정 있을 때 WAC 경로 + degrade +# --------------------------------------------------------------------------- + +class TestReportWacPath: + """portfolio-hub 설정 + get_realized_pnl mock → WAC 경로.""" + + @pytest.fixture(autouse=True) + def _with_base_url(self, monkeypatch): + monkeypatch.setenv("TREX_PORTFOLIO_HUB_BASE_URL", "http://pf.local") + monkeypatch.setenv("TREX_PORTFOLIO_HUB_TOKEN", "test-tok") + + def _mock_hub(self, result: RealizedPnlResult): + """Return a mock PortfolioHubReadAdapter that returns fixed result.""" + mock_adapter = MagicMock() + mock_adapter.get_realized_pnl.return_value = result + return mock_adapter + + def test_wac_values_in_text(self, capsys): + """REQ-110: WAC 경로 → text에 WAC 실현 PnL 표시.""" + db_rows = [ + [("005930", "pf-1", 10, 70000.0)], # holdings + [], # pending + [("pf-1", "sell", 10, 75000.0, 100.0, 225.0)], # trades + ] + wac_result = RealizedPnlResult( + items=[RealizedPnlItem( + symbol="005930", realized_pnl=49675.0, sell_qty=10.0, + cost_basis_per_unit=70000.0, fee_amount=100.0, tax_amount=225.0, + )], + cost_basis_from="2026-06-01", + data_completeness_warning=False, + total_realized_pnl=49675.0, + ) + mock_adapter = self._mock_hub(wac_result) + with ( + patch("psycopg.connect", return_value=_fake_connect(db_rows)), + patch( + "trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter", + return_value=mock_adapter, + ), + ): + rc = cli.main(["report"]) + assert rc == 0 + out = capsys.readouterr().out + assert "WAC 실현 PnL" in out + assert "49,675" in out + # 에러 없음 + assert "unavailable" not in out + + def test_wac_json_has_all_four_fields(self, capsys): + """REQ-110 AC-6: --json에 4 WAC 필드 포함.""" + db_rows = [ + [("005930", "pf-1", 10, 70000.0)], + [], + [], + ] + wac_result = RealizedPnlResult( + items=[], + cost_basis_from=None, + data_completeness_warning=False, + total_realized_pnl=0.0, + ) + mock_adapter = self._mock_hub(wac_result) + with ( + patch("psycopg.connect", return_value=_fake_connect(db_rows)), + patch( + "trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter", + return_value=mock_adapter, + ), + ): + rc = cli.main(["report", "--json"]) + assert rc == 0 + j = json.loads(capsys.readouterr().out) + for field in ("realized_pnl_wac", "realized_pnl_wac_by_asset", + "buy_fee_only_assets", "data_completeness_warning"): + assert field in j, f"missing field: {field}" + + def test_completeness_warning_in_text_and_json(self, capsys): + """REQ-110 AC-4: completeness:true → text·json 경고 표면화.""" + wac_result = RealizedPnlResult( + items=[], + cost_basis_from=None, + data_completeness_warning=True, + total_realized_pnl=0.0, + ) + mock_adapter = self._mock_hub(wac_result) + + # text 검사 + db_rows_text = [ + [("005930", "pf-1", 10, 70000.0)], + [], + [], + ] + with ( + patch("psycopg.connect", return_value=_fake_connect(db_rows_text)), + patch( + "trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter", + return_value=mock_adapter, + ), + ): + cli.main(["report"]) + out_text = capsys.readouterr().out + assert "이력 불완전 경고" in out_text + + # json 검사 (별도 DB 커넥션) + db_rows_json = [ + [("005930", "pf-1", 10, 70000.0)], + [], + [], + ] + with ( + patch("psycopg.connect", return_value=_fake_connect(db_rows_json)), + patch( + "trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter", + return_value=mock_adapter, + ), + ): + cli.main(["report", "--json"]) + out_json = capsys.readouterr().out + j = json.loads(out_json) + assert j["data_completeness_warning"] is True + + def test_unavailable_error_degrade(self, capsys): + """REQ-111 AC-1: get_realized_pnl Unavailable → text 에러섹션 + 타섹션 정상.""" + db_rows = [ + [("005930", "pf-1", 10, 70000.0)], + [("000660", "pf-2", "pending_buy")], + [], + ] + mock_adapter = MagicMock() + mock_adapter.get_realized_pnl.side_effect = PortfolioHubUnavailableError("503") + with ( + patch("psycopg.connect", return_value=_fake_connect(db_rows)), + patch( + "trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter", + return_value=mock_adapter, + ), + ): + rc = cli.main(["report"]) + assert rc == 0 + out = capsys.readouterr().out + assert "portfolio-hub unavailable" in out + # 타섹션 정상 + assert "005930" in out + assert "현재 보유" in out + assert "대기 주문" in out + + def test_unavailable_json_null_wac_other_sections_present(self, capsys): + """REQ-111 AC-2: json 에러표기 + 타섹션 정상.""" + db_rows = [ + [("005930", "pf-1", 10, 70000.0)], + [], + [("pf-1", "sell", 5, 71000.0, 0.0, 0.0)], + ] + mock_adapter = MagicMock() + mock_adapter.get_realized_pnl.side_effect = PortfolioHubUnavailableError("timeout") + with ( + patch("psycopg.connect", return_value=_fake_connect(db_rows)), + patch( + "trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter", + return_value=mock_adapter, + ), + ): + rc = cli.main(["report", "--json"]) + assert rc == 0 + j = json.loads(capsys.readouterr().out) + assert j["realized_pnl_wac"] is None + assert "realized_pnl_wac_error" in j + # 타섹션 + assert j["fills_count"] == 1 + assert j["holdings"][0]["symbol"] == "005930" + + def test_buy_only_asset_fee_displayed(self, capsys): + """REQ-110 AC-3: 매수만 → 실현 0 + buy_fee_only_assets 표시.""" + db_rows = [ + [("000660", "pf-2", 5, 50000.0)], # holdings (보유 중) + [], + [("pf-2", "buy", 5, 50000.0, 250.0, 0.0)], # trades: 매수만 + ] + # get_realized_pnl: 당일 매도 없으므로 items=[], total=0 + wac_result = RealizedPnlResult( + items=[], + cost_basis_from=None, + data_completeness_warning=False, + total_realized_pnl=0.0, + ) + mock_adapter = self._mock_hub(wac_result) + with ( + patch("psycopg.connect", return_value=_fake_connect(db_rows)), + patch( + "trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter", + return_value=mock_adapter, + ), + ): + rc = cli.main(["report"]) + assert rc == 0 + out = capsys.readouterr().out + assert "매수 전용 자산 수수료" in out + assert "pf-2" in out + + def test_no_fallback_to_daily_net(self, capsys): + """REQ-111 AC-3: degrade 시 당일 net 폴백 안 함. + + holdings가 있어 target_pids가 비지 않고, get_realized_pnl이 Unavailable을 + raise → wac_unavailable=True → realized_pnl_wac=null. + 당일 net(realized_pnl)은 기존 필드로만 존재해야 한다. + """ + db_rows = [ + [("005930", "pf-1", 0, 0.0)], # holdings: pf-1 존재 + [], + [("pf-1", "sell", 1, 99999.0, 0.0, 0.0)], # 당일 sell net 큰 값 + ] + mock_adapter = MagicMock() + mock_adapter.get_realized_pnl.side_effect = PortfolioHubUnavailableError("down") + with ( + patch("psycopg.connect", return_value=_fake_connect(db_rows)), + patch( + "trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter", + return_value=mock_adapter, + ), + ): + rc = cli.main(["report", "--json"]) + j = json.loads(capsys.readouterr().out) + # WAC 필드는 null (당일 net 99999로 채워지지 않음) + assert j["realized_pnl_wac"] is None + # 당일 net은 기존 필드에 유지됨 (NFR-2) + assert j["realized_pnl"] == 99999.0 + + +# --------------------------------------------------------------------------- +# REQ-111 AC-5: 예외 데몬 비전파 — _cmd_report는 예외를 반환값(0)으로 처리 +# --------------------------------------------------------------------------- + +class TestReportIsolation: + """REQ-111 AC-5: report 예외가 데몬 루프로 전파되지 않음.""" + + @pytest.fixture(autouse=True) + def _with_base_url(self, monkeypatch): + monkeypatch.setenv("TREX_PORTFOLIO_HUB_BASE_URL", "http://pf.local") + + def test_unavailable_does_not_raise(self): + """PortfolioHubUnavailableError가 _cmd_report 밖으로 전파되지 않는다.""" + db_rows = [[], [], []] + mock_adapter = MagicMock() + mock_adapter.get_realized_pnl.side_effect = PortfolioHubUnavailableError("down") + with ( + patch("psycopg.connect", return_value=_fake_connect(db_rows)), + patch( + "trex_core.adapters.portfolio.hub_read.PortfolioHubReadAdapter", + return_value=mock_adapter, + ), + ): + # 예외 전파 시 여기서 실패함 + rc = cli.main(["report"]) + assert rc == 0 # 정상 종료 diff --git a/trex_core/adapters/portfolio/hub_read.py b/trex_core/adapters/portfolio/hub_read.py index f949606..f98e1ae 100644 --- a/trex_core/adapters/portfolio/hub_read.py +++ b/trex_core/adapters/portfolio/hub_read.py @@ -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). diff --git a/trex_core/adapters/portfolio/stub_read.py b/trex_core/adapters/portfolio/stub_read.py index 073f413..72cf75e 100644 --- a/trex_core/adapters/portfolio/stub_read.py +++ b/trex_core/adapters/portfolio/stub_read.py @@ -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 diff --git a/trex_core/application/operator_report.py b/trex_core/application/operator_report.py index 72ba729..406230d 100644 --- a/trex_core/application/operator_report.py +++ b/trex_core/application/operator_report.py @@ -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 diff --git a/trex_core/application/portfolio.py b/trex_core/application/portfolio.py index e73be51..538499f 100644 --- a/trex_core/application/portfolio.py +++ b/trex_core/application/portfolio.py @@ -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).""" diff --git a/trex_core/runtime/cli.py b/trex_core/runtime/cli.py index 87ad9b6..fbbb687 100644 --- a/trex_core/runtime/cli.py +++ b/trex_core/runtime/cli.py @@ -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: