Merge pull request #33 from fourblocks2020/feat/dcy-035-multiday-wac-report
feat(dcy-035): 멀티데이 WAC 실현PnL 운영리포트 (REQ-109/110/111)
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
# 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 — 매수 전용 자산 수수료 표시 (CHG-20260626-02로 결정 변경)
|
||||
|
||||
초안 결정: ledger_outbox 당일 매수 fee 합산 → `buy_fee_only_assets` 표시.
|
||||
|
||||
**변경(CHG-20260626-02)**: portfolio-hub WAC는 매수 fee를 cost basis에 편입한다. 따라서 매수 전용일에 별도 수수료 라인을 출력하면 이중계상이 발생한다. `buy_fee_only_assets`·`buy_fee_by_asset` 헬퍼는 최종 구현에서 제거됐다. 매수 전용일에 실현 PnL = 0이고 별도 수수료 라인 없음. 수수료는 매도 시 cost basis를 통해 실현손익에 반영된다.
|
||||
|
||||
## 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: 제거됨 (CHG-20260626-02 — cost basis 편입, 이중계상 방지)
|
||||
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. (제거됨 CHG-20260626-02) buy_fee_only_assets — 매수 fee cost basis 편입으로 별도 라인 불필요
|
||||
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일 때만)
|
||||
```
|
||||
|
||||
> CHG-20260626-02: "매수 전용 자산 수수료" 섹션은 최종 구현에서 제거됐다. 매수 fee는 cost basis에 편입되므로 별도 라인 표시 시 이중계상이 발생한다.
|
||||
|
||||
## 계약 매핑 (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 3필드(realized_pnl_wac, realized_pnl_wac_by_asset, data_completeness_warning) + degrade 2필드(wac_unavailable, wac_unavailable_reason) 추가; apply_wac_to_report 헬퍼 추가; render_text/render_json WAC/degrade 섹션 추가. CHG-20260626-02: buy_fee_only_assets·buy_fee_by_asset 제거(cost basis 편입, 이중계상 방지) |
|
||||
| `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(CHG-20260626-02): buy_fee_only_assets 제거. 매수 fee는 portfolio-hub cost basis에 편입되므로 별도 라인 불필요(이중계상 방지). NFR-2 엄격화: 동일날 매수+매도 시 WAC == 당일 net 성립.
|
||||
- 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. CHG-20260626-02: 매수 fee가 portfolio-hub cost basis에 편입되므로 매수 전용일에 별도 수수료 라인을 출력하지 않는다. `buy_fee_only_assets`·`buy_fee_by_asset` 헬퍼는 최종 구현에서 제거됐다.
|
||||
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 (매수만→실현 0, 별도 수수료 라인 없음) | TestApplyWacToReport::test_buy_only_asset_no_fee_line (CHG-20260626-02: buy_fee_only_assets 제거, cost basis 편입) |
|
||||
| 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/) |
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# 0. 문서 상태
|
||||
- 상태: confirmed
|
||||
- 문서 ID: dcy-035_20260626_multiday-realized-pnl-report-wac
|
||||
- 이슈명: 중장기 운영 리포트 — portfolio-hub WAC 실현PnL(/pnl/realized) 연동 (멀티데이 정확 실현손익)
|
||||
- 작성 시각(KST): 2026-06-26 (KST)
|
||||
- 마지막 갱신 시각(KST): 2026-06-26 (KST)
|
||||
- 대체됨: 없음
|
||||
- 후속 Discovery 참조: 없음
|
||||
- 생성된 REQ 참조: REQ-109, REQ-110, REQ-111
|
||||
|
||||
# 1. 계획 상태 요약
|
||||
- 연계 문서 현황:
|
||||
- 관련 TODO/메모: GitHub 보드 #7 "중장기 운영 리포트 — portfolio-hub WAC 실현PnL API(/pnl/realized) 연동 (멀티데이, M3)". M3 중장기 인스턴스 운영 도구(슬롯 동기화 dcy-034의 리포트 짝).
|
||||
- 선행: dcy-029(운영 관측성·실현 PnL 뷰, REQ-088 operator report), dcy-032(체결가 누락→cost basis 오염), portfolio-hub dcy-007/REQ-040(WAC 실현 PnL API).
|
||||
- 해석:
|
||||
- 현행 trex-core 운영 리포트(`application/operator_report.py`, REQ-088/dcy-029)는 실현 PnL을 **당일 net 매매 현금흐름**(`Σ매도(qty·price−fee−tax) − Σ매수(qty·price+fee)`)으로 계산한다. **일중 진입·청산 전략에선 net ≈ 실현손익**이라 충분했으나(M1/M2 단기), **중장기(수일~수개월 보유)에선 cost basis(WAC)를 무시한 당일 net이 실현손익과 다르다**.
|
||||
- portfolio-hub는 이미 **WAC 기반 실현 PnL**(`GET /v1/portfolios/{id}/pnl/realized`, REQ-040/041: `(매도가−running_wac)·qty−fee−tax`)을 제공한다. M3 중장기 인스턴스의 운영 리포트는 이 WAC API를 소비해 **멀티데이 정확 실현손익**을 보여야 한다.
|
||||
- 본 Discovery는 그 연동을 trex-core 변경 단위로 정식화한다(portfolio-hub는 기존 API 그대로 — 무변경).
|
||||
|
||||
# 2. 요구사항 판정 결과
|
||||
- 판정: 신규
|
||||
- 근거
|
||||
- dcy-029(REQ-088)는 **당일 net** 실현 PnL을 정의했다(`operator_report.py` 주석: "일중 진입·청산 전략에서는 net ≈ 실현손익"). 멀티데이 WAC 실현손익은 다른 계산 정의이며, **외부(portfolio-hub) WAC API 소비**라는 신규 연동이다.
|
||||
- trex-core에 portfolio-hub `/pnl/realized` 클라이언트 메서드/포트가 없다(신규).
|
||||
|
||||
# 3. 문제점의 요약
|
||||
- 현재 상태
|
||||
- `operator_report.py`(REQ-088): `realized_pnl(trades)`가 `ledger_outbox` 당일 거래로 net(매도대금−매수대금−수수료−세금)을 계산. 자산별 분해 포함. `trex-core report` CLI로 노출.
|
||||
- 이 정의는 **당일 매수→당일 매도(일중)** 에서만 실현손익과 일치한다. **전일 이전 매수분을 오늘 매도**하면, 당일 net은 "오늘 매도대금 − (오늘 없는 매수)"가 되어 cost basis가 빠진 채 실현손익이 과대/왜곡된다(중장기 보유의 본질).
|
||||
- portfolio-hub `/pnl/realized`(REQ-040)는 매수 누적 WAC를 cost basis로 써 매도별 정확 실현손익을 주지만, trex-core 리포트가 이를 소비하지 않는다.
|
||||
- 기대 상태
|
||||
- 중장기 인스턴스 운영 리포트의 실현 PnL이 **portfolio-hub WAC API(/pnl/realized) 기준의 멀티데이 정확 실현손익**으로 표시된다(매수 cost basis 반영).
|
||||
- portfolio-hub 이력 불완전 시(`data_completeness_warning`)가 리포트에 표면화된다.
|
||||
- portfolio-hub 미가용 시 리포트가 안전하게 degrade(에러 표시 또는 당일 net 폴백)한다.
|
||||
- 이해관계자
|
||||
- 사장님 — 리포트 정의(멀티데이 기준)·범위 결정
|
||||
- 팀장(Claude) — Discovery/게이트
|
||||
- trex-core-dev — delivery
|
||||
- 운영자 — 중장기 인스턴스 손익 리포트 소비자
|
||||
- 제약사항
|
||||
- portfolio-hub `/pnl/realized`는 **매도 기준 date-filter, 전 매수 WAC 참여**(REQ-041): 즉 기간 내 *매도*의 실현손익을 주고, 그 cost basis는 기간 밖 매수도 포함. (멀티데이 정의에 부합.)
|
||||
- **매수만 한 날**은 매도가 없어 realized=0 — 사장님 정의("매수만 한 경우 수수료 비용")를 충족하려면 리포트가 **수수료(비용)를 별도 표시**해야 함(realized와 분리).
|
||||
- trex-core wire 계약(slots RPC) 무관(리포트는 read-only 운영 표면). portfolio-hub HTTP 호출 추가(read).
|
||||
|
||||
## 주요 사용자 시나리오
|
||||
- 운영자: 중장기 인스턴스의 `trex-core report`(또는 동등)로 기간 실현손익을 본다 → 전일 매수분을 오늘 매도한 손익이 WAC 기준으로 정확히 표시(당일 net 왜곡 아님).
|
||||
- 운영자: 매수만 한 날 → 실현손익 0 + 수수료 비용 표시. 매도한 날 → WAC 실현손익. 둘 다 → 매수(WAC) 반영 후 매도 실현손익.
|
||||
|
||||
# 4. 이번에 정의할 변경
|
||||
- 변경 1 — portfolio-hub `/pnl/realized` 소비 포트/어댑터 신규: `PortfolioHubReadPort`(또는 신규 포트)에 realized-pnl read 메서드 + HTTP 어댑터(`GET /v1/portfolios/{id}/pnl/realized`, from/to/symbol).
|
||||
- 변경 2 — 운영 리포트의 실현 PnL을 WAC 기반으로 **전 인스턴스 통일**(OQ-1 결정): `operator_report.py`가 당일 net 대신 portfolio-hub WAC 실현손익을 사용(단·중·장기 공통 — WAC는 일중에도 정확). data_completeness_warning 표면화. 매수만 한 날의 수수료 비용 별도 표시.
|
||||
- 변경 3 — degrade: portfolio-hub 미가용 시 **실현손익 섹션에 에러 명시**(OQ-5 결정 — 당일 net 폴백 안 함). 나머지 리포트 섹션(보유/체결/대기주문)은 정상 출력.
|
||||
|
||||
## 영향 범위
|
||||
- 코드 영향: `trex_core/adapters/portfolio/*`(realized-pnl read 어댑터 신규), `trex_core/application/portfolio.py`(포트), `trex_core/application/operator_report.py`(실현 PnL 소스 분기), `trex_core/runtime/cli.py`(report 명령 — 기간/모드 옵션 필요시).
|
||||
- 문서 영향: `docs/discovery/index.md`, srs/batches. `docs/operations.md`(리포트 해석 갱신).
|
||||
- 운영 영향: 리포트 실현손익 정의가 중장기 모드에서 WAC로 바뀜 — 런북에 명시. portfolio-hub 호출 의존 추가.
|
||||
|
||||
# 5. 요구사항 목록
|
||||
## 기능 요구사항
|
||||
- FR-1: trex-core가 portfolio-hub `GET /v1/portfolios/{id}/pnl/realized`(from/to/symbol)를 호출하는 read 포트+어댑터를 갖는다.
|
||||
- FR-2: 운영 리포트의 실현 PnL을 **전 인스턴스 공통으로** portfolio-hub WAC 실현손익 기준으로 산출·표시한다(당일 net 대체, 멀티데이 cost basis 반영). 자산별 분해 유지.
|
||||
- FR-3: 매수만 한 기간(매도 0)은 실현손익 0 + **수수료 비용을 별도 항목으로 표시**(사장님 정의).
|
||||
- FR-4: portfolio-hub `data_completeness_warning`(이력 불완전)을 리포트에 표면화한다.
|
||||
- FR-5: portfolio-hub 미가용 시 **실현손익 섹션에 에러를 명시**하고(폴백 없음), 나머지 리포트 섹션은 정상 출력한다.
|
||||
|
||||
## 비기능 요구사항
|
||||
- NFR-1: trex-core wire 계약(slots RPC) 불변 — 리포트는 read-only 운영 표면.
|
||||
- NFR-2: 전 인스턴스 통일이므로, **일중 매수+매도(같은 날) 케이스에서 WAC 실현손익이 기존 당일 net과 일치**함을 회귀 테스트로 보장한다(단기 라이브 의미 보존 — WAC(=당일 매수가)·매도가 동일 정의).
|
||||
- NFR-3: portfolio-hub 호출 실패가 데몬 본업(슬롯/집행)에 영향 주지 않는다(리포트 격리).
|
||||
|
||||
## 범위 경계
|
||||
- In Scope
|
||||
- portfolio-hub /pnl/realized 소비(포트+어댑터) + 중장기 운영 리포트 WAC 실현손익 + 매수일 수수료 표시 + completeness 경고 + degrade.
|
||||
- Out of Scope
|
||||
- portfolio-hub 측 변경(이미 REQ-040 제공 — 무변경).
|
||||
- 미실현 손익(현재가 결합), 세무 리포트.
|
||||
- 슬롯↔계좌 동기화(dcy-034 별건).
|
||||
|
||||
# 6. 리스크/가정 목록
|
||||
## 리스크
|
||||
- R-1 (Medium): WAC 실현손익 소스 전환이 단기 인스턴스 리포트(당일 net)에 회귀를 줄 수 있다.
|
||||
- 완화: 모드/인스턴스로 분리(단기=net 유지 or WAC 통일은 OQ-1), 회귀 테스트.
|
||||
- R-2 (Low): portfolio-hub 호출 추가로 리포트가 외부 의존·지연. 미가용 시 리포트 실패.
|
||||
- 완화: degrade(폴백/에러 명시, OQ-5) + 본업 격리(NFR-3).
|
||||
|
||||
## 가정
|
||||
- A-1: portfolio-hub `/pnl/realized`(REQ-040/041)는 멀티데이 WAC 실현손익을 정확히 제공한다(매도 date-filter + 전 매수 WAC).
|
||||
- A-2: 중장기 인스턴스는 portfolio_id별로 실현손익을 조회한다(슬롯↔portfolio_id 1:1).
|
||||
- A-3: trex-core는 portfolio-hub를 세션/토큰으로 호출 가능하다(기존 whoami/get_portfolios 경로 재사용).
|
||||
|
||||
## 오픈 질문
|
||||
- OQ-1: WAC 실현손익을 전 인스턴스 통일인지, 중장기 모드만인지.
|
||||
- 상태: **Answered (2026-06-26, 사장님)** — **전 인스턴스 WAC 통일**(당일 net 폐기). WAC는 일중에도 정확하므로 단기 라이브도 WAC로 통일하되, NFR-2 회귀(일중 케이스 net 일치)로 의미 보존.
|
||||
- OQ-2: 리포트 기간 정의 — 당일/지정 기간(from~to). /pnl/realized는 from_dt/to_dt(매도 기준). 멀티데이 리포트의 기본 기간·옵션.
|
||||
- 상태: Open / 처리: design, 팀장 / 종료: 기간 모델 확정 시 Answered
|
||||
- OQ-3: 매수만 한 날 "수수료 비용" 표시 방식 — 실현손익과 별도 라인(비용)으로. 수수료 출처(portfolio-hub 거래내역 REQ-039 또는 trex-core ledger).
|
||||
- 상태: Open / 처리: design, 팀장 / 종료: 표시·출처 확정 시 Answered
|
||||
- OQ-4: trex-core가 realized PnL을 어떻게 조회 — portfolio_id별 N콜 vs daemon 집계. (slots↔portfolio 1:1이라 N콜이 자연스러우나 종목 多면 비용.)
|
||||
- 상태: Open / 처리: design, 팀장 / 종료: 조회 단위 확정 시 Answered
|
||||
- OQ-5: portfolio-hub 미가용 시 degrade.
|
||||
- 상태: **Answered (2026-06-26, 사장님)** — **(b) 실현손익 섹션 에러 명시**(당일 net 폴백 없음). 나머지 섹션(보유/체결/대기주문) 정상 출력.
|
||||
|
||||
### 오픈 질문 처리 기준
|
||||
- `Open`/`Answered`/`Deferred`/`Dropped` 표준 처리.
|
||||
|
||||
# 7. 초기 성공 기준
|
||||
- S-1: 전일 이전 매수분을 당일 매도한 시나리오에서 리포트 실현손익이 **WAC 기준 정확값**(당일 net 왜곡 아님)으로 표시된다.
|
||||
- S-2: 매수만 한 기간 → 실현손익 0 + 수수료 비용 표시.
|
||||
- S-3: portfolio-hub `data_completeness_warning`이 리포트에 표면화된다.
|
||||
- S-4: portfolio-hub 미가용 시 리포트가 degrade(정의된 방식)하고 데몬 본업 무영향.
|
||||
- S-5: 일중 매수+매도(같은 날) 케이스에서 WAC 실현손익 = 기존 당일 net (단기 의미 보존, NFR-2).
|
||||
|
||||
## 측정 방식 및 데이터 출처
|
||||
- S-1/S-2: 멀티데이 픽스처(전일 매수+당일 매도, 매수만) 통합 테스트(portfolio-hub 모킹) / 신규 테스트.
|
||||
- S-3/S-4: 경고 주입·미가용 모킹 테스트.
|
||||
- S-5: 기존 operator_report 테스트 회귀.
|
||||
|
||||
# 8. REQ로 넘기기 전 확인 체크
|
||||
- [x] 적용 범위 확정 — OQ-1 = 전 인스턴스 WAC 통일
|
||||
- [x] degrade 정책 — OQ-5 = 실현손익 섹션 에러 명시
|
||||
- [ ] 기간/수수료/조회단위 design 인계 — OQ-2/3/4 (draft-req/design 단계)
|
||||
- [x] 성공지표 측정 이견 없음
|
||||
- [x] Freeze 담당자(CONFIRMED_BY) 지정 — 팀장
|
||||
|
||||
# 9. 파일 처리 결과
|
||||
- 처리 결과: 생성
|
||||
- 생성 경로: docs/discovery/dcy-035_20260626_multiday-realized-pnl-report-wac.md
|
||||
- 참조 문서:
|
||||
- docs/discovery/dcy-029(운영 리포트 REQ-088), dcy-032(cost basis 오염)
|
||||
- portfolio-hub dcy-007/REQ-040/041(WAC 실현 PnL), app/api/v1_portfolios.py:712, app/domain/pnl.py
|
||||
- trex_core/application/operator_report.py(현행 당일 net), runtime/cli.py(report)
|
||||
|
||||
# 10. 사용자 결정 필요 항목 요약
|
||||
## DECIDE
|
||||
- (OQ-1) WAC 실현손익 적용 범위: 전 인스턴스 통일 vs 중장기 모드만.
|
||||
- (OQ-5) portfolio-hub 미가용 시 degrade 방식.
|
||||
## CONFIRM
|
||||
- 본 건을 M3 중장기 운영 리포트(멀티데이 WAC 실현손익) 단위로 채택 — portfolio-hub 무변경, trex-core 연동.
|
||||
## DATA
|
||||
- (OQ-3 보조) 매수일 수수료 출처(portfolio-hub 거래내역 REQ-039 vs trex-core ledger) — 팀장 design서 확인.
|
||||
|
||||
# 11. Discovery Freeze
|
||||
## 섹션 참조
|
||||
- 범위: [# 5 > 범위 경계](#5-요구사항-목록) / 리스크·OQ: [# 6](#6-리스크가정-목록) / 성공: [# 7](#7-초기-성공-기준) / 인계 전 확인: [# 8](#8-req로-넘기기-전-확인-체크) / 결정: [# 10](#10-사용자-결정-필요-항목-요약)
|
||||
## Freeze 플래그
|
||||
- Handoff Decision: 진행 (인계) — OQ-1/OQ-5 사장님 결정 완료, OQ-2/3/4는 draft-req/design 단계 처리
|
||||
- Handoff Rationale: portfolio-hub WAC API는 이미 존재(무변경), trex-core 연동만. 적용범위=전 인스턴스 WAC 통일, degrade=섹션 에러 명시 확정. 기간/수수료/조회단위는 REQ·design에서 구체화.
|
||||
- Ready for REQ Drafting: true
|
||||
- Confirmed By: 팀장 (Claude)
|
||||
- Confirmed At (KST): 2026-06-26
|
||||
|
||||
# 12. 작성 가이드
|
||||
- trex-core dcy 템플릿 기반. Open Questions는 가정 대체 금지. confirm-discovery 시 OQ 처리 + Freeze 갱신.
|
||||
+9
-4
@@ -61,14 +61,19 @@ pkill -TERM -f trex-core-daemon
|
||||
- enter/exit 안전장치: 확인 프롬프트 기본 ON, `--yes`(paper 생략), live(`broker=kis`)는 `--yes --live` 이중 ack, `--dry-run` 미리보기. `--json`은 비대화형이라 enter/exit에 `--yes` 필수.
|
||||
- 인스턴스 라우팅: `--config <toml>` 또는 `--instance <key>`, 미지정 시 `default`.
|
||||
|
||||
### `report` — 일일 운영 리포트 (REQ-088 / dcy-029)
|
||||
### `report` — 일일 운영 리포트 (REQ-088 / dcy-029, REQ-110/111 / dcy-035)
|
||||
```bash
|
||||
.venv/bin/trex-core report # 사람용 표
|
||||
.venv/bin/trex-core report --json # 자동화용 JSON
|
||||
```
|
||||
출력: **현재 보유**(symbol/qty/avg_price), **오늘(KST) 체결 건수**, **오늘 실현 PnL**(= `ledger_outbox` net 매매 현금흐름 = Σ매도(대금−수수료−세금) − Σ매수(대금+수수료), 수수료·세금 반영 BAT-038 정책), **대기 주문**.
|
||||
- "오늘" 경계는 trading_date(KST 00:00~익일 00:00). 전일 체결은 제외.
|
||||
- 일중 진입·청산 전략에서는 net ≈ 실현손익. 미청산 보유의 평가손익은 포함하지 않는다(실현분만).
|
||||
출력: **현재 보유**(symbol/qty/avg_price), **오늘(KST) 체결 건수**, **WAC 실현 PnL**, **대기 주문**.
|
||||
|
||||
**WAC 실현 PnL (REQ-110/111, BAT-052)**: portfolio-hub WAC 기반 실현손익(`/pnl/realized`). 매수 fee가 cost basis에 편입돼 멀티데이에도 정확한 취득원가 기준 손익을 반영한다. 매수 전용일에 별도 fee 라인은 없다(매수 fee는 cost basis 편입 → 매도 시 실현).
|
||||
- **portfolio-hub 가용 시**: WAC 실현 PnL 섹션에 자산별 분해 + 이력 불완전 경고(data_completeness_warning) 출력.
|
||||
- **portfolio-hub 미가용 시**: WAC 실현 PnL 섹션에 에러 메시지 출력. 보유/대기/체결 건수 등 나머지 섹션은 정상 출력. 폴백 없음(당일 net을 WAC 위치에 대체 출력하지 않는다).
|
||||
- "오늘" 경계는 trading_date(KST 00:00~익일 00:00). 전일 체결은 제외. 미청산 보유의 평가손익은 포함하지 않는다(실현분만).
|
||||
|
||||
> 참고: 당일 net 현금흐름(`realized_pnl(trades)` 함수)은 회귀 비교용으로 코드에 보존돼 있으나 report 출력의 기본 경로가 아니다. WAC 경로는 `TREX_PORTFOLIO_HUB_BASE_URL` 설정 시 자동 활성화된다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -304,13 +304,15 @@ timeout 미지정·취소 실패로 잔여 고착된 pending 슬롯을 처분:
|
||||
- **거부 ERROR 가시성(REQ-087)**: dispatch/poll 거부 롤백 시 `event=order_reject`를 **ERROR 레벨**로 — 조용한 롤백 제거(issue #28 이 수 시간 묻혔던 원인). 정상 체결은 INFO. 알림 전송 채널은 Deferred(OQ-3).
|
||||
- **청산 reason+actor(REQ-086)**: `safety-sell fired: ... reason=<r> actor=trex-safety-sell` 로 조건축(reason)과 주체축(actor)을 함께 노출. `stop_loss`/`take_profit`/`forced_close_at` 는 watchdog expired 에서만 발화하므로 actor 동반으로 "안전망 자율청산 vs 전략 손절"을 구분(신규 reason 값·필드 없음). `slot_transition_log` 는 이미 actor 기록.
|
||||
|
||||
## Flow: `운영 조회 리포트 (trex-core report)` (BAT-046 / dcy-029, REQ-088)
|
||||
## Flow: `운영 조회 리포트 (trex-core report)` (BAT-046 / dcy-029, REQ-088; BAT-052 / dcy-035, REQ-110/111)
|
||||
|
||||
운영자 `trex-core report [--json]` — **direct-DB 시점 집계**(신규 RPC 없음, OQ-1):
|
||||
운영자 `trex-core report [--json]` — **direct-DB + portfolio-hub WAC 경로**(RPC 없음):
|
||||
1. `daily_slot` 에서 보유(status=holding: symbol/qty/avg_price) + 대기(pending_*) 조회.
|
||||
2. `ledger_outbox` 에서 오늘(trading_date KST 경계 → UTC 범위) 체결 조회.
|
||||
3. `application/operator_report.py` 순수 집계: 실현 PnL = Σ매도(qty·price − fee − tax) − Σ매수(qty·price + fee) (net 매매 현금흐름, 수수료·세금 반영 BAT-038 정책, 자산별 분해). 일중 진입·청산 전략에서 net ≈ 실현손익(미청산 평가손익 제외).
|
||||
4. text 표 또는 `--json` 출력. 일일 리포트 자동화 기반.
|
||||
3. `application/operator_report.py` 순수 집계(`build_report`): 기존 당일 net(Σ매도−Σ매수) 계산 (회귀 보존, NFR-2).
|
||||
4. **WAC 경로(REQ-110)**: `TREX_PORTFOLIO_HUB_BASE_URL` 설정 시 portfolio-hub `GET /v1/portfolios/{id}/pnl/realized` 를 portfolio_id 별 N회 호출(`get_realized_pnl(pid, from_dt, to_dt)`, from_dt/to_dt = 오늘 KST date). 응답의 `total_realized_pnl` / 자산별 `realized_pnl` 을 합산해 `apply_wac_to_report` 로 report 에 반영. 매수 fee 가 cost basis 에 편입된 WAC 기준이므로 멀티데이 정확.
|
||||
5. **degrade(REQ-111)**: portfolio-hub 미설정 또는 `PortfolioHubUnavailableError` → `wac_unavailable=True` + `wac_unavailable_reason` 기록. 폴백 없음(당일 net 을 WAC 위치에 대체하지 않음). 보유/대기/체결 건수 섹션은 정상 출력.
|
||||
6. text 표 또는 `--json` 출력. WAC 섹션 미가용 시 에러 메시지만 표시, 나머지 섹션 정상.
|
||||
|
||||
## Shared Components
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# req-111: 운영 리포트 — portfolio-hub 미가용 시 실현손익 섹션 에러 degrade
|
||||
|
||||
- Status: Approved
|
||||
- Type: Exception
|
||||
- Priority: High
|
||||
- Owner: trex-core-dev
|
||||
- Approved By: 팀장 (Claude), 2026-06-26 (confirm-req)
|
||||
|
||||
## Intent
|
||||
- portfolio-hub가 미가용(네트워크 오류, 5xx, timeout)이면 실현손익 섹션에 에러를 명시하고 나머지 섹션(보유/체결/대기주문)은 정상 출력한다. 폴백(당일 net 대체)은 없다. (dcy-035 FR-5, OQ-5=에러 명시, NFR-3=데몬 본업 격리)
|
||||
|
||||
## Requirement
|
||||
- `_cmd_report`에서 portfolio-hub WAC 조회(`get_realized_pnl`) 시 `PortfolioHubUnavailableError`가 발생하면:
|
||||
- 실현손익 섹션에 에러 메시지를 명시한다(`portfolio-hub unavailable — realized PnL unavailable` 또는 동등한 식별 가능한 문구).
|
||||
- 당일 net 값으로 대체(폴백) 하지 않는다.
|
||||
- 나머지 리포트 섹션(보유/체결/대기주문)은 정상적으로 조회·출력한다.
|
||||
- `--json` 모드에서도 동일하게 실현손익 필드를 `null` 또는 `{"error": "..."}` 형태로 표기하고 나머지 섹션은 정상 포함한다.
|
||||
- portfolio-hub 호출 실패가 `trex-core-daemon`의 슬롯/집행 루프, AMQP 연결, safety-sell 등 데몬 본업에 영향을 주지 않는다(NFR-3: report는 CLI 단독 실행, 데몬 루프 경계 외부).
|
||||
- portfolio-hub `base_url` 미설정 시도 동일하게 degrade 처리한다(설정 부재 = 미가용과 동일 경로).
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given portfolio-hub가 `PortfolioHubUnavailableError`를 raise 할 때, When `report` 실행, Then 텍스트 출력의 실현손익 섹션에 에러 문구가 표시되고 보유/체결/대기주문 섹션은 정상 출력된다.
|
||||
2. Given portfolio-hub 미가용 상황에서 `--json` 플래그, When `report` 실행, Then JSON 출력의 실현손익 관련 필드가 에러 식별 가능한 값(`null` 또는 `"error"` 키 포함)이고 `holdings`, `pending`, `fills_count` 필드는 정상 포함된다.
|
||||
3. Given portfolio-hub 미가용, When `report` 실행, Then 당일 net 값으로 대체(폴백)하지 않는다(당일 net 실현손익 값이 출력에 포함되지 않는다).
|
||||
4. Given portfolio-hub `base_url` 미설정(`cfg.portfolio_hub is None` 또는 `base_url` 빈값), When `report` 실행, Then AC-1/AC-2와 동일한 degrade 경로를 밟는다.
|
||||
5. Given `report` CLI가 portfolio-hub 호출 실패로 degrade하는 상황, When 데몬 본업(슬롯 RPC, execution 루프, safety-sell 트리거) 확인, Then report CLI의 예외가 데몬 루프로 전파되지 않는다(구조적 분리 — report는 CLI 독립 실행).
|
||||
|
||||
## Impacted Area
|
||||
- Docs: 없음(운영 설명은 REQ-110의 docs/operations.md 갱신에 포함)
|
||||
- Modules:
|
||||
- `trex_core/runtime/cli.py` — `_cmd_report` 내 `PortfolioHubUnavailableError` 및 설정 부재 catch, degrade 분기
|
||||
- Tests:
|
||||
- `tests/runtime/test_cli.py` 또는 `tests/application/test_operator_report.py` — portfolio-hub 미가용 모킹 테스트(AC-1~4), 폴백 미발생 단언(AC-3)
|
||||
|
||||
## Notes
|
||||
- OQ-5 결정(사장님, 2026-06-26): 폴백 없이 실현손익 섹션 에러 명시. 나머지 섹션 정상.
|
||||
- AC-5(데몬 본업 격리)는 구조적으로 `_cmd_report`가 `main()`의 `if args.cmd == "report"` 분기에서 독립 실행됨으로써 보장된다. 데몬 루프(`daemon.py`)는 report CLI를 호출하지 않는다.
|
||||
- NFR-1(wire 계약 불변)은 이 REQ와 무관(report는 read-only, AMQP 미관여).
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260626-01
|
||||
- Date: 2026-06-26
|
||||
- Author: trex-core-dev
|
||||
|
||||
#### Change Summary
|
||||
- dcy-035 FR-5, OQ-5(에러 명시, 폴백 없음), NFR-3(데몬 본업 격리)로부터 최초 생성(Proposed).
|
||||
|
||||
#### Intent
|
||||
- portfolio-hub 미가용 시 운영 리포트가 안전하게 degrade하고 데몬 본업에 영향이 없음을 요구사항으로 명시.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1~5 신규
|
||||
- Updated:
|
||||
- none
|
||||
- Removed:
|
||||
- none
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs: 없음
|
||||
- Modules:
|
||||
- cli.py _cmd_report degrade 분기
|
||||
- Tests:
|
||||
- 미가용 모킹 단위 테스트 신규
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미배치)
|
||||
- Releases:
|
||||
- none
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 승인 게이트 후 suggest-batch-reqs
|
||||
@@ -0,0 +1,81 @@
|
||||
# req-109: portfolio-hub /pnl/realized read 포트 메서드 + HTTP 어댑터
|
||||
|
||||
- Status: Approved
|
||||
- Type: Integration
|
||||
- Priority: High
|
||||
- Owner: trex-core-dev
|
||||
- Approved By: 팀장 (Claude), 2026-06-26 (confirm-req)
|
||||
|
||||
## Intent
|
||||
- 현재 `PortfolioHubReadAdapter`(`hub_read.py`)에는 portfolio-hub WAC 실현 PnL 조회(`GET /v1/portfolios/{id}/pnl/realized`) 메서드가 없다. 운영 리포트가 WAC 기반 실현손익을 정확하게 표시하려면 이 엔드포인트를 소비하는 포트 메서드와 HTTP 어댑터가 필요하다. (dcy-035 FR-1)
|
||||
|
||||
## Requirement
|
||||
- `PortfolioHubReadPort`(프로토콜, `trex_core/application/portfolio.py`)에 `get_realized_pnl(portfolio_id, from_dt, to_dt, *, symbol)` 메서드를 추가한다. 반환 타입은 `RealizedPnlResult` 데이터클래스로 정의한다.
|
||||
- `RealizedPnlResult`는 다음 필드를 포함한다: `items: list[RealizedPnlItem]`, `cost_basis_from: str | None`, `data_completeness_warning: bool`, `total_realized_pnl: float`. `RealizedPnlItem`은 최소 `symbol`, `realized_pnl`, `sell_qty`를 포함한다.
|
||||
- `PortfolioHubReadAdapter`(`hub_read.py`)에 위 메서드를 구현한다: `GET /v1/portfolios/{portfolio_id}/pnl/realized` 를 `from_dt`(ISO8601 date), `to_dt`(ISO8601 date), `symbol`(optional)을 query param으로 전달해 호출하고, 응답을 `RealizedPnlResult`로 파싱·반환한다. 네트워크/HTTP 오류 시 `PortfolioHubUnavailableError`를 raise 한다.
|
||||
- `StubPortfolioRead`(`stub_read.py`)에도 동일 메서드를 결정론 stub로 추가한다(테스트/CI용).
|
||||
- 기존 `whoami`, `get_portfolios`, `get_cost_basis` 메서드와 동작은 변경하지 않는다.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given portfolio-hub가 portfolio_id/from_dt/to_dt에 대해 `{"items": [...], "cost_basis_from": "...", "data_completeness_warning": false}` 형태의 200 응답을 반환할 때, When `get_realized_pnl` 호출, Then `RealizedPnlResult`가 올바른 items/cost_basis_from/data_completeness_warning/total_realized_pnl으로 반환된다.
|
||||
2. Given `symbol` 파라미터가 제공될 때, When `get_realized_pnl` 호출, Then `?symbol=<값>`이 쿼리 파라미터로 포함된다.
|
||||
3. Given portfolio-hub가 네트워크 오류 또는 비-2xx 응답을 반환할 때, When `get_realized_pnl` 호출, Then `PortfolioHubUnavailableError`가 raise 된다.
|
||||
4. Given `stub_read.py` stub, When `get_realized_pnl` 호출, Then 네트워크 없이 미리 설정한 고정 `RealizedPnlResult`를 반환한다.
|
||||
5. Given import sweep, When `trex_core/domain`을 검사, Then 신규 포트/어댑터 관련 IO/네트워크 의존이 domain에 없다(NFR: 도메인 순수성 불변).
|
||||
6. Given `data_completeness_warning: true` 응답, When 파싱, Then `RealizedPnlResult.data_completeness_warning == True`로 반영된다.
|
||||
|
||||
## Impacted Area
|
||||
- Docs: 없음(리포트 표면 갱신은 REQ-110/111)
|
||||
- Modules:
|
||||
- `trex_core/application/portfolio.py` — `PortfolioHubReadPort`에 `get_realized_pnl` 메서드 추가, `RealizedPnlResult`/`RealizedPnlItem` 데이터클래스 신규 정의
|
||||
- `trex_core/adapters/portfolio/hub_read.py` — `get_realized_pnl` HTTP 구현 추가
|
||||
- `trex_core/adapters/portfolio/stub_read.py` — stub 메서드 추가
|
||||
- Tests:
|
||||
- `tests/adapters/portfolio/test_hub_read.py` — 신규 AC-1~3, AC-6 케이스 추가
|
||||
- `tests/adapters/portfolio/test_stub_read.py` — AC-4 stub 케이스 추가
|
||||
- `tests/domain/test_domain_import_sweep.py` — AC-5 회귀(기존 sweep 재실행)
|
||||
|
||||
## Notes
|
||||
- portfolio-hub `GET /v1/portfolios/{id}/pnl/realized` 계약(REQ-040/041): from_dt/to_dt는 **매도 기준** date-filter. cost basis는 기간 밖 매수도 WAC에 포함(멀티데이 정의). 응답 envelope는 portfolio-hub `app/api/v1_portfolios.py:712`·`app/domain/pnl.py`·schemas 기준 — 구현 시 실 계약을 `hub_read.py`의 기존 `get_cost_basis` 패턴(graceful degrade 방식)에 맞춰 구현한다.
|
||||
- OQ-2(기간 파라미터 기본값 정의), OQ-3(수수료 출처), OQ-4(N콜 vs 집계) 미결 → 이 REQ는 어댑터 계층만 정의. 기간 인자는 caller(REQ-110의 CLI/report 레이어)가 결정해 전달. 어댑터는 전달된 파라미터를 그대로 쿼리에 쓴다.
|
||||
- `total_realized_pnl`은 `items`의 `realized_pnl` 합산으로 어댑터 내에서 계산(portfolio-hub 응답에 합산 필드가 있으면 재사용, 없으면 클라이언트 합산).
|
||||
- 기존 `get_cost_basis`(REQ-105 FR-7)는 sync CLI용 포트로 독립 유지 — 이 REQ와 중복 아님.
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260626-01
|
||||
- Date: 2026-06-26
|
||||
- Author: trex-core-dev
|
||||
|
||||
#### Change Summary
|
||||
- dcy-035 FR-1로부터 최초 생성(Proposed). portfolio-hub WAC 실현 PnL read 포트 + HTTP 어댑터 + stub.
|
||||
|
||||
#### Intent
|
||||
- 운영 리포트(REQ-110)가 WAC 실현손익을 표시하기 위한 하위 의존 어댑터 정의.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1~6 신규
|
||||
- Updated:
|
||||
- none
|
||||
- Removed:
|
||||
- none
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs: 없음
|
||||
- Modules:
|
||||
- portfolio.py 포트 확장, hub_read.py/stub_read.py 어댑터 신규 메서드
|
||||
- Tests:
|
||||
- test_hub_read.py, test_stub_read.py, test_domain_import_sweep.py
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미배치)
|
||||
- Releases:
|
||||
- none
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 승인 게이트 후 suggest-batch-reqs
|
||||
@@ -0,0 +1,115 @@
|
||||
# req-110: 운영 리포트 WAC 실현손익 통합 — 전 인스턴스 공통
|
||||
|
||||
- Status: Approved
|
||||
- Type: Interface
|
||||
- Priority: High
|
||||
- Owner: trex-core-dev
|
||||
- Approved By: 팀장 (Claude), 2026-06-26 (confirm-req)
|
||||
|
||||
## Intent
|
||||
- 현재 `operator_report.py`(REQ-088)의 `realized_pnl(trades)`은 당일 net 현금흐름(`Σ매도대금 − Σ매수대금 − 수수료 − 세금`)을 사용한다. 중장기 인스턴스에서 전일 매수→당일 매도 시 cost basis가 반영되지 않아 실현손익이 왜곡된다. portfolio-hub WAC API(`/pnl/realized`)를 소비해 전 인스턴스 공통으로 정확한 실현손익을 산출하고 리포트에 표시한다. (dcy-035 FR-2, FR-3, FR-4, OQ-1=전 인스턴스 WAC 통일, NFR-2)
|
||||
|
||||
## Requirement
|
||||
- `operator_report.py`의 `OperatorReport`에 WAC 실현손익 관련 필드를 추가한다: `realized_pnl_wac: float`, `realized_pnl_wac_by_asset: dict[str, float]`, `data_completeness_warning: bool`. (CHG-20260626-02: `buy_fee_only_assets` 필드 제거 — 아래 매수 fee 편입 결정 반영.)
|
||||
- 운영 리포트(`trex-core report` CLI)는 당일 net 대신 portfolio-hub WAC 실현손익(`/pnl/realized`)을 **전 인스턴스 공통**으로 사용해 실현손익을 표시한다.
|
||||
- 매도가 있는 자산: WAC 기준 실현손익(`(매도가 − WAC) × qty − fee − tax`)을 자산별로 분해해 출력한다. portfolio-hub WAC는 **매수 수수료를 cost basis에 편입**(REQ-041 CHG-20260626-01)하므로 이 실현손익은 매수·매도 fee를 모두 정확히 반영한다.
|
||||
- 매수만 한 기간(해당 자산의 매도 0): 실현손익 0으로 표시한다. **매수 수수료는 cost basis(WAC)에 편입되어 매도 시 실현**되므로, 별도 "수수료 비용" 라인은 두지 않는다(CHG-20260626-02 — 별도 라인은 매도 시 실현분과 이중계상되어 제거).
|
||||
- portfolio-hub 응답의 `data_completeness_warning: true` 시 리포트 출력에 경고를 표면화한다(텍스트/JSON 모두 포함).
|
||||
- `render_text` / `render_json`을 WAC 모드에 맞게 갱신한다.
|
||||
- CLI `report` 서브커맨드에서 portfolio-hub 설정(`cfg.portfolio_hub.base_url`)이 있을 때 WAC 경로를 사용하고, 미설정이면 REQ-111 degrade 경로로 위임한다.
|
||||
|
||||
## Acceptance Criteria
|
||||
1. Given 전일 매수 + 당일 매도 시나리오(portfolio-hub `/pnl/realized` 모킹 응답), When `report` 실행, Then 실현손익이 WAC 기준 정확값(`(매도가 − WAC) × qty − fee − tax`)으로 표시되고 당일 net 왜곡값과 다르다.
|
||||
2. Given 동일 날 **닫힌** 매수+매도(일중, buy_qty==sell_qty) 시나리오, When `report` 실행, Then WAC 실현손익이 기존 당일 net과 **정확히 일치**한다(`==`, NFR-2 회귀 보장). (CHG-20260626-02: 매수 fee 편입(REQ-041)으로 일중 닫힌 거래는 WAC==net이 엄격 성립 — `≈`가 아니라 `==`로 단언.)
|
||||
3. Given 매수만 한 기간(매도 0), When `report` 실행, Then 실현손익 0으로 표시되고 별도 수수료 비용 라인은 없다(매수 fee는 cost basis 편입, 매도 시 실현). (CHG-20260626-02: 기존 `buy_fee_only_assets` 별도 라인 제거.)
|
||||
4. Given portfolio-hub 응답에 `data_completeness_warning: true`, When `report` 실행, Then 텍스트 출력과 `--json` 출력 모두에 completeness 경고가 표시된다.
|
||||
5. Given 자산별 실현손익 분해, When `report` 실행, Then `realized_pnl_wac_by_asset`에 자산(symbol)별 WAC 실현손익이 분해된다.
|
||||
6. Given `--json` 플래그, When `report` 실행, Then `render_json` 출력에 `realized_pnl_wac`, `realized_pnl_wac_by_asset`, `data_completeness_warning` 필드가 포함된다. (CHG-20260626-02: `buy_fee_only_assets` 제거.)
|
||||
7. Given portfolio-hub `base_url` 미설정(cfg.portfolio_hub None 또는 base_url 미정의), When `report` 실행, Then REQ-111 degrade 경로로 처리된다(에러 표시).
|
||||
|
||||
## Impacted Area
|
||||
- Docs:
|
||||
- `docs/operations.md` — 리포트 실현손익 정의 갱신(당일 net → WAC 기준, 매수일 수수료 표시 명시)
|
||||
- `docs/runtime-flows.md` — operator report 흐름에 portfolio-hub WAC 호출 경로 추가
|
||||
- Modules:
|
||||
- `trex_core/application/operator_report.py` — `OperatorReport` 필드 확장, 기존 `realized_pnl(trades)` 함수는 legacy/테스트 목적 유지(NFR-2 회귀 비교용), WAC 모드 신규 함수/빌더, `render_text`/`render_json` 갱신
|
||||
- `trex_core/runtime/cli.py` — `_cmd_report` 내 portfolio-hub WAC 호출 분기, `from_dt`/`to_dt` 파라미터 전달 (OQ-2 deferred: 기본값=오늘 KST 경계로 구현)
|
||||
- Tests:
|
||||
- 신규: 멀티데이 픽스처(전일 매수+당일 매도) 통합 테스트, 매수만 기간 테스트, data_completeness_warning 테스트, NFR-2 일중 회귀 단위 테스트
|
||||
- 기존 `test_operator_report.py` 회귀 (당일 net 함수 동작 보존)
|
||||
|
||||
## Notes
|
||||
- OQ-2(기간 모델): from_dt/to_dt는 오늘(KST 경계) 기본값으로 구현. 기간 범위 확장(지정 기간)은 design/팀장 결정 대기 → 기본 구현은 "당일 KST 매도 기준" 유지, `--from`/`--to` CLI 옵션 추가는 OQ-2 확정 후 별도 change-req 또는 design 인계.
|
||||
- OQ-3(수수료 출처): 매수만 한 날의 수수료는 `ledger_outbox` 당일 거래에서 집계(기존 경로 재사용). portfolio-hub 거래내역(REQ-039) 사용 여부는 design 인계.
|
||||
- OQ-4(N콜 vs 집계): 슬롯↔portfolio 1:1이므로 portfolio_id별 N콜이 기본 구현. 집계 최적화는 design 인계.
|
||||
- 기존 `realized_pnl(trades)` 함수는 삭제하지 않고 유지한다(NFR-2 회귀 비교, 기존 테스트 보존).
|
||||
- NFR-1(wire 계약 불변): report는 read-only 운영 표면 — slots RPC/AMQP 계약 무관. REQ 명시 불필요.
|
||||
- NFR-3(데몬 본업 격리): `_cmd_report`는 CLI 직접 실행 경로이며 데몬 루프와 무관 — 격리는 구조적으로 보장됨.
|
||||
|
||||
## Change Log
|
||||
|
||||
### CHG-20260626-02
|
||||
- Date: 2026-06-26
|
||||
- Author: 팀장 (Claude)
|
||||
|
||||
#### Change Summary
|
||||
- 사장님 결정(2026-06-26): portfolio-hub WAC에 매수 수수료 편입(REQ-041 CHG-20260626-01). 그 결과 (1) 일중 닫힌 거래에서 WAC 실현손익 == 당일 net이 **엄격히** 성립 → AC-2를 `≈`에서 `==`로 강화. (2) 매수 fee가 cost basis에 편입되므로 매수전용일의 별도 "수수료 비용" 라인(`buy_fee_only_assets`)은 매도 시 실현분과 **이중계상** → 제거.
|
||||
|
||||
#### Intent
|
||||
- 매수 fee 편입 결정과 정합: 단일 정확 실현손익 숫자, NFR-2 엄격 성립, 이중계상 제거.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Updated:
|
||||
- AC-2: 일중 닫힌 거래 WAC==net **엄격 일치**(`==`)로 강화.
|
||||
- AC-3: 매수전용일 별도 수수료 비용 라인 제거(매수 fee는 cost basis 편입·매도 시 실현).
|
||||
- AC-6: `--json` 필드에서 `buy_fee_only_assets` 제거.
|
||||
- Removed:
|
||||
- `OperatorReport.buy_fee_only_assets` 필드 및 관련 산출/렌더.
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Modules: operator_report.py(buy_fee_only_assets 제거, NFR-2 테스트 강화), cli.py(해당 표시 제거)
|
||||
- Tests: NFR-2 일중 회귀 테스트를 `==` 단언으로 강화, buy_fee_only_assets 테스트 제거/대체
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: yes (BAT-052 보정 후 재검증)
|
||||
- Existing Implementation Invalidated: partial (BAT-052 구현 중 buy_fee_only_assets 부분)
|
||||
- Status Recommendation: keep-current (Approved)
|
||||
- Follow-up Action: BAT-052 보정 → 재QC → confirm-batch-verification
|
||||
|
||||
### CHG-20260626-01
|
||||
- Date: 2026-06-26
|
||||
- Author: trex-core-dev
|
||||
|
||||
#### Change Summary
|
||||
- dcy-035 FR-2/FR-3/FR-4, OQ-1(전 인스턴스 WAC 통일), NFR-2(일중 회귀)로부터 최초 생성(Proposed).
|
||||
|
||||
#### Intent
|
||||
- 중장기 인스턴스 실현손익 왜곡 해소 + 단기 회귀 보장 + 매수일 수수료 표시 + completeness 경고 표면화.
|
||||
|
||||
#### Acceptance Criteria Delta
|
||||
- Added:
|
||||
- AC-1~7 신규
|
||||
- Updated:
|
||||
- none
|
||||
- Removed:
|
||||
- none
|
||||
|
||||
#### Impacted Area Delta
|
||||
- Docs:
|
||||
- docs/operations.md, docs/runtime-flows.md
|
||||
- Modules:
|
||||
- operator_report.py 확장, cli.py _cmd_report 분기
|
||||
- Tests:
|
||||
- 신규 멀티데이/매수전용/completeness/NFR-2 테스트, 기존 회귀
|
||||
|
||||
#### Delivery Trace Delta
|
||||
- Batches:
|
||||
- none (미배치)
|
||||
- Releases:
|
||||
- none
|
||||
|
||||
#### Revalidation Impact
|
||||
- Reverification Needed: no
|
||||
- Existing Implementation Invalidated: no
|
||||
- Status Recommendation: keep-current
|
||||
- Follow-up Action: confirm-req 승인 게이트 후 suggest-batch-reqs
|
||||
+5
-1
@@ -74,7 +74,7 @@
|
||||
|
||||
## Next Requirement ID
|
||||
|
||||
- Current: `REQ-109`
|
||||
- Current: `REQ-112`
|
||||
- Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다.
|
||||
|
||||
## Requirement Register
|
||||
@@ -189,9 +189,13 @@
|
||||
| REQ-106 | trex-core verify 명령 — C 불일치 슬롯 재대조 + warning 해제 (C 전용 OQ-C) | Interface | Implemented | High | trex-core-dev | [req-106](./Interface/req-106_verify-cli-command.md) |
|
||||
| REQ-107 | 차단/해제 전이 식별 로깅 + 고영향 불일치 알람 | Exception | Implemented | High | trex-core-dev | [req-107](./Exception/req-107_blocking-reconcile-logging-and-alarm.md) |
|
||||
| REQ-108 | 차단형 reconcile 비기능 요건 — 내구성·회귀 방지·자동 정정 금지·wire 계약 최소 침습 | Non-Functional | Implemented | High | trex-core-dev | [req-108](./Non-Functional/req-108_reconcile-blocking-regression-and-nfr.md) |
|
||||
| REQ-109 | portfolio-hub /pnl/realized read 포트 메서드 + HTTP 어댑터 | Integration | Approved | High | trex-core-dev | [req-109](./Integration/req-109_portfolio-hub-realized-pnl-read-adapter.md) |
|
||||
| REQ-110 | 운영 리포트 WAC 실현손익 통합 — 전 인스턴스 공통 | Interface | Approved | High | trex-core-dev | [req-110](./Interface/req-110_operator-report-wac-realized-pnl.md) |
|
||||
| REQ-111 | 운영 리포트 — portfolio-hub 미가용 시 실현손익 섹션 에러 degrade | Exception | Approved | High | trex-core-dev | [req-111](./Exception/req-111_operator-report-realized-pnl-degrade.md) |
|
||||
|
||||
## Recent Change Log Summary
|
||||
|
||||
- 2026-06-26 :: REQ-109~111 **신규 생성(Proposed, draft-req dcy-035)**: 멀티데이 실현 PnL — WAC 기반 운영 리포트. REQ-109(portfolio-hub /pnl/realized read 포트+어댑터+stub), REQ-110(운영 리포트 WAC 실현손익 통합: 전 인스턴스 공통, 매수일 수수료 별도, completeness 경고), REQ-111(portfolio-hub 미가용 시 실현손익 섹션 에러 degrade, 폴백 없음). NFR-1(wire 불변)/NFR-3(데몬 격리)은 REQ-111 AC/Notes 흡수. NFR-2(일중 WAC==net 회귀)는 REQ-110 AC-2 흡수. OQ-2(기간)/OQ-3(수수료 출처)/OQ-4(N콜) design 인계.
|
||||
- 2026-06-26 :: REQ-102~108 **Approved → Implemented** (confirm-req-implemented, dcy-034): BAT-050(core-A, PR #31 ced7eda) + BAT-051(core-B, PR #32 07b393d) origin/main 머지 완료. QC(opus) PASS + 팀장 confirm-batch-verification. 최종 683 passed/47 skipped/0 fail. 차단형 reconcile(REQ-102·103), 전략 차단 게이트(REQ-104), sync/verify CLI(REQ-105·106), 차단/해제 로깅+알람(REQ-107), NFR(REQ-108) 전 AC 검증 완료. M3 배포 대기.
|
||||
- 2026-06-25 :: REQ-102~108 **Proposed → Approved** (confirm-req, 팀장): dcy-034 OQ-A/B/C 결정 + FR-7 참조 구체화 반영. **OQ-A**: REQ-105에 INACTIVE→HOLDING 전이 엣지 추가 승인(actor=sync/reconcile 제한, wire 무영향 — enum 5개 불변·엣지만 추가), AC-10 신규, FR-7 → portfolio-hub dcy-008/REQ-043(`GET /v1/portfolios/{id}/holdings/cost-basis`) 구체 참조 갱신. **OQ-B**: REQ-104에 차단 예외 명시 — 내부 안전망(safety-sell/watchdog/kill-switch)은 reconcile-warning 차단 우회, AC-2 갱신. **OQ-C**: REQ-106에 verify=C 전용 확정, B 케이스는 sync(REQ-105) 책임 명시. REQ-102·103·107·108은 초안 그대로 승인.
|
||||
- 2026-06-25 :: REQ-102~108 **신규 생성(Proposed, draft-req dcy-034)**: 슬롯↔실계좌 차단형 reconcile + sync/verify 명령 — M3 cold-start. REQ-102(durable warning store), REQ-103(B/C 차단형 reconcile), REQ-104(pause∨warning OR 게이트), REQ-105(sync CLI), REQ-106(verify CLI), REQ-107(차단/해제 로깅+알람), REQ-108(NFR: 내구성·회귀·자동정정금지·wire계약최소침습). FR-7(portfolio-hub WAC read)은 portfolio-hub-dev 별도 REQ로 위임, trex-core REQ에는 의존 참조만 포함.
|
||||
|
||||
@@ -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,10 +1,12 @@
|
||||
"""운영 조회 리포트 집계 단위 테스트 (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,
|
||||
kst_day_bounds_utc,
|
||||
realized_pnl,
|
||||
@@ -74,3 +76,266 @@ 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)
|
||||
# CHG-20260626-02: buy_fee_only_assets·buy_fee_by_asset 제거 (cost basis 편입)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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},
|
||||
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},
|
||||
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 실현손익 == 당일 net (완전 동치).
|
||||
|
||||
portfolio-hub WAC가 매수 fee를 cost basis에 편입하므로(CHG-20260626-02):
|
||||
WAC = (1000*10 + 5) / 10 = 1000.5
|
||||
realized = (1100 - 1000.5) * 10 - 5 - 33 = 957
|
||||
당일 net도 957이므로 닫힌 거래에서 엄격 동치가 성립한다.
|
||||
"""
|
||||
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(편입 후) = (1000*10+5)/10 = 1000.5
|
||||
# realized_pnl_wac = (1100-1000.5)*10 - 5 - 33 = 9950 - 38 - 5 - 33 = 957
|
||||
apply_wac_to_report(
|
||||
rep,
|
||||
wac_total=957.0,
|
||||
wac_by_asset={"A": 957.0},
|
||||
data_completeness_warning=False,
|
||||
)
|
||||
# NFR-2 엄격화 (AC-2): 일중 닫힌 거래에서 WAC 실현손익 == 당일 net
|
||||
assert rep.realized_pnl_wac == rep.realized_pnl # 957 == 957
|
||||
|
||||
def test_buy_only_asset_no_fee_line(self):
|
||||
"""REQ-110 AC-3(변경): 매수만 있는 자산 → 실현 0, 별도 수수료 라인 없음.
|
||||
|
||||
CHG-20260626-02: 매수 fee가 cost basis에 편입 → 매도 시 실현되므로
|
||||
매수 전용일에 별도 "수수료 비용" 라인을 표시하지 않는다(이중계상 방지).
|
||||
"""
|
||||
rep = build_report(
|
||||
holdings=[Holding("000660", "pf-2", 10, 50000)],
|
||||
pending=[],
|
||||
trades=[TradeRow("pf-2", "buy", 10, 50000, fee=250)],
|
||||
)
|
||||
apply_wac_to_report(
|
||||
rep,
|
||||
wac_total=0.0,
|
||||
wac_by_asset={},
|
||||
data_completeness_warning=False,
|
||||
)
|
||||
assert rep.realized_pnl_wac == 0.0
|
||||
# 별도 수수료 라인 없음
|
||||
text = render_text(rep)
|
||||
assert "매수 전용 자산 수수료" not 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={},
|
||||
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},
|
||||
data_completeness_warning=False,
|
||||
)
|
||||
text = render_text(rep)
|
||||
assert "005930" in text and "000660" in text
|
||||
|
||||
def test_render_json_has_three_wac_fields(self):
|
||||
"""REQ-110 AC-6: --json에 신규 3 WAC 필드 포함 (CHG-20260626-02: buy_fee_only_assets 제거).
|
||||
|
||||
필드: realized_pnl_wac, realized_pnl_wac_by_asset, data_completeness_warning.
|
||||
"""
|
||||
rep = build_report([], [], [])
|
||||
apply_wac_to_report(
|
||||
rep,
|
||||
wac_total=5000.0,
|
||||
wac_by_asset={"X": 5000.0},
|
||||
data_completeness_warning=False,
|
||||
)
|
||||
j = render_json(rep)
|
||||
assert "realized_pnl_wac" in j
|
||||
assert "realized_pnl_wac_by_asset" in j
|
||||
assert "data_completeness_warning" in j
|
||||
assert "buy_fee_only_assets" not 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["data_completeness_warning"] is None
|
||||
assert "buy_fee_only_assets" not in j # CHG-20260626-02: 필드 자체 제거됨
|
||||
# 에러 필드 존재
|
||||
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,358 @@
|
||||
"""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_three_wac_fields(self, capsys):
|
||||
"""REQ-110 AC-6: --json에 3 WAC 필드 포함 (CHG-20260626-02: buy_fee_only_assets 제거).
|
||||
|
||||
필드: realized_pnl_wac, realized_pnl_wac_by_asset, data_completeness_warning.
|
||||
"""
|
||||
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",
|
||||
"data_completeness_warning"):
|
||||
assert field in j, f"missing field: {field}"
|
||||
assert "buy_fee_only_assets" not in j
|
||||
|
||||
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_no_fee_line(self, capsys):
|
||||
"""REQ-110 AC-3(변경): 매수만 → 실현 0, 별도 수수료 라인 없음.
|
||||
|
||||
CHG-20260626-02: 매수 fee 편입 → 매수 전용 자산 수수료 섹션 제거.
|
||||
"""
|
||||
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 "매수 전용 자산 수수료" not 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,16 @@ 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)
|
||||
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 +98,24 @@ def build_report(
|
||||
)
|
||||
|
||||
|
||||
def apply_wac_to_report(
|
||||
rep: OperatorReport,
|
||||
*,
|
||||
wac_total: float,
|
||||
wac_by_asset: dict[str, float],
|
||||
data_completeness_warning: bool,
|
||||
) -> None:
|
||||
"""REQ-110: WAC 실현손익을 OperatorReport에 in-place로 적용한다.
|
||||
|
||||
순수 도메인 계층에서는 이 헬퍼가 아닌 build_report를 사용하고,
|
||||
CLI(_cmd_report)에서 portfolio-hub 응답을 받아 이 함수로 주입한다.
|
||||
CHG-20260626-02: 매수 fee가 cost basis에 편입되어 buy_fee_only_assets 제거됨.
|
||||
"""
|
||||
rep.realized_pnl_wac = wac_total
|
||||
rep.realized_pnl_wac_by_asset = dict(wac_by_asset)
|
||||
rep.data_completeness_warning = data_completeness_warning
|
||||
|
||||
|
||||
def render_text(rep: OperatorReport) -> str:
|
||||
"""사람용 표 요약."""
|
||||
lines: list[str] = []
|
||||
@@ -99,15 +125,26 @@ 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 산출이 부정확할 수 있습니다.")
|
||||
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 +157,14 @@ 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 필드 (CHG-20260626-02: buy_fee_only_assets 제거)
|
||||
"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,
|
||||
"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)."""
|
||||
|
||||
@@ -33,7 +33,7 @@ import argparse
|
||||
import json as _json
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from trex_core.adapters.messaging import topology
|
||||
@@ -666,18 +666,20 @@ 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 trex_core.application.operator_report import (
|
||||
Holding,
|
||||
PendingOrder,
|
||||
TradeRow,
|
||||
apply_wac_to_report,
|
||||
build_report,
|
||||
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 +711,59 @@ 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 = timezone(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
|
||||
|
||||
apply_wac_to_report(
|
||||
rep,
|
||||
wac_total=wac_total,
|
||||
wac_by_asset=wac_by_asset_accum,
|
||||
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