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:
@@ -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) |
|
||||
@@ -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 유지
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
@@ -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 |
|
||||
@@ -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/) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 # 정상 종료
|
||||
@@ -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