141 lines
10 KiB
Markdown
141 lines
10 KiB
Markdown
<!-- Source URL: https://github.com/fourblocks2020/portfolio-hub/issues/29
|
|
Source author: genichin
|
|
Source created_at: 2026-08-18T10:04:45Z
|
|
Body SHA-256: 833b5c8e2dd4bb6c90331bef58a0fdee48534fc1458a82b646f1581877428b66
|
|
Snapshot: exact source body begins after this header.
|
|
-->
|
|
|
|
## 배경
|
|
|
|
`trex-web` 포트폴리오 상세 화면은 현재 다음 manual 전용 계약만 사용합니다.
|
|
|
|
```http
|
|
GET /v1/portfolios/{portfolio_id}/ledger/manual?limit=50
|
|
```
|
|
|
|
이 계약은 수동으로 기록한 원장만 표시하므로, broker에서 유입된 체결/원장 이벤트를 포함한 포트폴리오의 통합 원장 화면을 제공할 수 없습니다.
|
|
|
|
현재 provider 확인 근거:
|
|
|
|
- `app/api/v1_portfolios.py`의 manual 목록 route는 `require_read` 뒤에 user/session token 및 portfolio owner를 확인한다.
|
|
- `app/repositories/postgres.py`의 `list_manual_ledger_entries()`는 `WHERE portfolio_id = %s AND source = 'manual'`로 필터하고 `recorded_at DESC, ledger_entry_id DESC`로 정렬한다.
|
|
- 도메인 `LedgerEntry`는 `source`를 보유하며 기본값은 `broker`; manual 기록은 `source='manual'`이다.
|
|
- 현재 응답의 `total`은 전체 일치 건수가 아니라 반환된 `items` 길이이다.
|
|
|
|
## 목표
|
|
|
|
포트폴리오 소유자가 manual과 broker를 포함한 **모든 출처의 원장 기록**을 하나의 안정적이고 page 가능한 read 계약으로 조회할 수 있게 한다. 소비자 UI는 owner가 제공한 원장 사실을 표시할 뿐, 원장 action이나 잔고 효과를 재계산하지 않는다.
|
|
|
|
## 제안 API 초안
|
|
|
|
```http
|
|
GET /v1/portfolios/{portfolio_id}/ledger?limit=50
|
|
GET /v1/portfolios/{portfolio_id}/ledger?limit=50&cursor=...
|
|
GET /v1/portfolios/{portfolio_id}/ledger?source=manual&limit=50
|
|
GET /v1/portfolios/{portfolio_id}/ledger?source=broker&limit=50
|
|
```
|
|
|
|
- `source` 미지정은 모든 출처를 최신순으로 반환한다.
|
|
- `source`가 지정되면 비어 있지 않은 provenance 문자열을 exact match로 사용한다. 현재 결과가 없는 값도 오류가 아니라 빈 page(`items=[]`, `next_cursor=null`)를 반환한다.
|
|
- 기본 정렬은 `recorded_at DESC, ledger_entry_id DESC`이며, cursor는 두 값을 함께 고정해 동률에서도 중복/누락 없이 다음 페이지를 반환해야 한다.
|
|
- 응답 `items`는 적어도 `ledger_entry_id`, `portfolio_id`, `source`, canonical 대문자 `action`, `recorded_at`, `executed_at`(있는 경우), order/execution 식별자(있는 경우), 수량·가격·수수료·세금·note, 기록 후 owner snapshot, reversal 참조와 confirmation evidence를 표현해야 한다.
|
|
- 새 통합 endpoint는 `side`를 노출하지 않고 canonical 대문자 `action`만 노출한다. 공통 필드와 출처별 필드는 response schema에 명시하며, 해당 사실에 값이 없을 때만 `null`을 반환한다. broker 원장과 manual 원장의 필드 공백을 0이나 추정값으로 채우지 않는다.
|
|
|
|
## Open Questions
|
|
|
|
각 OQ에서 하나의 선택지를 선택해 주세요. 선택은 Discovery 결정을 위한 것이며 구현·push·배포 승인을 뜻하지 않습니다.
|
|
|
|
### OQ-001 — pagination 계약
|
|
|
|
- [x] **A. Cursor pagination** — `recorded_at`과 `ledger_entry_id` 기반 opaque cursor, `next_cursor` 반환.
|
|
- [ ] **B. Offset pagination** — `offset`과 `limit`으로 페이지 이동.
|
|
- [ ] **C. 단일 무제한 응답** — 모든 원장을 한 응답에 반환.
|
|
|
|
**권장안: A** — 원장은 계속 증가하며, 최신순 목록에 새 행이 들어와도 cursor가 offset보다 중복·누락에 강하다. 무제한 응답은 응답 크기와 DB 부하 경계가 없다.
|
|
|
|
### OQ-002 — source 필터 정책
|
|
|
|
- [x] **A. 전체 출처가 기본값이며, 선택적 `source` filter를 제공한다.**
|
|
- [ ] **B. 전체 출처만 제공하며, source filter는 제공하지 않는다.**
|
|
- [ ] **C. manual/broker 전용 endpoint를 계속 분리하고 통합 endpoint는 만들지 않는다.**
|
|
|
|
**권장안: A** — 통합 운영 화면의 기본 요구를 충족하면서, manual-only 감사 화면 같은 소비자는 같은 안정적 계약에서 명시적으로 좁힐 수 있다. 기존 `/ledger/manual`은 하위호환성을 위해 유지한다.
|
|
|
|
### OQ-003 — read 권한 경계
|
|
|
|
- [x] **A. 현재 manual 조회와 동일하게 user/session token + portfolio owner만 허용한다.**
|
|
- [ ] **B. owner 외 admin도 명시적으로 허용한다.**
|
|
- [ ] **C. daemon service token에도 portfolio scope 내 조회를 허용한다.**
|
|
|
|
**권장안: A** — 최초 소비자인 trex-web의 사용자 상세 조회를 최소 권한으로 충족한다. admin/service 조회는 실제 운영 소비자와 audit 노출 범위를 별도로 확정한 뒤 additive하게 설계하는 편이 안전하다.
|
|
|
|
### OQ-004 — 전체 건수(`total`) 의미
|
|
|
|
- [x] **A. `next_cursor`만 반환하고 전체 건수는 제공하지 않는다.**
|
|
- [ ] **B. 정확한 전체 일치 건수를 `total`로 반환한다.**
|
|
- [ ] **C. 현재 page의 반환 건수를 `total`로 반환한다.**
|
|
|
|
**권장안: A** — 현재 manual endpoint의 `total=len(items)`와 전역 건수 의미를 혼동하지 않으며, 목록 UI에 필요한 다음 페이지 판단도 충족한다. 정확한 total은 필요성이 확인될 때 별도 비용·일관성 계약으로 추가한다.
|
|
|
|
### OQ-005 — 통합 원장 item의 정규 응답 계약
|
|
|
|
- [x] **A. 하나의 canonical `action` 필드를 대문자 enum으로 제공하고, 모든 공통·출처별 필드는 schema에 명시적으로 nullable로 선언한다. — 결정**
|
|
- 새 통합 endpoint는 `side`를 노출하지 않는다.
|
|
- `action`은 manual/broker 모두에서 동일한 대문자 vocabulary를 사용한다.
|
|
- 값이 없는 source별 사실은 0 또는 추정값으로 대체하지 않고 `null`로 표현한다.
|
|
- [ ] B. `action`과 `side`를 모두 노출하고 source별 field 존재·동치·casing 규칙을 명시한다.
|
|
- [ ] C. broker와 manual을 별도 item type/discriminator schema로 제공한다.
|
|
|
|
### OQ-006 — cursor scope 및 오류 계약
|
|
|
|
- [x] **A. opaque cursor는 `portfolio_id`, 선택된 `source` filter 및 `(recorded_at, ledger_entry_id)`를 결속한다. malformed·tampered·다른 portfolio/source에 재사용한 cursor는 HTTP 422로 거부한다. 마지막 page의 `next_cursor`는 `null`이다. (권장)**
|
|
- 다른 목록 조건의 cursor 혼용을 fail-closed하고, 소비자는 `next_cursor is null`로 종료를 판정한다.
|
|
- [ ] B. cursor에는 `(recorded_at, ledger_entry_id)`만 담고, portfolio/source 불일치는 구현이 현재 query 결과로 처리한다.
|
|
- cursor 재사용의 의미와 오류가 모호해진다.
|
|
- [ ] C. cursor pagination을 유지하되 cursor 오류·scope 동작은 구현 단계에서 정한다.
|
|
- 외부 API의 실패 동작을 구현자 재량으로 남긴다.
|
|
|
|
### OQ-007 — `source` parameter의 허용값 및 결과 없음 의미
|
|
|
|
- [x] **A. `source`는 비어 있지 않은 provenance 문자열을 exact match로 허용하며, 알 수 없는 값은 빈 page(`items=[]`, `next_cursor=null`)를 반환한다. — 결정**
|
|
- all-source 기본값과 미래 producer source를 별도 API 변경 없이 지원한다.
|
|
- source 값은 portfolio owner 권한 검증 뒤에만 평가한다.
|
|
- [ ] B. `source`는 현재 `manual`·`broker` enum만 허용하고 그 외 값은 HTTP 422로 거부한다.
|
|
- [ ] C. known source 조회는 허용하되, source 목록을 API metadata로 제공한 뒤 그 목록 밖 값의 처리 규칙을 별도 계약으로 정한다.
|
|
|
|
## 포함 범위
|
|
|
|
- source-중립 통합 원장 read endpoint, request/response schema, repository query와 cursor semantics.
|
|
- portfolio ownership 및 선택된 권한 모델의 명시적 검증.
|
|
- manual과 broker 행 혼합, 동일 `recorded_at` tie-breaker, page 경계, source filter, token/타인 portfolio 경계에 대한 계약 테스트.
|
|
- 필요한 DB index 및 migration 여부 검토. 실제 PostgreSQL에서 pagination query plan과 migration을 검증한다.
|
|
- OpenAPI/API interface 문서 갱신.
|
|
- 기존 `GET /v1/portfolios/{portfolio_id}/ledger/manual` 계약의 하위호환성 회귀 테스트.
|
|
|
|
## 제외 범위
|
|
|
|
- ledger 생성·수정·삭제, `REVERSAL` 실행 또는 기존 원장의 재해석.
|
|
- broker 주문/체결 생성, strategy signal, outbox/producer 동작 변경.
|
|
- 원장에 없는 경제적 효과·잔고·PnL을 API나 trex-web이 추론하는 기능.
|
|
- portfolio return aggregate 의미 변경(관련 이슈 #28의 별도 범위).
|
|
- trex-web UI 구현, push/PR/merge, 운영 배포·릴리스.
|
|
|
|
## 수용 기준 초안
|
|
|
|
- [ ] OQ-001~007 선택이 issue 본문에 결정으로 기록된다.
|
|
- [ ] 소유자가 자신의 portfolio에 대해 선택된 source 범위의 원장을 결정된 정렬·pagination 규칙으로 조회할 수 있다.
|
|
- [ ] 서로 다른 출처의 행이 누락 없이 공통 read model로 표현되며, 새 통합 endpoint는 대문자 canonical `action`만 노출한다. 없는 source별 사실은 추정값이 아닌 schema의 명시된 nullable 값이다.
|
|
- [ ] cursor 페이지 경계에서 동일 시각 행의 중복·누락이 없고, cursor의 portfolio/source scope 및 malformed·tampered cursor 거부 동작은 OQ-006 결정과 일치한다.
|
|
- [ ] 선택된 권한 범위 밖의 token 또는 타인 portfolio는 기존 정책에 맞는 거부 응답을 받는다.
|
|
- [ ] `source` 필터가 선택되면 지정 provenance와 exact match인 row만 반환하고, 결과 없는 non-empty 값은 빈 page(`items=[]`, `next_cursor=null`)를 반환한다. 미지정 기본 동작은 OQ-002·OQ-007 결정과 일치한다.
|
|
- [ ] `/ledger/manual`의 source=manual 전용·최신순·limit 계약은 하위호환으로 유지된다.
|
|
- [ ] API schema/OpenAPI와 실제 응답이 일치하고, focused 및 전체 회귀 테스트가 통과한다.
|
|
|
|
## 관련
|
|
|
|
- 소비자 맥락: `fourblocks2020/trex-web#15`
|
|
- 별도 집계 의미 변경: #28
|
|
- 작성 시각: 2026-08-18T19:04:00+09:00
|
|
|
|
> **Discovery 상태: APPROVED** — requester가 OQ-001~007을 모두 명시적으로 결정했고, 2026-08-20 KST에 현재 provider/data-flow/ownership 계약과 대조하여 승인했다. 이 승인은 구현·커밋·push·PR·merge·배포·릴리스 승인을 포함하지 않습니다.
|