docs(proofline): capture portfolio ledger discovery
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<!-- Source URL: https://github.com/fourblocks2020/portfolio-hub/issues/29#issuecomment-5350993550
|
||||
Source author: genichin
|
||||
Source created_at: 2026-08-20T03:28:21Z
|
||||
Body SHA-256: ce25425fa95afad70221be88a8df88fe39b13f706bbc4f8508a0be34a8327187
|
||||
Snapshot: exact source body begins after this header.
|
||||
-->
|
||||
|
||||
## 구현 완료
|
||||
|
||||
포트폴리오 소유자가 manual·broker를 포함한 모든 출처의 원장을 cursor 기반으로 조회하는 통합 API를 로컬 `main`에 구현·검증·커밋했습니다.
|
||||
|
||||
### 구현 내용
|
||||
|
||||
- `GET /v1/portfolios/{portfolio_id}/ledger`를 추가해 모든 source의 원장을 `recorded_at DESC, ledger_entry_id DESC` 순서로 반환합니다.
|
||||
- optional exact `source` filter, HMAC-signed opaque cursor 및 portfolio/source 조건 결속을 구현했습니다. malformed·tampered·다른 조건의 cursor는 HTTP 422로 거부합니다.
|
||||
- 통합 item은 canonical 대문자 `action`만 노출하며 `side`는 노출하지 않습니다. 출처별로 없는 사실은 추정값 대신 nullable field로 표현합니다.
|
||||
- PostgreSQL repository keyset query와 `(portfolio_id, recorded_at DESC, ledger_entry_id DESC)` index migration (`20260820_0030`)을 추가했습니다.
|
||||
- source filter, owner/service-token 권한 경계, cursor 오류, OpenAPI, release migration-head contract 및 `temp/` 제외 source-surface 검사를 회귀로 보완했습니다.
|
||||
|
||||
### 검증 결과
|
||||
|
||||
전체 회귀와 변경 범위 검증을 완료했습니다.
|
||||
|
||||
- `pytest -q` → `664 passed, 16 skipped`
|
||||
- focused API·release·auth-surface 검증 → `29 passed`
|
||||
- disposable PostgreSQL fresh migration → head `20260820_0030`, `idx_portfolio_ledger_portfolio_recorded_entry_desc` 생성 확인
|
||||
- 변경 범위 lint 및 `git diff --check` → 통과
|
||||
- 로컬 커밋 → `ac742b1150832f6c1cf58744308b4f3ea6803fd4` (`feat(portfolios): add unified ledger history API`)
|
||||
|
||||
### 비차단 후속 권고
|
||||
|
||||
- 운영 환경에서는 `PORTFOLIOHUB_LEDGER_CURSOR_SECRET`를 장기 랜덤 secret으로 설정하고, cursor secret 교체 시 기존 cursor의 유효기간·무효화 정책을 운영 문서에 명시하는 것을 권고합니다.
|
||||
- 실제 운영 규모의 ledger row가 있는 환경에서 `EXPLAIN ANALYZE`로 source-filter 포함 query의 index 사용과 latency를 추가 관찰하는 것을 권고합니다.
|
||||
|
||||
> 현재 상태는 **로컬 구현·검증·커밋 완료**입니다. push·PR·배포·릴리스 완료를 의미하지 않습니다.
|
||||
@@ -0,0 +1,36 @@
|
||||
<!-- Source URL: https://github.com/fourblocks2020/portfolio-hub/issues/29#issuecomment-5349683566
|
||||
Source author: genichin
|
||||
Source created_at: 2026-08-20T00:30:07Z
|
||||
Body SHA-256: e90881b5df47b17650169c0858e7f39c300682ab70d3a88032b6fe41d31f65c7
|
||||
Snapshot: exact source body begins after this header.
|
||||
-->
|
||||
|
||||
## 구현 진행 상황
|
||||
|
||||
- **대상:** [portfolio-hub] discovery: 포트폴리오 전체 원장 조회 API (#29)
|
||||
- **브랜치:** `main`
|
||||
- **기준:** `main` (`fe22be0b6686f346f4e6a5c9b64f7b3469e75810`)
|
||||
- **상태:** 로컬 main 구현·검증·커밋 완료 — 미푸시, 미배포, 미릴리스
|
||||
- **최종 업데이트:** 2026-08-20T11:05:00+09:00 (KST)
|
||||
|
||||
### 구현 계획 및 작업 목록
|
||||
|
||||
- [x] 통합 원장 조회 API의 응답·입력 계약을 추가했다.
|
||||
- 모든 출처를 하나의 대문자 `action` 모델로 표현하고, 출처별로 없는 사실은 `null`로 명시한다.
|
||||
- [x] source 필터와 cursor 기반 페이지 조회를 구현했다.
|
||||
- cursor는 portfolio·source 조건과 정렬 기준에 결속하며, 잘못되거나 다른 조건의 cursor는 HTTP 422로 거부한다.
|
||||
- [x] 소유자 권한과 기존 manual 전용 조회의 하위호환성을 검증했다.
|
||||
- user/session owner만 허용하고 기존 `/ledger/manual` 계약을 변경하지 않았다.
|
||||
- [x] API/OpenAPI 문서와 단위·통합 회귀 검증을 추가·실행했다.
|
||||
- manual·broker 혼합, source filter, 권한 경계와 cursor 오류를 검증했다.
|
||||
- [x] 실제 PostgreSQL query plan 및 index/migration을 검증했다.
|
||||
- disposable PostgreSQL DB에서 `20260820_0030` migration head와 `idx_portfolio_ledger_portfolio_recorded_entry_desc` 생성을 확인했다.
|
||||
|
||||
### 진행 기록
|
||||
|
||||
- 2026-08-20T09:29:48+09:00 (KST): 승인된 Discovery(OQ-001~007)와 현재 `main` 기준 SHA를 확인했다. 별도 구현 worktree는 만들지 않고 `main`에서 구현·검증을 시작한다.
|
||||
- 2026-08-20T10:40:54+09:00 (KST): source-중립 `GET /v1/portfolios/{portfolio_id}/ledger` vertical slice를 추가했다. focused `tests/test_v1_portfolios_manual_ledger.py`는 `14 passed`였다.
|
||||
- 2026-08-20T10:55:00+09:00 (KST): disposable PostgreSQL fresh upgrade에서 migration head와 unified-ledger keyset index 생성을 확인했다.
|
||||
- 2026-08-20T11:05:00+09:00 (KST): 권한/source/OpenAPI/release-contract 회귀를 보완했다. 전체 회귀 `664 passed, 16 skipped`, 변경 범위 lint와 `git diff --check`를 통과했고, 로컬 `main`에 `ac742b1`로 커밋했다. `temp/`는 후보 source가 아니므로 auth-surface 정적 검사의 재귀 대상에서 제외했다.
|
||||
|
||||
> 각 작업이 실제로 완료되고 검증될 때마다 이 댓글의 체크박스와 진행 기록을 갱신한다. 체크 표시는 구현 완료 또는 해당 검증의 실제 통과를 의미하며, 배포·릴리스 완료를 의미하지 않는다.
|
||||
@@ -0,0 +1,140 @@
|
||||
<!-- 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·배포·릴리스 승인을 포함하지 않습니다.
|
||||
Reference in New Issue
Block a user