5 Commits
Author SHA1 Message Date
genichin 6e17a53b17 chore(release): prepare portfolio-hub v0.2.0
Publish verified PortfolioHub image / publish (push) Canceled after 0s
Publish verified PortfolioHub image / verify (push) Canceled after 0s
2026-08-21 17:13:34 +09:00
genichin f48a7c5cd7 docs(proofline): capture portfolio ledger discovery 2026-08-20 12:36:24 +09:00
genichin ac742b1150 feat(portfolios): add unified ledger history API 2026-08-20 12:01:28 +09:00
genichin fe22be0b66 docs: record ProofLine specification for issue 28 2026-08-19 17:42:22 +09:00
genichin 5eb8c04266 fix: aggregate investment totals across ledger sources 2026-08-19 17:34:13 +09:00
40 changed files with 1409 additions and 89 deletions
+1
View File
@@ -3,6 +3,7 @@ PORTFOLIOHUB_ALLOWED_ORIGINS=http://localhost:3000
PORTFOLIOHUB_READ_TOKENS=read-token-123
PORTFOLIOHUB_TRADE_WRITE_TOKENS=trade-token-456
PORTFOLIOHUB_ADMIN_TOKENS=admin-token-789
PORTFOLIOHUB_LEDGER_CURSOR_SECRET=replace-with-a-long-random-secret
PORTFOLIOHUB_ENVIRONMENT=development
# Enable DB-backed repository (uses TIMESCALEDB_* below)
+6 -5
View File
@@ -3,14 +3,15 @@ id: "ac-0001"
status: active
---
# 포트폴리오 목록의 ledger 투자금 집계 필드
# 포트폴리오 목록의 source-independent 투자금 집계 필드
## Criterion
`GET /v1/portfolios`의 각 `PortfolioItem`은 기존 응답 필드를 유지하면서 `net_contributed_capital`과 `cumulative_profit_withdrawn`을 float 값으로 포함해야 한다. 전자는 순투자현금, 후자는 누적 수익 인출액을 뜻하며, 해당 portfolio에 유효한 manual ledger가 없거나 집계 결과가 없으면 두 값은 각각 `0.0`이어야 한다.
`GET /v1/portfolios`의 각 `PortfolioItem`은 기존 응답 필드를 유지하면서 `net_contributed_capital`과 `cumulative_profit_withdrawn`을 float 값으로 포함해야 한다. 전자는 모든 source의 유효한 `INVEST` gross 합계에서 유효한 `WITHDRAW` 합계를 뺀 순투자원금이고, 후자는 원금과 분리한 모든 source의 유효한 `PROFIT` 합계다. 해당 portfolio에 유효한 대상 ledger event가 없으면 두 값은 각각 `0.0`이어야 한다.
## Verification
- 성공 조건: ledger 집계가 있는 portfolio는 두 필드에 해당 집계값을 반환하고, ledger 집계가 없는 portfolio는 두 필드를 `0.0`으로 반환한다.
- 실패·경계 조건: 빈 목록은 `portfolios: []`, `total: 0`을 반환하며 신규 필드 추가 때문에 기존 응답 필드가 제거되거나 값의 의미가 바뀌지 않는다.
- 검사 방법: `tests/test_v1_portfolios_list.py`의 목록 집계값 결합·기본값·빈 목록·기존 필드 회귀 테스트와 OpenAPI schema의 `PortfolioItem`을 검사한다.
- 성공 조건: broker-only와 manual/broker mixed-source portfolio가 source와 무관한 side별 합계로 두 필드를 반환하고, ledger 집계가 없는 portfolio는 두 필드를 `0.0`으로 반환한다.
- 실패·경계 조건: `PROFIT`이 원금에 포함되거나 `buffer_out` 등 다른 side가 `INVEST`로 재해석되거나, 기존 응답 필드·token/filter 동작이 변경되면 실패한다.
- 검사 방법: `tests/test_portfolio_ledger_aggregates.py`, `tests/test_v1_portfolios_list.py`, `tests/test_v1_portfolios_manual_ledger.py`와 disposable PostgreSQL integration을 실행한다.
- 현재 구현 평가: focused `39 passed`, actual PostgreSQL semantic integration `1 passed`, clean full regression `661 passed, 16 skipped`이며 독립 재QC가 PASS했다.
+6 -5
View File
@@ -3,14 +3,15 @@ id: "ac-0002"
status: active
---
# REVERSAL-aware manual ledger 산식과 목록·단건 일치
# Source-independent REVERSAL 산식과 목록·단건 일치
## Criterion
`net_contributed_capital`은 `REVERSAL`되지 않은 manual `INVEST` 합계에서 `REVERSAL`되지 않은 manual `WITHDRAW` 합계를 뺀 값이어야 하고, `cumulative_profit_withdrawn`은 `REVERSAL`되지 않은 manual `PROFIT` 합계여야 한다. `REVERSAL`이 참조한 `INVEST`, `WITHDRAW`, `PROFIT` 원거래는 모든 return aggregate에서 제외하고 `REVERSAL` 자체는 금액으로 합산하지 않으며, 목록과 단건 `returns/summary`는 같은 유효 원장 판정과 네 집계값 의미를 사용해야 한다.
`total_invested`는 모든 source의 유효한 `INVEST` gross 합계, `total_withdrawn`은 유효한 `WITHDRAW` 합계, `net_contributed_capital`은 두 값의 차, `cumulative_profit_withdrawn`은 원금에서 제외한 유효한 `PROFIT` 합계여야 한다. 동일 portfolio에서 matching `REVERSAL.target_ledger_entry_id`로 참조된 original `INVEST`, `WITHDRAW`, `PROFIT`은 original과 reversal의 source와 무관하게 모든 return aggregate에서 제외하고 `REVERSAL` 자체는 금액으로 합산하지 않는다. 목록과 단건 `returns/summary`는 같은 repository aggregate와 네 집계값 의미를 사용해야 한다.
## Verification
- 성공 조건: 미취소 `INVEST`, `WITHDRAW`, `PROFIT` 조합에서 `PROFIT`은 `WITHDRAW`와 분리되고 목록과 단건 summary의 `total_invested`, `total_withdrawn`, `net_contributed_capital`, `cumulative_profit_withdrawn`이 일치한다.
- 실패·경계 조건: `INVEST`, `WITHDRAW`, `PROFIT`을 각각 reversal하면 해당 원거래 효과가 제거되며 reversal row 자체가 합계에 더해지지 않는다.
- 검사 방법: `tests/test_portfolio_ledger_aggregates.py`의 shared batch semantics와 `tests/test_v1_portfolios_manual_ledger.py`의 profit 분리 및 세 action reversal 회귀를 실행한다.
- 성공 조건: broker-only와 mixed-source `INVEST`·`WITHDRAW`·`PROFIT` 조합에서 gross·withdrawn·net·profit 값이 승인 산식과 일치하고 목록과 단건 summary가 일치한다.
- 실패·경계 조건: manual reversal→broker original 또는 broker reversal→manual original에서 original 효과가 남거나, `PROFIT`이 원금에 섞이거나 reversal row 자체가 합산되면 실패한다.
- 검사 방법: SQL contract unit test와 실제 PostgreSQL 16 integration에서 production repository SQL, 양방향 cross-source reversal, `buffer_out` 제외를 검사한다.
- 현재 구현 평가: source predicate가 original과 reversal 양쪽에서 제거됐고 actual PostgreSQL semantic integration 및 독립 재QC가 PASS했다.
+6 -5
View File
@@ -3,14 +3,15 @@ id: "ac-0003"
status: active
---
# 다중 포트폴리오 grouped aggregate와 N+1 방지
# 다중 포트폴리오 source-independent grouped aggregate와 N+1 방지
## Criterion
목록용 repository 계약은 반환 대상인 여러 portfolio ID의 유효 manual ledger 집계를 하나의 grouped aggregate query로 계산해 portfolio ID별 결과를 반환해야 하며, 목록 크기에 비례하는 portfolio별 ledger query를 수행해서는 안 된다. 입력 ID 목록이 비어 있으면 schema 확인이나 DB 연결·query 없이 빈 mapping을 반환해야 한다.
목록용 repository 계약은 반환 대상인 여러 portfolio ID의 source-independent valid ledger aggregate를 하나의 grouped query로 계산해 portfolio ID별 결과를 반환해야 하며, 목록 크기에 비례하는 portfolio별 ledger query를 수행해서는 안 된다. 입력 ID 목록이 비어 있으면 schema 확인이나 DB 연결·query 없이 빈 mapping을 반환해야 한다.
## Verification
- 성공 조건: 둘 이상의 portfolio ID를 한 번에 전달했을 때 단일 query가 실행되고 각 ID의 합계가 독립된 mapping으로 반환된다.
- 실패·경계 조건: 빈 ID 입력은 `{}`를 반환하고 DB connection 및 schema/query 호출이 발생하지 않는다.
- 검사 방법: `tests/test_portfolio_ledger_aggregates.py`에서 `GROUP BY ledger.portfolio_id`, portfolio ID 배열 parameter, 단일 `execute`, 빈 입력 no-call을 검사하고 `tests/test_v1_portfolios_list.py`에서 route가 summary batch를 한 번 요청하는지 확인한다.
- 성공 조건: 둘 이상의 portfolio ID를 한 번에 전달했을 때 단일 query가 실행되고 broker/manual source가 섞여도 각 ID의 side별 합계가 독립된 mapping으로 반환된다.
- 실패·경계 조건: source predicate가 다시 도입되거나 portfolio별 N+1 query가 발생하거나, 빈 ID 입력에서 DB 작업이 발생하거나, portfolio ID 사이 금액이 섞이면 실패한다.
- 검사 방법: `tests/test_portfolio_ledger_aggregates.py`에서 `GROUP BY ledger.portfolio_id`, ID 배열 parameter, 단일 execute와 빈 입력 no-call을 검사하고 list route의 summary batch 요청을 검증한다.
- 현재 구현 평가: grouped query 구조와 empty-input fast path를 유지한 채 source predicate만 제거했으며 focused·full regression과 독립 재QC가 PASS했다.
+6 -5
View File
@@ -3,14 +3,15 @@ id: "ac-0005"
status: active
---
# additive API·문서 호환성과 reversal 회귀 보존
# API·문서 호환성과 query-only source-independent 교정
## Criterion
ledger 투자금 집계 확장은 기존 `GET /v1/portfolios` 및 단건 returns summary 계약에 대한 additive 변경이어야 한다. OpenAPI와 repository-owned interface/runtime 문서는 `net_contributed_capital`을 순투자현금, `cumulative_profit_withdrawn`을 누적 수익 인출액으로 정의하고 `portfolio-hub`가 ledger 권위 값을 제공함을 명시해야 하며, 기존 이중 reversal 거부 동작을 유지해야 한다.
Ledger 투자금 집계 교정은 기존 `GET /v1/portfolios` 및 단건 returns summary의 field 이름과 응답 구조를 유지해야 한다. OpenAPI schema와 repository-owned interface/data-model/runtime 문서는 source-independent valid `INVEST`·`WITHDRAW`·`PROFIT`, gross `total_invested`, `INVEST-WITHDRAW` net, 별도 `PROFIT`, source-independent matching reversal과 non-capital side 제외를 일관되게 설명해야 한다. 교정은 existing ledger row mutation/backfill 또는 `buffer_out` 재분류 없이 query semantic에 한정돼야 한다.
## Verification
- 성공 조건: 기존 목록 필드와 token/filter 동작이 보존되고 `docs/interface-contract.md`, `docs/runtime-flows.md`, OpenAPI schema가 두 필드의 산식·REVERSAL·권위 경계를 일관되게 설명한다.
- 실패·경계 조건: `PROFIT`을 `WITHDRAW` 또는 전체 실현손익으로 설명하지 않고, 동일 원거래를 두 번 reversal하려는 기존 요청은 계속 거부된다.
- 검사 방법: 목록·manual ledger 회귀 테스트와 문서·OpenAPI 검사를 실행하고 focused suite 및 전체 프로젝트 suite를 사용해 기존 동작 회귀 여부를 판정한다.
- 성공 조건: `app/schemas/v1.py`, `docs/interface-contract.md`, `docs/data-model.md`, `docs/runtime-flows.md`가 같은 산식·reversal·권위 경계를 설명하고 기존 field·auth·filter 계약을 보존한다.
- 실패·경계 조건: stale manual-only 문구가 남거나 `PROFIT`을 원금/전체 실현손익으로 설명하거나, API rename, ledger mutation/backfill, `buffer_out=INVEST` 재분류 또는 권한 우회가 발생하면 실패한다.
- 검사 방법: docs contract regression, API/list/manual ledger focused suite, 변경 diff와 migration/ledger write 부재를 검사한다.
- 현재 구현 평가: 최초 독립 QC가 발견한 stale interface contract를 보완하고 manual-only 문구 부재 회귀 테스트를 추가했으며 재QC는 PASS(Blocker 0 / Major 0 / Minor 0)했다.
+15
View File
@@ -0,0 +1,15 @@
---
id: "ac-0032"
status: active
---
# Source-neutral portfolio ledger read
## Criterion
Portfolio owner는 `GET /v1/portfolios/{portfolio_id}/ledger`로 source 미지정 시 manual·broker를 포함한 모든 자기 portfolio 원장을 `recorded_at DESC, ledger_entry_id DESC` 순서로 조회할 수 있어야 한다.
## Verification
- mixed manual/broker 행이 최신순으로 반환되고 source 미지정이 모든 source를 포함한다.
- 구현 평가: `tests/test_v1_portfolios_manual_ledger.py` 및 전체 회귀 `664 passed, 16 skipped`에서 확인됐다.
+15
View File
@@ -0,0 +1,15 @@
---
id: "ac-0033"
status: active
---
# Stable keyset pagination and fail-closed cursor
## Criterion
Unified ledger 목록은 `(recorded_at, ledger_entry_id)` keyset cursor로 동시각 행의 중복·누락 없이 page를 이어야 하며 cursor는 portfolio/source에 결속되고 malformed·tampered·scope mismatch는 HTTP 422이어야 한다.
## Verification
- cursor 경계, 변조 및 다른 source cursor 재사용은 각각 정확한 next page 또는 422을 반환한다.
- 구현 평가: focused cursor 회귀와 전체 회귀 `664 passed, 16 skipped`에서 확인됐다.
+15
View File
@@ -0,0 +1,15 @@
---
id: "ac-0034"
status: active
---
# Canonical cross-source ledger representation
## Criterion
Unified ledger item은 canonical 대문자 `action`을 제공하고 internal `side`를 노출하지 않으며 source별로 없는 사실은 명시적 nullable schema 값으로 반환해야 한다.
## Verification
- manual/broker mixed 응답이 action casing과 nullable field 계약을 유지하고 OpenAPI가 해당 schema를 노출한다.
- 구현 평가: focused schema/API regression 및 OpenAPI inspection으로 확인됐다.
+15
View File
@@ -0,0 +1,15 @@
---
id: "ac-0035"
status: active
---
# Owner-only unified read and manual compatibility
## Criterion
Unified ledger read는 user/session portfolio owner만 허용하고 타 owner와 service token을 거부하며 기존 `/ledger/manual`의 manual-only·latest-first·limit 계약을 유지해야 한다.
## Verification
- 타 owner/service token 거부, owner 허용 및 manual endpoint 하위호환 회귀를 실행한다.
- 구현 평가: focused API/auth-surface `29 passed`, 전체 회귀 `664 passed, 16 skipped`에서 확인됐다.
+15
View File
@@ -0,0 +1,15 @@
---
id: "ac-0036"
status: active
---
# Unified ledger persistence and regression evidence
## Criterion
Unified ledger query는 portfolio/source keyset ordering에 맞는 PostgreSQL index migration을 제공하고 API/OpenAPI/repository 계약 회귀로 검증되어야 한다.
## Verification
- disposable PostgreSQL fresh upgrade가 head `20260820_0030`과 `idx_portfolio_ledger_portfolio_recorded_entry_desc` 생성을 확인하고 focused/full suite가 통과한다.
- 구현 평가: fresh migration 확인 및 전체 회귀 `664 passed, 16 skipped`가 있다. 운영 규모 `EXPLAIN ANALYZE` latency 관찰은 비차단 후속이다.
+2 -2
View File
@@ -1,5 +1,5 @@
{
"schema_version": 1,
"next_line_number": 5,
"next_ac_number": 32
"next_line_number": 7,
"next_ac_number": 37
}
+227
View File
@@ -0,0 +1,227 @@
---
id: "dcy-0005"
status: confirmed
---
# Portfolio ledger source-independent return aggregates
## Problem
`GET /v1/portfolios`와 `GET /v1/portfolios/{portfolio_id}/returns/summary`의 투자금 집계가 `portfolio_ledger.source = 'manual'`인 row만 포함해 broker 등 다른 producer가 기록한 유효한 원금·수익 event를 누락했다. 이 때문에 실제 ledger provenance와 API 집계값이 달라질 수 있으며, original source 제한만 제거하고 reversal source 제한을 남기면 cross-source reversal까지 잘못 집계할 수 있었다.
이 Discovery는 요청자가 OQ-1~OQ-5를 모두 결정하고 승인한 GitHub 이슈 #28을 canonical ProofLine 문서로 포착한다. 아래 `Scope`의 승인 이슈 본문은 canonical H2 충돌을 피하기 위해 heading만 한 단계 낮추고 나머지 내용을 최대한 원문 순서대로 보존한다.
## Evidence
### 승인 원문과 provenance
- 승인 원문: https://github.com/fourblocks2020/portfolio-hub/issues/28
- 제목: `Fix investment totals: remove source='manual' filter from ledger aggregates`
- 상태: `closed`
- 최초 작성: `2026-08-18T01:53:37Z`
- 승인 본문 최종 갱신: `2026-08-19T08:39:49Z`
- 승인 본문 SHA-256: `d5e5f65500d163bd5a9c9a97f47222e96373b3b6d0e09b021f1de3a2380a6014`
- Exact 승인 본문 snapshot: `evidence/issue-28-approved-discovery.md`
- 승인 결과: `APPROVED`; OQ-1~OQ-5는 각각 A로 결정됐고 unresolved blocking Open Question은 없다.
### 구현·검증 evidence
- 구현 기준 commit: `02bfa602995b8e1c2f410c9bd5762f062666f2d6`
- 구현 commit: `5eb8c042668b1c383ac59ca9be00aa1a5dfcb37a` (`fix: aggregate investment totals across ledger sources`)
- 구현 진행 comment: https://github.com/fourblocks2020/portfolio-hub/issues/28#issuecomment-5339488232; exact snapshot `evidence/implementation-progress.md`; SHA-256 `ada9617f9fb106e890ab4b232f23cde3b457b429971b664ed9fcc34689d03c52`
- 구현 완료 comment: https://github.com/fourblocks2020/portfolio-hub/issues/28#issuecomment-5339596866; exact snapshot `evidence/implementation-completion.md`; SHA-256 `3458db05561d843a7ecebab816f6aecc6120a6a2d62a4be151efd2876f0d3007`
- Focused repository/API/docs contract tests: `39 passed`.
- Disposable PostgreSQL 16 semantic integration: `1 passed`; broker-only, mixed-source, cross-source reversal 양방향과 `buffer_out` 제외를 실제 production repository SQL로 검증했다.
- 기존 `temp/deleted_20260813/.worktrees/` artifact를 제외한 clean candidate full suite: `661 passed, 16 skipped`.
- 변경 test Ruff와 `git diff --check`: PASS.
- 최초 독립 QC의 stale manual-only interface contract Major를 보완한 뒤 독립 재QC: PASS, Blocker 0 / Major 0 / Minor 0.
### Lifecycle 경계
- 현재 local `main`에 구현·검증·독립 재QC·commit까지 완료됐다.
- Push, PR, 원격 main 반영, release, production deployment/restart, production read-only pre/post check와 production ledger mutation은 수행되지 않았다.
- 이 confirmed Discovery와 사후 approved REQ/active AC는 normative scope와 현재 구현 평가를 기록하며 terminal lifecycle closure나 미수행 외부 반영을 주장하지 않는다.
## Scope
### 승인된 이슈 본문 전체
### Discovery: 투자 원금 집계의 source-independent 의미 복원
- **상태:** `APPROVED`
- **대상 저장소:** `fourblocks2020/portfolio-hub`
- **대상 API:** `GET /v1/portfolios`, `GET /v1/portfolios/{portfolio_id}/returns/summary`
- **관련 이슈:** #23의 manual-only cash-flow 집계 가정 중 Invest-total field 의미를 이 이슈가 대체한다.
#### Problem
`get_return_summaries()`는 현재 투자 원금 집계를 manual ledger row로 제한한다.
```sql
WHERE ledger.source = 'manual'
AND ledger.side IN ('invest', 'withdraw', 'profit')
```
그 결과 `total_invested`와 `net_contributed_capital`은 portfolio ledger 전체의 Invest/net Invest가 아니라 manual source의 cash flow만 반영한다. Broker 등 다른 source가 기록한 유효한 `INVEST`가 제외되므로 사용자에게 표시되는 투자 원금 의미와 실제 집계가 불일치한다.
이 동작은 #23의 manual-cash-flow contract에서 유래했지만, Invest-total field가 모든 유효한 투자 원금을 나타내야 한다면 적합하지 않다.
#### Evidence
- Production 관찰 사례인 portfolio `353200`에는 broker-origin `invest`, `buffer_out`, `buy` ledger entry가 있고 manual `INVEST`/`WITHDRAW`가 없다.
- 현재 portfolio API는 모든 `source='broker'` row를 집계에서 제외하므로 `net_contributed_capital: 0.0`을 반환한다.
- 같은 사례에서 broker `invest` amount는 `0`, `buffer_out`은 `800,000`이다. 이 이슈의 source-filter 수정만으로 `buffer_out`을 `INVEST`로 간주하거나 zero-amount event를 교정하지는 않는다.
#### Target Semantics
승인된 목표 semantics는 다음과 같다.
```text
total_invested = sum(valid INVEST entries from every source)
total_withdrawn = sum(valid WITHDRAW entries from every source)
net_contributed_capital = total_invested - total_withdrawn
cumulative_profit_withdrawn = sum(valid PROFIT entries from every source)
```
`valid`는 원본 ledger entry가 `REVERSAL.target_ledger_entry_id`로 연결된 matching reversal에 의해 취소되지 않았음을 뜻한다.
`total_invested`는 gross value인 `Σ INVEST`로 유지한다. `WITHDRAW`는 `total_withdrawn`과 `net_contributed_capital`에 반영하고, `PROFIT`는 투자 원금 계산에서 제외하여 `cumulative_profit_withdrawn`으로만 별도 집계한다.
#### Scope
- 투자 원금 aggregate에서 `ledger.source = 'manual'` predicate를 제거하고 모든 source의 유효한 `INVEST`·`WITHDRAW`·`PROFIT`을 side별로 집계한다.
- `INVEST`, `WITHDRAW`, `PROFIT` accounting side 의미는 유지한다.
- Original entry와 동일 portfolio에서 `target_ledger_entry_id`가 일치하는 `REVERSAL`이 있으면 reversal 자체의 source와 무관하게 original entry를 제외한다.
- Portfolio list와 single-portfolio returns summary가 동일한 aggregate semantics를 사용하게 한다.
- Non-manual `broker` INVEST와 mixed manual/broker INVEST·WITHDRAW·reversal 회귀를 검증한다.
- API/interface 문서에서 `total_invested`와 `net_contributed_capital`의 manual-only 표현을 승인된 의미로 갱신한다.
- 기존 owner/admin/service visibility와 authorization 경계를 변경하지 않는다.
#### Out of Scope
- `buffer_out`, `buy` 등 non-INVEST event를 INVEST로 재분류하는 작업
- Zero-amount broker `invest`를 non-zero event로 정규화하는 ledger-event modelling
- Existing ledger row의 mutation, backfill 또는 rewrite
- Ledger producer의 event emission 변경
- Reversal model 또는 `REVERSAL.target_ledger_entry_id` 의미 변경
- Owner/admin/service visibility 및 authorization 변경
- 이 Discovery 승인만으로 배포·릴리스·production mutation을 수행하는 것
#### Risks and Constraints
- Source predicate 제거는 과거 모든 source row에 즉시 적용되는 query semantic correction이므로 기존 응답 값이 증가하거나 달라질 수 있다.
- Side label이 같다는 이유만으로 `buffer_out`·`buy` 같은 다른 accounting event를 합산하면 안 된다.
- Reversal exclusion을 source-independent 집계와 함께 유지하지 않으면 reversed principal이 중복 반영될 수 있다.
- List와 detail summary가 다른 query path를 사용한다면 동일 fixture로 semantic parity를 검증해야 한다.
- `total_invested`의 의미 변경은 API field rename 없이 기존 field contract를 교정하는 선택인지 명시해야 한다.
- Production 사례 `353200`은 source-filter defect와 separate ledger-event modelling gap을 동시에 포함하므로, source-filter 수정만으로 `800,000`이 투자 원금으로 표시된다고 보장해서는 안 된다.
#### Open Questions
##### OQ-1 — Invest-total 집계의 source 범위
- [x] **A. 모든 source의 유효한 원금 event를 포함하되 `total_invested`는 gross `Σ INVEST`로 유지한다. — 결정**
- `total_invested = Σ valid INVEST`, `total_withdrawn = Σ valid WITHDRAW`, `net_contributed_capital = Σ valid INVEST - Σ valid WITHDRAW`로 계산한다.
- `PROFIT`는 원금 계산에서 제외하고 `cumulative_profit_withdrawn = Σ valid PROFIT`으로만 별도 집계한다.
- `ledger.source = 'manual'` predicate를 제거하고 source와 무관하게 accounting side와 reversal을 적용한다.
- [ ] **B. `manual`과 `broker`만 포함하는 allowlist를 사용한다.**
- 현재 알려진 두 source만 포함하지만 새 source가 추가되면 다시 누락될 수 있다.
- [ ] **C. Manual-only 의미를 유지하고 별도 all-source field를 추가한다.**
- 기존 값은 보존하지만 API schema와 UI 소비 계약이 확장되며 본 이슈의 단순 semantic correction 범위를 넘어간다.
##### OQ-2 — `buffer_out` 및 zero-amount broker `invest` 처리
- [x] **A. 이 이슈에서는 현행 accounting side를 그대로 사용하고 별도 ledger-event modelling 이슈로 분리한다. — 결정**
- `buffer_out`을 INVEST로 추론하지 않으며 source-filter defect만 교정한다.
- Portfolio `353200`의 `800,000` 표시 문제는 별도 결정 없이는 해결됐다고 주장하지 않는다.
- [ ] **B. `buffer_out`을 이 집계에서 INVEST로 간주한다.**
- 사용자 표시 값은 달라질 수 있지만 accounting event 의미를 query layer에서 재해석한다.
- [ ] **C. Broker producer가 non-zero `INVEST`를 기록하도록 이 이슈 범위를 확장한다.**
- Producer contract, historical data, deduplication과 migration 결정이 추가로 필요하다.
##### OQ-3 — 기존 ledger data 처리 방식
- [x] **A. Query semantic만 교정하고 existing ledger row를 mutation/backfill하지 않는다. — 결정**
- 모든 기존 유효 row가 새 query에 자동 반영되며 원장 provenance를 보존한다.
- [ ] **B. Source 또는 side를 migration으로 backfill한다.**
- Historical ledger mutation 범위, 대상 식별과 rollback/audit 계약이 별도로 필요하다.
- [ ] **C. 별도 aggregate projection/table을 만들어 재계산한다.**
- 지속 materialization, replay, ordering 및 consistency ownership이 추가된다.
##### OQ-4 — API compatibility 방식
- [x] **A. 기존 field 이름을 유지하고 의미를 all-source Invest-total로 교정한다. — 결정**
- `total_invested`, `total_withdrawn`, `net_contributed_capital`, `cumulative_profit_withdrawn`의 문서를 함께 갱신한다.
- [ ] **B. 새 field를 추가하고 기존 field는 manual-only로 유지한다.**
- 하위 호환은 보존하지만 response schema와 consumer 선택이 복잡해진다.
- [ ] **C. Versioned endpoint에서만 새 의미를 제공한다.**
- 가장 강한 contract 분리를 제공하지만 새 API version과 migration 계획이 필요하다.
##### OQ-5 — 배포 후 검증 경계
- [x] **A. Repository tests에 더해 승인된 read-only production query/API pre/post check를 별도 배포 절차에 포함한다. — 결정**
- Source-independent 합계, reversal 제외와 list/detail parity를 확인하되 production row는 변경하지 않는다.
- `353200`은 source-filter 제거 여부를 확인하는 관찰 사례로만 사용하고 `buffer_out=INVEST`를 기대하지 않는다.
- [ ] **B. Repository tests만 수행하고 production 검증은 하지 않는다.**
- 구현 검증은 단순하지만 실제 source 분포와 응답 변화 확인이 남는다.
- [ ] **C. Synthetic staging data에서만 post-deploy 검증한다.**
- Production read를 피하지만 실제 historical source/reversal 조합을 완전히 대표하지 못할 수 있다.
#### Acceptance Criteria
OQ가 확정되면 아래 criterion을 선택 결과에 맞춰 canonical requirement로 전환한다.
- [ ] Investment aggregate에서 original ledger row의 source predicate가 제거되고 모든 source의 유효 row가 side별로 집계된다.
- [ ] `total_invested = Σ valid INVEST`, `total_withdrawn = Σ valid WITHDRAW`, `net_contributed_capital = total_invested - total_withdrawn`이며 `PROFIT`는 원금 계산에서 제외되고 `cumulative_profit_withdrawn`으로만 집계된다.
- [ ] 동일 portfolio에서 matching `target_ledger_entry_id`를 가진 `REVERSAL`이 존재하면 reversal source와 무관하게 original entry가 제외된다.
- [ ] `GET /v1/portfolios`와 single-portfolio returns summary가 동일한 aggregate semantics를 사용한다.
- [ ] Non-manual `broker` INVEST의 non-zero amount 회귀가 검증된다.
- [ ] Mixed manual/broker INVEST·WITHDRAW, reversal과 source-independent total이 검증된다.
- [ ] API/interface 문서가 승인된 field 의미를 정확히 설명한다.
- [ ] Existing owner/admin/service visibility 경계가 유지된다.
- [ ] Out-of-scope인 `buffer_out` 재분류나 historical ledger mutation이 암묵적으로 포함되지 않는다.
#### Approval Record
- **승인 상태:** `APPROVED`
- **승인 결정:** OQ-1-A, OQ-2-A, OQ-3-A, OQ-4-A, OQ-5-A
- **OQ-1:** 모든 source의 유효한 row를 side별로 집계한다. `total_invested`는 gross `Σ INVEST`, `net_contributed_capital`은 `Σ INVEST - Σ WITHDRAW`이며 `PROFIT`는 원금 계산에서 제외한다.
- **OQ-2:** `buffer_out` 재분류와 zero-amount broker `invest` 교정은 별도 ledger-event modelling으로 분리한다.
- **OQ-3:** Query semantic만 교정하고 existing ledger row를 mutation/backfill하지 않는다.
- **OQ-4:** 기존 field 이름을 유지하고 all-source Invest-total 의미로 문서와 contract를 교정한다.
- **OQ-5:** Repository 검증에 더해 별도 배포 절차에서 승인된 read-only production query/API pre/post check를 수행한다. Production row mutation 권한은 포함하지 않는다.
- **Reversal 적용:** all-source original 집계에서 matching reversal도 source와 무관하게 적용한다. `reversal.source = 'manual'` predicate를 보존하면 broker original reversal이 누락되므로 허용하지 않는다.
- **추가 결정 검토:** 현재 구현·data-flow 검토에서 승인을 중지할 추가 material OQ는 발견되지 않았다.
- **권한 경계:** 이 승인은 이 Discovery와 bounded implementation/verification scope를 승인한다. Push, PR, release, deployment, production mutation 또는 issue closure 권한을 자동으로 부여하지 않는다.
### AC admission 분류
- `update`: 기존 active `ac-0001`, `ac-0002`, `ac-0003`, `ac-0005`의 manual-only 의미를 source-independent 계약과 현재 검증 상태로 교정한다.
- `satisfy`: 기존 active `ac-0004`의 목록 가시성·인증·필터 격리는 의미 변경 없이 이번 구현이 충족한다.
- `create`: 없음. 동일 behavior를 새 AC로 중복하지 않는다.
- `retire`: 없음.
- Release evidence와 terminal closure: 없음.
- Housekeeping: GitHub comment exact snapshot 두 건을 Line evidence로 보존한다.
## Out of Scope
- Existing ledger row mutation 또는 historical migration/backfill
- `buffer_out`을 `INVEST`로 재분류하거나 zero-amount broker event를 교정하는 ledger-event modelling
- 별도 aggregate projection/table 및 성능 근거 없는 schema 확장
- API field rename 또는 breaking response change
- Push, PR, 원격 main 반영, release, production deployment/restart
- Production write/mutation과 credential 기록
- 배포 절차의 승인된 read-only production pre/post check를 이미 완료했다고 주장하는 것
## Risks and Unknowns
### Risks
- 모든 source를 집계하므로 producer가 accounting side를 잘못 기록하면 그 modelling 오류가 aggregate에 노출될 수 있다. 이번 범위는 side 재분류가 아니라 유효한 기존 side의 source filter 교정이다.
- Cross-source reversal을 놓치면 original row가 잘못 남으므로 original과 reversal 양쪽의 source 제한을 제거하고 실제 PostgreSQL 테스트로 검증했다.
- 배포 전 실제 production data parity는 아직 미확인이다. 승인된 read-only pre/post check는 별도 배포 절차에서 수행해야 하며 mutation 권한을 포함하지 않는다.
### Open Questions
없음. OQ-1~OQ-5는 승인 이슈 본문에서 모두 결정됐으며 추가 blocking decision은 발견되지 않았다.
@@ -0,0 +1,50 @@
## 구현 완료
### 구현 내용
- Portfolio return aggregate에서 original ledger의 `source='manual'` 제한을 제거해 모든 source의 유효한 `INVEST`·`WITHDRAW`·`PROFIT`을 side별로 집계한다.
- `total_invested`는 gross `Σ INVEST`, `total_withdrawn`은 `Σ WITHDRAW`, `net_contributed_capital`은 두 값의 차로 유지한다.
- `PROFIT`는 투자 원금 계산에서 제외하고 `cumulative_profit_withdrawn`으로만 별도 집계한다.
- Matching `REVERSAL.target_ledger_entry_id`는 original과 reversal의 source와 무관하게 original entry를 제외한다.
- `buffer_out` 등 non-capital side는 `INVEST`로 재해석하거나 집계하지 않는다.
- Existing ledger row mutation/backfill 없이 query semantic만 교정했다.
- List와 단건 returns summary가 동일한 repository aggregate를 사용하도록 유지했다.
- API schema, data model, runtime flow와 외부 interface contract를 all-source 의미로 동기화했다.
### 실제 검증 결과
- Focused repository/API/docs contract tests: **39 passed**
- Disposable PostgreSQL 16 semantic integration: **1 passed**
- Non-zero broker-only INVEST
- Manual/broker mixed INVEST·WITHDRAW·PROFIT
- Manual reversal → broker original
- Broker reversal → manual original
- `buffer_out` 집계 제외
- Clean candidate full suite: **661 passed, 16 skipped**
- 변경 테스트 범위 Ruff: **PASS**
- `git diff --check`: **PASS**
- 최초 독립 QC: Blocker 0 / Major 1 / Minor 0 — stale manual-only interface contract 발견
- 보완 후 독립 재QC: **PASS — Blocker 0 / Major 0 / Minor 0**
원본 checkout의 full suite에서는 기존 `temp/deleted_20260813/.worktrees/` 복제본을 grep하는 auth-surface tests 3건만 실패했다. 해당 pre-existing artifact를 수정·삭제하지 않고 제외한 clean candidate copy에서 전체 회귀가 통과했다.
### 비차단 후속 권고
- 배포 절차에서 승인된 read-only production pre/post check로 실제 portfolio의 source-independent 합계와 list/detail parity를 확인한다.
- Portfolio `353200`은 source-filter 교정 관찰에만 사용하며 `buffer_out=INVEST` 또는 zero-amount broker `invest` 교정 완료를 주장하지 않는다.
- `buffer_out` 재분류나 producer의 ledger-event modelling 변경이 필요하면 별도 Discovery로 결정한다.
### 완료 및 외부 반영 경계
- 로컬 `main` 구현·검증·독립 재QC·커밋 완료:
- `5eb8c042668b1c383ac59ca9be00aa1a5dfcb37a`
- `fix: aggregate investment totals across ledger sources`
- Tracked worktree/index: clean
- 원격 push: **수행하지 않음**
- PR: **생성하지 않음**
- Release: **수행하지 않음**
- Production deployment/restart: **수행하지 않음**
- Production read-only pre/post check: **배포 절차에서 수행 예정이며 현재 미수행**
- Production ledger mutation: **수행하지 않음**
따라서 이 완료는 로컬 제품 구현·검증·커밋 완료를 의미하며 원격 통합, release 또는 production deployment 완료를 의미하지 않는다.
@@ -0,0 +1,46 @@
## 구현 진행 상황
- **대상:** Fix investment totals: remove source='manual' filter from ledger aggregates
- **브랜치:** `main`
- **기준:** `main` (`02bfa602995b8e1c2f410c9bd5762f062666f2d6`)
- **상태:** 로컬 `main` 구현·검증·독립 재QC·커밋 완료 — 미푸시, 미배포
- **최종 업데이트:** 2026-08-19 17:34:13 KST
### 구현 계획 및 작업 목록
- [x] 투자 원금 집계를 모든 source로 확장
- `total_invested`는 유효한 모든 `INVEST`의 gross 합계로 유지하고, `net_contributed_capital`은 `INVEST - WITHDRAW`로 계산한다.
- [x] 수익 인출을 투자 원금과 분리
- `PROFIT`는 원금 계산에서 제외하고 `cumulative_profit_withdrawn`으로만 집계한다.
- [x] Source와 무관한 reversal 제외 보장
- 동일 portfolio의 matching `REVERSAL.target_ledger_entry_id`가 있으면 original row와 reversal의 source와 관계없이 원본을 제외한다.
- [x] 기존 ledger data와 accounting side 경계 보존
- Existing ledger row를 mutation/backfill하지 않고 query semantic만 교정하며 `buffer_out` 등 다른 side를 `INVEST`로 재해석하지 않는다.
- [x] List와 단건 returns summary의 동일한 의미 유지
- `GET /v1/portfolios`와 `GET /v1/portfolios/{portfolio_id}/returns/summary`가 같은 repository aggregate를 사용함을 검증한다.
- [x] Non-manual 및 mixed-source 회귀 테스트 추가
- Non-zero broker `INVEST`, manual/broker 혼합 `INVEST`·`WITHDRAW`·`PROFIT`, source-independent reversal을 검증한다.
- [x] API 및 interface 문서 갱신
- `total_invested`, `total_withdrawn`, `net_contributed_capital`, `cumulative_profit_withdrawn`의 all-source 의미와 `PROFIT` 분리 경계를 명시한다.
- [x] 전체 회귀와 변경 범위 정적 검증
- Focused tests, full repository tests, 변경 범위 lint와 diff 검사를 실행한다.
- [x] 독립 QC 및 완료 경계 기록
- Blocker/Major/Minor를 독립 검토하고 local 구현·검증과 push·release·deployment를 구분해 기록한다.
### 진행 기록
- 2026-08-19 17:23:19 KST — Issue #28의 승인된 OQ-1-A~OQ-5-A와 구현 경계를 확인했다.
- 2026-08-19 17:23:19 KST — 별도 worktree 없이 clean local `main`의 기준 SHA를 고정하고 구현·검증을 시작했다.
- 2026-08-19 17:25:55 KST — Original과 reversal의 `source='manual'` predicate를 제거하고 side별 gross INVEST, WITHDRAW, PROFIT 분리 계산을 구현했다.
- 2026-08-19 17:25:55 KST — API schema와 data-model/runtime-flow 문서를 all-source semantics, PROFIT 분리, source-independent reversal 경계로 갱신했다.
- 2026-08-19 17:25:55 KST — Focused API/repository tests `38 passed`; disposable PostgreSQL 16에서 broker-only, mixed source, 양방향 cross-source reversal, `buffer_out` 제외 semantic test `1 passed`.
- 2026-08-19 17:25:55 KST — 전체 파일 Ruff에는 candidate 이전부터 존재한 오류가 있어 변경 범위와 구분해 기록했고, 신규 파일 lint 및 diff 검사는 PASS했다.
- 2026-08-19 17:28:28 KST — 원본 checkout full suite는 기존 `temp/deleted_20260813/.worktrees/` 복제본을 grep하는 auth surface tests 3건만 실패하고 `657 passed, 16 skipped`였다. 해당 pre-existing artifact를 제외한 clean candidate copy에서 full suite `660 passed, 16 skipped`로 PASS했다.
- 2026-08-19 17:28:28 KST — 구현 범위의 독립 read-only QC를 시작했다. QC 결과 반영 전에는 최종 완료로 표시하지 않는다.
- 2026-08-19 17:32:35 KST — 최초 독립 QC는 Blocker 0 / Major 1 / Minor 0으로, `docs/interface-contract.md`의 manual-only 외부 계약이 구현과 충돌한다고 판정했다.
- 2026-08-19 17:32:35 KST — 외부 interface contract의 list/summary 의미, source-independent reversal, non-capital side 제외와 change log를 갱신하고 문서 계약 회귀 테스트를 추가했다.
- 2026-08-19 17:32:35 KST — 보완 후 focused `39 passed`, test-file Ruff·diff check PASS, clean candidate full suite `661 passed, 16 skipped`로 재검증하고 독립 재QC를 시작했다.
- 2026-08-19 17:34:13 KST — 독립 재QC가 PASS(Blocker 0 / Major 0 / Minor 0)했다.
- 2026-08-19 17:34:13 KST — 로컬 `main`에 `5eb8c042668b1c383ac59ca9be00aa1a5dfcb37a` (`fix: aggregate investment totals across ledger sources`)로 커밋했다. Push, PR, release, deployment와 production read-only pre/post check는 수행하지 않았다.
> 각 작업이 실제로 완료되고 검증될 때마다 이 댓글의 체크박스와 진행 기록을 갱신한다. 체크 표시는 구현 완료 또는 해당 검증의 실제 통과를 의미하며, 배포·릴리스 완료를 의미하지 않는다.
@@ -0,0 +1,147 @@
# Discovery: 투자 원금 집계의 source-independent 의미 복원
- **상태:** `APPROVED`
- **대상 저장소:** `fourblocks2020/portfolio-hub`
- **대상 API:** `GET /v1/portfolios`, `GET /v1/portfolios/{portfolio_id}/returns/summary`
- **관련 이슈:** #23의 manual-only cash-flow 집계 가정 중 Invest-total field 의미를 이 이슈가 대체한다.
## Problem
`get_return_summaries()`는 현재 투자 원금 집계를 manual ledger row로 제한한다.
```sql
WHERE ledger.source = 'manual'
AND ledger.side IN ('invest', 'withdraw', 'profit')
```
그 결과 `total_invested`와 `net_contributed_capital`은 portfolio ledger 전체의 Invest/net Invest가 아니라 manual source의 cash flow만 반영한다. Broker 등 다른 source가 기록한 유효한 `INVEST`가 제외되므로 사용자에게 표시되는 투자 원금 의미와 실제 집계가 불일치한다.
이 동작은 #23의 manual-cash-flow contract에서 유래했지만, Invest-total field가 모든 유효한 투자 원금을 나타내야 한다면 적합하지 않다.
## Evidence
- Production 관찰 사례인 portfolio `353200`에는 broker-origin `invest`, `buffer_out`, `buy` ledger entry가 있고 manual `INVEST`/`WITHDRAW`가 없다.
- 현재 portfolio API는 모든 `source='broker'` row를 집계에서 제외하므로 `net_contributed_capital: 0.0`을 반환한다.
- 같은 사례에서 broker `invest` amount는 `0`, `buffer_out`은 `800,000`이다. 이 이슈의 source-filter 수정만으로 `buffer_out`을 `INVEST`로 간주하거나 zero-amount event를 교정하지는 않는다.
## Target Semantics
승인된 목표 semantics는 다음과 같다.
```text
total_invested = sum(valid INVEST entries from every source)
total_withdrawn = sum(valid WITHDRAW entries from every source)
net_contributed_capital = total_invested - total_withdrawn
cumulative_profit_withdrawn = sum(valid PROFIT entries from every source)
```
`valid`는 원본 ledger entry가 `REVERSAL.target_ledger_entry_id`로 연결된 matching reversal에 의해 취소되지 않았음을 뜻한다.
`total_invested`는 gross value인 `Σ INVEST`로 유지한다. `WITHDRAW`는 `total_withdrawn`과 `net_contributed_capital`에 반영하고, `PROFIT`는 투자 원금 계산에서 제외하여 `cumulative_profit_withdrawn`으로만 별도 집계한다.
## Scope
- 투자 원금 aggregate에서 `ledger.source = 'manual'` predicate를 제거하고 모든 source의 유효한 `INVEST`·`WITHDRAW`·`PROFIT`을 side별로 집계한다.
- `INVEST`, `WITHDRAW`, `PROFIT` accounting side 의미는 유지한다.
- Original entry와 동일 portfolio에서 `target_ledger_entry_id`가 일치하는 `REVERSAL`이 있으면 reversal 자체의 source와 무관하게 original entry를 제외한다.
- Portfolio list와 single-portfolio returns summary가 동일한 aggregate semantics를 사용하게 한다.
- Non-manual `broker` INVEST와 mixed manual/broker INVEST·WITHDRAW·reversal 회귀를 검증한다.
- API/interface 문서에서 `total_invested`와 `net_contributed_capital`의 manual-only 표현을 승인된 의미로 갱신한다.
- 기존 owner/admin/service visibility와 authorization 경계를 변경하지 않는다.
## Out of Scope
- `buffer_out`, `buy` 등 non-INVEST event를 INVEST로 재분류하는 작업
- Zero-amount broker `invest`를 non-zero event로 정규화하는 ledger-event modelling
- Existing ledger row의 mutation, backfill 또는 rewrite
- Ledger producer의 event emission 변경
- Reversal model 또는 `REVERSAL.target_ledger_entry_id` 의미 변경
- Owner/admin/service visibility 및 authorization 변경
- 이 Discovery 승인만으로 배포·릴리스·production mutation을 수행하는 것
## Risks and Constraints
- Source predicate 제거는 과거 모든 source row에 즉시 적용되는 query semantic correction이므로 기존 응답 값이 증가하거나 달라질 수 있다.
- Side label이 같다는 이유만으로 `buffer_out`·`buy` 같은 다른 accounting event를 합산하면 안 된다.
- Reversal exclusion을 source-independent 집계와 함께 유지하지 않으면 reversed principal이 중복 반영될 수 있다.
- List와 detail summary가 다른 query path를 사용한다면 동일 fixture로 semantic parity를 검증해야 한다.
- `total_invested`의 의미 변경은 API field rename 없이 기존 field contract를 교정하는 선택인지 명시해야 한다.
- Production 사례 `353200`은 source-filter defect와 separate ledger-event modelling gap을 동시에 포함하므로, source-filter 수정만으로 `800,000`이 투자 원금으로 표시된다고 보장해서는 안 된다.
## Open Questions
### OQ-1 — Invest-total 집계의 source 범위
- [x] **A. 모든 source의 유효한 원금 event를 포함하되 `total_invested`는 gross `Σ INVEST`로 유지한다. — 결정**
- `total_invested = Σ valid INVEST`, `total_withdrawn = Σ valid WITHDRAW`, `net_contributed_capital = Σ valid INVEST - Σ valid WITHDRAW`로 계산한다.
- `PROFIT`는 원금 계산에서 제외하고 `cumulative_profit_withdrawn = Σ valid PROFIT`으로만 별도 집계한다.
- `ledger.source = 'manual'` predicate를 제거하고 source와 무관하게 accounting side와 reversal을 적용한다.
- [ ] **B. `manual`과 `broker`만 포함하는 allowlist를 사용한다.**
- 현재 알려진 두 source만 포함하지만 새 source가 추가되면 다시 누락될 수 있다.
- [ ] **C. Manual-only 의미를 유지하고 별도 all-source field를 추가한다.**
- 기존 값은 보존하지만 API schema와 UI 소비 계약이 확장되며 본 이슈의 단순 semantic correction 범위를 넘어간다.
### OQ-2 — `buffer_out` 및 zero-amount broker `invest` 처리
- [x] **A. 이 이슈에서는 현행 accounting side를 그대로 사용하고 별도 ledger-event modelling 이슈로 분리한다. — 결정**
- `buffer_out`을 INVEST로 추론하지 않으며 source-filter defect만 교정한다.
- Portfolio `353200`의 `800,000` 표시 문제는 별도 결정 없이는 해결됐다고 주장하지 않는다.
- [ ] **B. `buffer_out`을 이 집계에서 INVEST로 간주한다.**
- 사용자 표시 값은 달라질 수 있지만 accounting event 의미를 query layer에서 재해석한다.
- [ ] **C. Broker producer가 non-zero `INVEST`를 기록하도록 이 이슈 범위를 확장한다.**
- Producer contract, historical data, deduplication과 migration 결정이 추가로 필요하다.
### OQ-3 — 기존 ledger data 처리 방식
- [x] **A. Query semantic만 교정하고 existing ledger row를 mutation/backfill하지 않는다. — 결정**
- 모든 기존 유효 row가 새 query에 자동 반영되며 원장 provenance를 보존한다.
- [ ] **B. Source 또는 side를 migration으로 backfill한다.**
- Historical ledger mutation 범위, 대상 식별과 rollback/audit 계약이 별도로 필요하다.
- [ ] **C. 별도 aggregate projection/table을 만들어 재계산한다.**
- 지속 materialization, replay, ordering 및 consistency ownership이 추가된다.
### OQ-4 — API compatibility 방식
- [x] **A. 기존 field 이름을 유지하고 의미를 all-source Invest-total로 교정한다. — 결정**
- `total_invested`, `total_withdrawn`, `net_contributed_capital`, `cumulative_profit_withdrawn`의 문서를 함께 갱신한다.
- [ ] **B. 새 field를 추가하고 기존 field는 manual-only로 유지한다.**
- 하위 호환은 보존하지만 response schema와 consumer 선택이 복잡해진다.
- [ ] **C. Versioned endpoint에서만 새 의미를 제공한다.**
- 가장 강한 contract 분리를 제공하지만 새 API version과 migration 계획이 필요하다.
### OQ-5 — 배포 후 검증 경계
- [x] **A. Repository tests에 더해 승인된 read-only production query/API pre/post check를 별도 배포 절차에 포함한다. — 결정**
- Source-independent 합계, reversal 제외와 list/detail parity를 확인하되 production row는 변경하지 않는다.
- `353200`은 source-filter 제거 여부를 확인하는 관찰 사례로만 사용하고 `buffer_out=INVEST`를 기대하지 않는다.
- [ ] **B. Repository tests만 수행하고 production 검증은 하지 않는다.**
- 구현 검증은 단순하지만 실제 source 분포와 응답 변화 확인이 남는다.
- [ ] **C. Synthetic staging data에서만 post-deploy 검증한다.**
- Production read를 피하지만 실제 historical source/reversal 조합을 완전히 대표하지 못할 수 있다.
## Acceptance Criteria
OQ가 확정되면 아래 criterion을 선택 결과에 맞춰 canonical requirement로 전환한다.
- [ ] Investment aggregate에서 original ledger row의 source predicate가 제거되고 모든 source의 유효 row가 side별로 집계된다.
- [ ] `total_invested = Σ valid INVEST`, `total_withdrawn = Σ valid WITHDRAW`, `net_contributed_capital = total_invested - total_withdrawn`이며 `PROFIT`는 원금 계산에서 제외되고 `cumulative_profit_withdrawn`으로만 집계된다.
- [ ] 동일 portfolio에서 matching `target_ledger_entry_id`를 가진 `REVERSAL`이 존재하면 reversal source와 무관하게 original entry가 제외된다.
- [ ] `GET /v1/portfolios`와 single-portfolio returns summary가 동일한 aggregate semantics를 사용한다.
- [ ] Non-manual `broker` INVEST의 non-zero amount 회귀가 검증된다.
- [ ] Mixed manual/broker INVEST·WITHDRAW, reversal과 source-independent total이 검증된다.
- [ ] API/interface 문서가 승인된 field 의미를 정확히 설명한다.
- [ ] Existing owner/admin/service visibility 경계가 유지된다.
- [ ] Out-of-scope인 `buffer_out` 재분류나 historical ledger mutation이 암묵적으로 포함되지 않는다.
## Approval Record
- **승인 상태:** `APPROVED`
- **승인 결정:** OQ-1-A, OQ-2-A, OQ-3-A, OQ-4-A, OQ-5-A
- **OQ-1:** 모든 source의 유효한 row를 side별로 집계한다. `total_invested`는 gross `Σ INVEST`, `net_contributed_capital`은 `Σ INVEST - Σ WITHDRAW`이며 `PROFIT`는 원금 계산에서 제외한다.
- **OQ-2:** `buffer_out` 재분류와 zero-amount broker `invest` 교정은 별도 ledger-event modelling으로 분리한다.
- **OQ-3:** Query semantic만 교정하고 existing ledger row를 mutation/backfill하지 않는다.
- **OQ-4:** 기존 field 이름을 유지하고 all-source Invest-total 의미로 문서와 contract를 교정한다.
- **OQ-5:** Repository 검증에 더해 별도 배포 절차에서 승인된 read-only production query/API pre/post check를 수행한다. Production row mutation 권한은 포함하지 않는다.
- **Reversal 적용:** all-source original 집계에서 matching reversal도 source와 무관하게 적용한다. `reversal.source = 'manual'` predicate를 보존하면 broker original reversal이 누락되므로 허용하지 않는다.
- **추가 결정 검토:** 현재 구현·data-flow 검토에서 승인을 중지할 추가 material OQ는 발견되지 않았다.
- **권한 경계:** 이 승인은 이 Discovery와 bounded implementation/verification scope를 승인한다. Push, PR, release, deployment, production mutation 또는 issue closure 권한을 자동으로 부여하지 않는다.
+3
View File
@@ -0,0 +1,3 @@
---
id: "line-0005"
---
+56
View File
@@ -0,0 +1,56 @@
---
id: "req-0005"
status: approved
discovery: "dcy-0005"
criteria:
create: []
update:
- "ac-0001"
- "ac-0002"
- "ac-0003"
- "ac-0005"
retire: []
satisfy:
- "ac-0004"
---
# Portfolio ledger source-independent return aggregates
## Objective
Portfolio return aggregate가 producer source와 무관하게 유효한 accounting event를 반영하도록 교정하면서 gross 투자원금, 회수원금, 순투자원금과 수익 인출을 분리하고, source-independent reversal·목록/단건 일치·기존 권한 경계를 보존한다.
## Scope
- 모든 source의 유효한 `INVEST`, `WITHDRAW`, `PROFIT` row를 side별로 집계한다.
- `total_invested = Σ valid INVEST`, `total_withdrawn = Σ valid WITHDRAW`, `net_contributed_capital = total_invested - total_withdrawn`, `cumulative_profit_withdrawn = Σ valid PROFIT` 의미를 사용한다.
- `PROFIT`을 원금에서 제외하고 별도 수익 인출 합계로 유지한다.
- 동일 portfolio의 matching `REVERSAL.target_ledger_entry_id`가 존재하면 original과 reversal의 source와 무관하게 original `INVEST`·`WITHDRAW`·`PROFIT`을 제외한다.
- `buffer_out` 등 다른 side를 원금·수익 aggregate에 포함하거나 `INVEST`로 재해석하지 않는다.
- 목록은 반환 대상 ID만 하나의 grouped query로 집계하고 단건 summary는 같은 repository aggregate에 위임한다.
- Existing API field 이름과 owner/admin/service authorization 및 filter 경계를 유지한다.
- API schema, data model, runtime flow와 external interface contract를 source-independent 의미로 동기화한다.
## Constraints
- Existing ledger row mutation, migration 또는 backfill 없이 SELECT query semantic만 교정한다.
- 별도 aggregate projection/table 또는 schema migration을 추가하지 않는다.
- Credential, token, password 또는 connection string을 문서와 evidence에 기록하지 않는다.
- Production 확인은 승인된 read-only query/API pre/post check로 제한하며 별도 배포 절차가 소유한다.
### Current Implementation Assessment
- 승인 범위는 local `main` commit `5eb8c042668b1c383ac59ca9be00aa1a5dfcb37a`에 구현됐다.
- Focused repository/API/docs contract tests `39 passed`, disposable PostgreSQL 16 semantic integration `1 passed`, clean candidate full suite `661 passed, 16 skipped`, 변경 test Ruff와 `git diff --check`가 통과했다.
- 최초 독립 QC의 stale manual-only interface contract Major를 보완한 후 독립 재QC는 PASS(Blocker 0 / Major 0 / Minor 0)했다.
- exact GitHub 진행·완료 comment는 `line-0005/evidence/implementation-progress.md`와 `implementation-completion.md`에 보존한다.
- 현재 평가는 local 구현·검증·commit 완료이며 push, PR, 원격 main 반영, release, production deployment/restart와 read-only production pre/post check 완료를 주장하지 않는다.
## Non-Goals
- `buffer_out` 재분류 또는 zero-amount broker event producer 교정
- Historical ledger mutation/backfill
- API field rename 또는 breaking response change
- 별도 projection/table, migration 또는 성능 redesign
- Push, PR, 원격 main 반영, release, production deployment/restart
- Production ledger mutation 또는 배포 lifecycle closure
+41
View File
@@ -0,0 +1,41 @@
---
id: "dcy-0006"
status: confirmed
---
# [portfolio-hub] 포트폴리오 전체 원장 조회 API
## Problem
기존 `GET /v1/portfolios/{portfolio_id}/ledger/manual`는 manual source만 반환하므로, portfolio owner가 broker 체결/원장 이벤트를 포함한 전체 원장을 하나의 안정적인 read 계약으로 조회할 수 없었다. 소비자 UI는 owner가 제공한 원장 사실을 표시할 뿐 action·잔고 효과·PnL을 재계산해서는 안 된다.
## Evidence
- 승인 issue: `https://github.com/fourblocks2020/portfolio-hub/issues/29`; 전체 원문은 `evidence/issue-29-approved-discovery.md`에 body SHA-256 `833b5c8e2dd4bb6c90331bef58a0fdee48534fc1458a82b646f1581877428b66`으로 보존했다.
- 진행 comment: `evidence/implementation-progress.md`, body SHA-256 `e90881b5df47b17650169c0858e7f39c300682ab70d3a88032b6fe41d31f65c7`.
- 완료 comment: `evidence/implementation-completion.md`, body SHA-256 `ce25425fa95afad70221be88a8df88fe39b13f706bbc4f8508a0be34a8327187`.
- 구현 commit `ac742b1150832f6c1cf58744308b4f3ea6803fd4`에서 통합 endpoint, repository keyset query, `20260820_0030` index migration 및 회귀가 확인됐다.
- 실행 evidence: 전체 `pytest -q`는 `664 passed, 16 skipped`; disposable PostgreSQL fresh migration에서 head와 `idx_portfolio_ledger_portfolio_recorded_entry_desc` 생성을 확인했다.
## Scope
- owner-only `GET /v1/portfolios/{portfolio_id}/ledger`가 manual과 broker를 포함한 모든 source를 기본 최신순(`recorded_at DESC, ledger_entry_id DESC`)으로 반환한다.
- optional non-empty exact `source` filter, `(recorded_at, ledger_entry_id)` keyset pagination 및 `next_cursor` 종료 계약을 제공한다. cursor는 portfolio/source 조건에 결속되고 malformed·tampered·다른 조건 재사용은 HTTP 422다.
- 통합 item은 canonical 대문자 `action`을 노출하고 internal `side`는 노출하지 않는다. source별로 없는 사실은 0/추정값이 아닌 schema nullable 값이다.
- user/session owner만 허용한다. owner 밖 사용자와 service token은 기존 정책에 따라 거부한다.
- PostgreSQL index/migration, OpenAPI, mixed source/page/cursor/권한/manual 하위호환 회귀를 포함한다.
- AC admission: 기존 active AC는 aggregate 의미를 소유할 뿐 source-neutral unified read contract를 소유하지 않으므로 durable behavior를 `create`한다.
## Out of Scope
- ledger 생성·수정·삭제, REVERSAL 실행 또는 기존 원장 재해석.
- broker order/fill 생성, strategy signal, outbox/producer 변경.
- API/UI가 원장에 없는 경제 효과·잔고·PnL을 추론하는 동작.
- portfolio return aggregate 의미 변경(#28), trex-web UI, push/PR/merge, 배포·릴리스.
## Risks and Unknowns
### Risks
- 운영 cursor secret은 장기 랜덤 값으로 관리해야 하며 교체 시 기존 cursor의 만료·무효화 정책을 운영 문서에 명시해야 한다.
- 운영 규모 row가 있는 환경에서 source-filter query의 `EXPLAIN ANALYZE` index 사용과 latency 관찰이 필요하다.
@@ -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·배포·릴리스 승인을 포함하지 않습니다.
+3
View File
@@ -0,0 +1,3 @@
---
id: "line-0006"
---
+35
View File
@@ -0,0 +1,35 @@
---
id: "req-0006"
status: approved
discovery: "dcy-0006"
criteria:
create: ["ac-0032", "ac-0033", "ac-0034", "ac-0035", "ac-0036"]
update: []
retire: []
satisfy: []
---
# 포트폴리오 전체 원장 조회 API
## Objective
Portfolio owner가 manual·broker를 포함한 모든 원장 source를 안정적이고 page 가능한 단일 read 계약으로 조회하도록 한다.
## Scope
- `GET /v1/portfolios/{portfolio_id}/ledger`의 source-neutral 목록, optional exact `source`, latest-first keyset pagination과 signed opaque cursor.
- canonical uppercase `action`, nullable source-specific facts, owner-only authorization, OpenAPI/repository/index migration 및 회귀.
## Constraints
- 기존 `/ledger/manual` 계약을 변경하지 않는다.
- cursor는 portfolio/source와 tuple ordering에 결속하며 잘못된 cursor는 fail-closed HTTP 422다.
- source에 없는 사실을 0 또는 추정값으로 만들지 않는다.
### 현재 구현 평가
구현 commit `ac742b1150832f6c1cf58744308b4f3ea6803fd4`에서 이 REQ의 모든 AC 동작이 구현됐다. `pytest -q`는 `664 passed, 16 skipped`; focused API/release/auth-surface는 `29 passed`; disposable PostgreSQL fresh migration에서 `20260820_0030`과 index 생성을 확인했다. local main은 origin/main보다 1 commit ahead이며 push·PR·배포·릴리스는 수행하지 않았다. 이 평가는 구현 evidence이며 approved/active status는 normative authority다.
## Non-Goals
Ledger write/reversal 실행, broker/strategy/outbox producer 변경, UI 구현, aggregate #28 변경, push·PR·배포·릴리스는 포함하지 않는다.
+106 -2
View File
@@ -8,6 +8,11 @@ REQ-043: GET /v1/portfolios/{id}/holdings/cost-basis — current holding WAC cos
from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import json
import logging
import uuid
from datetime import date, datetime, timedelta, timezone
@@ -15,11 +20,15 @@ from datetime import date, datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from app.core.auth import AuthContext, Permission, require_read, require_trade_write
from app.core.config import get_settings
from app.domain.errors import DaemonInactiveError, DaemonNotFoundError
from app.domain.models import Portfolio, PortfolioComment
from app.domain.pnl import calculate_current_wac, calculate_realized_pnl
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import (
HoldingsCostBasisResponse,
LedgerHistoryItem,
LedgerHistoryResponse,
LedgerTradeRequest,
LedgerTradeResponse,
ManualLedgerHistoryResponse,
@@ -35,7 +44,6 @@ from app.schemas.v1 import (
PortfolioReturnSummaryResponse,
PortfolioTagListResponse,
PortfolioUpdateRequest,
HoldingsCostBasisResponse,
RealizedPnlItem,
RealizedPnlResponse,
TagApplyRequest,
@@ -44,7 +52,10 @@ from app.schemas.v1 import (
TradeHistoryItem,
TradeHistoryResponse,
)
from app.services.strategy_set_service import StrategyInactiveError, StrategyNotFoundError
from app.services.strategy_set_service import (
StrategyInactiveError,
StrategyNotFoundError,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/portfolios", tags=["v1"])
@@ -710,6 +721,62 @@ def _manual_error_code(message: str) -> str:
return "MANUAL_LEDGER_CONFLICT"
def _encode_ledger_cursor(*, portfolio_id: str, source: str | None, recorded_at: datetime, ledger_entry_id: int) -> str:
payload = {
"portfolio_id": portfolio_id,
"source": source,
"recorded_at": recorded_at.isoformat(),
"ledger_entry_id": ledger_entry_id,
}
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
signature = hmac.new(get_settings().ledger_cursor_secret.encode(), raw, hashlib.sha256).digest()
return base64.urlsafe_b64encode(raw + signature).decode().rstrip("=")
def _decode_ledger_cursor(cursor: str, *, portfolio_id: str, source: str | None) -> tuple[datetime, int]:
try:
encoded = cursor.encode()
raw_and_signature = base64.urlsafe_b64decode(encoded + b"=" * (-len(encoded) % 4))
if base64.urlsafe_b64encode(raw_and_signature).rstrip(b"=") != encoded:
raise ValueError
raw, signature = raw_and_signature[:-32], raw_and_signature[-32:]
expected = hmac.new(get_settings().ledger_cursor_secret.encode(), raw, hashlib.sha256).digest()
payload = json.loads(raw)
recorded_at = datetime.fromisoformat(payload["recorded_at"])
ledger_entry_id = int(payload["ledger_entry_id"])
if not hmac.compare_digest(signature, expected) or payload["portfolio_id"] != portfolio_id or payload["source"] != source:
raise ValueError
if recorded_at.tzinfo is None:
raise ValueError
return recorded_at, ledger_entry_id
except (KeyError, TypeError, ValueError, json.JSONDecodeError, UnicodeDecodeError, binascii.Error):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="invalid ledger cursor") from None
def _ledger_entry_to_history_item(entry) -> LedgerHistoryItem:
return LedgerHistoryItem(
ledger_entry_id=entry.ledger_entry_id,
portfolio_id=entry.portfolio_id,
source=entry.source or "broker",
action=(entry.action or entry.side or "").upper(),
recorded_at=entry.recorded_at,
executed_at=entry.executed_at,
order_id=entry.order_id,
execution_id=entry.execution_id,
quantity=entry.quantity,
price=entry.price,
fee_amount=entry.fee_amount,
tax_amount=entry.tax_amount,
note=entry.note,
cash_balance_after=entry.cash_balance_after,
asset_quantity_after=entry.asset_quantity_after,
profit_buffer_after=entry.profit_buffer_after,
total_value_after=entry.total_value_after,
target_ledger_entry_id=entry.target_ledger_entry_id,
confirmation_evidence=entry.confirmation_evidence,
)
@router.post(
"/{portfolio_id}/ledger/manual",
response_model=ManualLedgerResponse,
@@ -788,6 +855,43 @@ def list_manual_ledger_v1(
return ManualLedgerHistoryResponse(items=items, total=len(items))
@router.get("/{portfolio_id}/ledger", response_model=LedgerHistoryResponse)
def list_ledger_v1(
portfolio_id: str,
source: str | None = Query(default=None, min_length=1),
cursor: str | None = Query(default=None),
limit: int = Query(default=50, ge=1, le=200),
auth: AuthContext = Depends(require_read),
) -> LedgerHistoryResponse:
if auth.token_type in {"service", "client"} or auth.user_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ledger requires user/session token")
before_recorded_at = before_ledger_entry_id = None
if cursor is not None:
before_recorded_at, before_ledger_entry_id = _decode_ledger_cursor(cursor, portfolio_id=portfolio_id, source=source)
repo = _get_repo()
try:
_get_owner_portfolio_or_404(auth, portfolio_id, repo)
entries = repo.list_ledger_entries(
portfolio_id,
source=source,
before_recorded_at=before_recorded_at,
before_ledger_entry_id=before_ledger_entry_id,
limit=limit + 1,
)
finally:
_close_repo(repo)
page, extra = entries[:limit], entries[limit:]
next_cursor = None
if extra and page:
last = page[-1]
if last.recorded_at is None:
raise RuntimeError("ledger entry is missing recorded_at")
next_cursor = _encode_ledger_cursor(
portfolio_id=portfolio_id, source=source, recorded_at=last.recorded_at, ledger_entry_id=last.ledger_entry_id
)
return LedgerHistoryResponse(items=[_ledger_entry_to_history_item(entry) for entry in page], next_cursor=next_cursor)
@router.get("/{portfolio_id}/returns/summary", response_model=PortfolioReturnSummaryResponse)
def get_portfolio_return_summary_v1(
portfolio_id: str,
+2 -1
View File
@@ -18,7 +18,7 @@ if env_file.exists():
class Settings(BaseSettings):
app_name: str = "PortfolioHub"
app_version: str = "0.1.3"
app_version: str = "0.2.0"
environment: str = "development"
allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS")
bootstrap_username: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_USERNAME")
@@ -27,6 +27,7 @@ class Settings(BaseSettings):
read_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_READ_TOKENS")
trade_write_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_TRADE_WRITE_TOKENS")
admin_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_ADMIN_TOKENS")
ledger_cursor_secret: str = Field(default="development-ledger-cursor-secret", validation_alias="PORTFOLIOHUB_LEDGER_CURSOR_SECRET")
db_enabled: bool = Field(default=False, validation_alias="PORTFOLIOHUB_DB_ENABLED")
session_token_ttl_hours: int = Field(default=12, validation_alias="PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS")
client_token_default_ttl_days: int = Field(default=365, validation_alias="PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS")
@@ -0,0 +1,31 @@
"""portfolio_ledger: keyset pagination index for unified ledger reads
Revision ID: 20260820_0030
Revises: 20260814_0029
Create Date: 2026-08-20 10:50:00
"""
from __future__ import annotations
from alembic import op
from sqlalchemy import text
revision = "20260820_0030"
down_revision = "20260814_0029"
branch_labels = None
depends_on = None
_INDEX = "idx_portfolio_ledger_portfolio_recorded_entry_desc"
def upgrade() -> None:
op.get_bind().execute(
text(
f"CREATE INDEX IF NOT EXISTS {_INDEX} "
"ON portfolio_ledger (portfolio_id, recorded_at DESC, ledger_entry_id DESC)"
)
)
def downgrade() -> None:
op.get_bind().execute(text(f"DROP INDEX IF EXISTS {_INDEX}"))
+10
View File
@@ -333,6 +333,16 @@ class PortfolioRepository(Protocol):
def list_manual_ledger_entries(self, portfolio_id: str, limit: int = 50) -> list[LedgerEntry]: ...
def list_ledger_entries(
self,
portfolio_id: str,
*,
source: str | None = None,
before_recorded_at: datetime | None = None,
before_ledger_entry_id: int | None = None,
limit: int = 50,
) -> list[LedgerEntry]: ...
def get_return_summary(self, portfolio_id: str) -> dict[str, float]: ...
def get_return_summaries(self, portfolio_ids: list[str]) -> dict[str, dict[str, float]]: ...
+35 -2
View File
@@ -2210,6 +2210,41 @@ class PostgresPortfolioRepository:
)
return [self._row_to_ledger_entry(row) for row in cur.fetchall()]
def list_ledger_entries(
self,
portfolio_id: str,
*,
source: str | None = None,
before_recorded_at: datetime | None = None,
before_ledger_entry_id: int | None = None,
limit: int = 50,
) -> list[LedgerEntry]:
self.ensure_schema()
clauses = ["portfolio_id = %s"]
params: list[Any] = [portfolio_id]
if source is not None:
clauses.append("source = %s")
params.append(source)
if before_recorded_at is not None and before_ledger_entry_id is not None:
clauses.append("(recorded_at, ledger_entry_id) < (%s, %s)")
params.extend([before_recorded_at, before_ledger_entry_id])
params.append(int(limit))
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT ledger_entry_id, portfolio_id, order_id, execution_id, side, quantity, price,
fee_amount, tax_amount, executed_at, recorded_at, cash_balance_after,
asset_quantity_after, total_value_after, profit_buffer_after, strategy_set_id,
note, source, target_ledger_entry_id, confirmation_evidence, actor_user_id
FROM portfolio_ledger
WHERE """ + " AND ".join(clauses) + """
ORDER BY recorded_at DESC, ledger_entry_id DESC
LIMIT %s
""",
params,
)
return [self._row_to_ledger_entry(row) for row in cur.fetchall()]
def get_return_summaries(self, portfolio_ids: list[str]) -> dict[str, dict[str, float]]:
if not portfolio_ids:
return {}
@@ -2223,13 +2258,11 @@ class PostgresPortfolioRepository:
COALESCE(SUM(ledger.price) FILTER (WHERE ledger.side = 'profit'), 0)
FROM portfolio_ledger AS ledger
WHERE ledger.portfolio_id = ANY(%s)
AND ledger.source = 'manual'
AND ledger.side IN ('invest', 'withdraw', 'profit')
AND NOT EXISTS (
SELECT 1
FROM portfolio_ledger AS reversal
WHERE reversal.portfolio_id = ledger.portfolio_id
AND reversal.source = 'manual'
AND reversal.side = 'reversal'
AND reversal.target_ledger_entry_id = ledger.ledger_entry_id
)
+47 -6
View File
@@ -220,12 +220,53 @@ class ManualLedgerHistoryResponse(BaseModel):
total: int
class LedgerHistoryItem(BaseModel):
"""Source-neutral, owner-visible ledger fact for GET /ledger."""
ledger_entry_id: int
portfolio_id: str
source: str
action: str
recorded_at: datetime
executed_at: datetime | None = None
order_id: str | None = None
execution_id: str | None = None
quantity: float | None = None
price: float | None = None
fee_amount: float | None = None
tax_amount: float | None = None
note: str | None = None
cash_balance_after: float | None = None
asset_quantity_after: float | None = None
profit_buffer_after: float | None = None
total_value_after: float | None = None
target_ledger_entry_id: int | None = None
confirmation_evidence: str | None = None
class LedgerHistoryResponse(BaseModel):
items: list[LedgerHistoryItem]
next_cursor: str | None = None
class PortfolioReturnSummaryResponse(BaseModel):
portfolio_id: str
total_invested: float = 0.0
total_withdrawn: float = 0.0
net_contributed_capital: float = 0.0
cumulative_profit_withdrawn: float = 0.0
total_invested: float = Field(
default=0.0,
description="총투자원금: source와 무관한 유효 INVEST의 gross 합계",
)
total_withdrawn: float = Field(
default=0.0,
description="총회수원금: source와 무관한 유효 WITHDRAW 합계",
)
net_contributed_capital: float = Field(
default=0.0,
description="순투자원금: total_invested - total_withdrawn (PROFIT 제외)",
)
cumulative_profit_withdrawn: float = Field(
default=0.0,
description="누적 수익 인출액: source와 무관한 유효 PROFIT 합계 (원금 계산 제외)",
)
# ---------------------------------------------------------------------------
@@ -273,11 +314,11 @@ class PortfolioItem(BaseModel):
profit_buffer: float = 0.0
net_contributed_capital: float = Field(
default=0.0,
description="순투자현금: 유효 manual INVEST 합계 - 유효 manual WITHDRAW 합계",
description="순투자원금: source와 무관한 유효 INVEST 합계 - 유효 WITHDRAW 합계 (PROFIT 제외)",
)
cumulative_profit_withdrawn: float = Field(
default=0.0,
description="누적 수익 인출액: 유효 manual PROFIT 합계",
description="누적 수익 인출액: source와 무관한 유효 PROFIT 합계 (원금 계산 제외)",
)
tag_ids: list[str] = Field(default_factory=list)
managed_by_daemon_id: str | None = None
+17 -17
View File
@@ -1,11 +1,11 @@
# PortfolioHub v0.1.3 container contract
# PortfolioHub v0.2.0 container contract
## Publication is not deployment
Only a requester-authorized push of the strict tag `v0.1.3` can publish this revision. The workflow checks that the tag equals `[project].version`, checks out `github.sha`, runs the complete Python suite, builds one `linux/amd64` image from `uv.lock`, and tests those exact bytes before publication. It gives the same image exactly two selectors:
Only a requester-authorized push of the strict tag `v0.2.0` can publish this revision. The workflow checks that the tag equals `[project].version`, checks out `github.sha`, runs the complete Python suite, builds one `linux/amd64` image from `uv.lock`, and tests those exact bytes before publication. It gives the same image exactly two selectors:
- `ghcr.io/fourblocks2020/portfolio-hub:<source-commit-first-12>`
- `ghcr.io/fourblocks2020/portfolio-hub:v0.1.3`
- `ghcr.io/fourblocks2020/portfolio-hub:v0.2.0`
It never publishes `latest`. Both selectors must report the same registry digest; deployment and rollback must use the immutable identity:
@@ -13,9 +13,9 @@ It never publishes `latest`. Both selectors must report the same registry digest
ghcr.io/fourblocks2020/portfolio-hub@sha256:<64-lowercase-hex-digest>
```
OCI labels are fail-closed build inputs: `org.opencontainers.image.source`, the full 40-character `org.opencontainers.image.revision`, and strict `org.opencontainers.image.version=v0.1.3`. Publication does **not** deploy, replace, start, stop, or restart a production service. This repository provides no production Compose, Kubernetes, Helm, systemd, or Dockge configuration.
OCI labels are fail-closed build inputs: `org.opencontainers.image.source`, the full 40-character `org.opencontainers.image.revision`, and strict `org.opencontainers.image.version=v0.2.0`. Publication does **not** deploy, replace, start, stop, or restart a production service. This repository provides no production Compose, Kubernetes, Helm, systemd, or Dockge configuration.
The Dockerfile frontend, Python base image, uv image, and GitHub Actions are digest/commit pinned. The workflow intentionally uses `--provenance=false` to preserve the tested `docker save` archive and build-once/no-rebuild identity. Registry-attached provenance, SBOM, signing, and attestations are a nonblocking v0.1.3 supply-chain gap; they must not be added by rebuilding a different image in the publish job.
The Dockerfile frontend, Python base image, uv image, and GitHub Actions are digest/commit pinned. The workflow intentionally uses `--provenance=false` to preserve the tested `docker save` archive and build-once/no-rebuild identity. Registry-attached provenance, SBOM, signing, and attestations are a nonblocking v0.2.0 supply-chain gap; they must not be added by rebuilding a different image in the publish job.
## Image runtime
@@ -66,46 +66,46 @@ Values are read at process start; changing any value requires a container restar
| `PORTFOLIOHUB_BOOTSTRAP_PASSWORD` | optional; empty | **yes** | bootstrap password; inject temporarily and remove after first successful start |
| `PORTFOLIOHUB_ENVIRONMENT` | optional; `development` | no | reported environment metadata |
| `PORTFOLIOHUB_APP_NAME` | optional; `PortfolioHub` | no | application/OpenAPI display name |
| `PORTFOLIOHUB_APP_VERSION` | do not override for v0.1.3 | `0.1.3` | no | health/OpenAPI version metadata |
| `PORTFOLIOHUB_APP_VERSION` | do not override for v0.2.0 | `0.2.0` | no | health/OpenAPI version metadata |
`REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`, `REDIS_PASSWORD`, `LOKI_HOST`, `LOKI_PORT`, and `LOKI_ENABLED` are still parsed for source compatibility but the inspected v0.1.3 application does not open Redis or Loki connections. They are not production dependencies of this image.
`REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`, `REDIS_PASSWORD`, `LOKI_HOST`, `LOKI_PORT`, and `LOKI_ENABLED` are still parsed for source compatibility but the inspected v0.2.0 application does not open Redis or Loki connections. They are not production dependencies of this image.
### Legacy and sibling mapping
The existing database variable names remain `TIMESCALEDB_*`; v0.1.3 deliberately does not invent a `DATABASE_URL` alias. Existing `PORTFOLIOHUB_{READ,TRADE_WRITE,ADMIN}_TOKENS` Bearer-token behavior remains compatible. `trex-core`'s `TREX_PORTFOLIO_HUB_BASE_URL` and TOML `portfolio_hub_token`, and `trex-web`'s server-only `PORTFOLIO_HUB_API_BASE_URL`, are **caller-side** settings: point them to `http://portfolio-hub:8000` (or the equivalent service URL) and do not inject them into this container.
The existing database variable names remain `TIMESCALEDB_*`; v0.2.0 deliberately does not invent a `DATABASE_URL` alias. Existing `PORTFOLIOHUB_{READ,TRADE_WRITE,ADMIN}_TOKENS` Bearer-token behavior remains compatible. `trex-core`'s `TREX_PORTFOLIO_HUB_BASE_URL` and TOML `portfolio_hub_token`, and `trex-web`'s server-only `PORTFOLIO_HUB_API_BASE_URL`, are **caller-side** settings: point them to `http://portfolio-hub:8000` (or the equivalent service URL) and do not inject them into this container.
## Liveness and readiness
- **Liveness:** unauthenticated `GET /health` must return HTTP 200 with `status=ok`. It is intentionally process-only and does not query PostgreSQL. `db_enabled` only reports configuration; it is not readiness.
- **Readiness:** run `pfhub --format json health-check` with the same runtime environment. With DB enabled it performs a repository ping, reads `alembic_version` without migration, compares it with the single packaged Alembic head, and exits nonzero on connectivity, missing schema, multiple heads, or revision mismatch.
- **API identity:** `GET /openapi.json` is stable for smoke verification. Health version is `0.1.3`.
- **API identity:** `GET /openapi.json` is stable for smoke verification. Health version is `0.2.0`.
Do not route traffic from a static `/health` success alone. Readiness checks never apply, stamp, or repair migrations.
## One-shot migration and 0028 -> 0029 policy
## One-shot migration and 0029 -> 0030 policy
The installed image contains exactly **29** migration files and one Alembic head, `20260814_0029`. The application default command does not invoke Alembic. Migration is a separate pre-start operation using the exact same digest and database environment:
The installed image contains exactly **30** migration files and one Alembic head, `20260820_0030`. The application default command does not invoke Alembic. Migration is a separate pre-start operation using the exact same digest and database environment:
1. back up the database and record the current immutable image digest and `alembic_version`;
2. require the normal upgrade origin `20260715_0028`; for an older revision, review and apply the full ordered chain first;
2. require the normal upgrade origin `20260814_0029`; for an older revision, review and apply the full ordered chain first;
3. stop writes or otherwise establish an operator-approved maintenance boundary;
4. execute `pfhub migrate-db` once with restart disabled;
5. stop rollout on any nonzero result and preserve sanitized logs;
6. run `pfhub --format json health-check` and require both `database_revision` and `expected_revision` to equal `20260814_0029`;
6. run `pfhub --format json health-check` and require both `database_revision` and `expected_revision` to equal `20260820_0030`;
7. start application replicas and verify `/health`, `/openapi.json`, authenticated caller paths, and strategy data;
8. a repeated `pfhub migrate-db` is expected to be idempotent and remain at `0029`, but it is verification—not an application-start hook.
8. a repeated `pfhub migrate-db` is expected to be idempotent and remain at `0030`, but it is verification—not an application-start hook.
Migration `0029` is additive/idempotent around the strategy catalog: it preserves existing strategy IDs and portfolio references, reuses the canonical `target_value`/`{}` strategy, and backfills catalog data. Before migration, inspect duplicate or invalid strategy references and take a backup. After migration, verify strategy listing and portfolio active-strategy fields before resuming writes.
v0.1.3 introduces no schema migration: the packaged inventory remains exactly 29 migration files and the single head remains `20260814_0029`. Operators already at `0029` must not expect or manufacture a new revision for this patch release.
v0.2.0 adds migration `0030`, an idempotent index on `(portfolio_id, recorded_at DESC, ledger_entry_id DESC)` for unified-ledger keyset reads. Operators at `0029` must run the same-image one-shot migration before rollout; it advances to `20260820_0030` without mutating ledger rows.
A pre-Alembic legacy schema is detected only when all three tables `strategy_sets`, `portfolios`, and `portfolio_ledger` exist; the CLI stamps `0027` and then runs real upgrades `0028` and `0029`. This compatibility path must be used only after backup and schema review. A partially matching schema is not auto-stamped and must fail for operator investigation.
A pre-Alembic legacy schema is detected only when all three tables `strategy_sets`, `portfolios`, and `portfolio_ledger` exist; the CLI stamps `0027` and then runs real upgrades through `0030`. This compatibility path must be used only after backup and schema review. A partially matching schema is not auto-stamped and must fail for operator investigation.
Image rollback is **not** database rollback. Selecting older application bytes does not reverse schema/data changes, tokens, or side effects. Database restore, downgrade, or forward-fix requires separate review and authorization; no automatic downgrade is provided.
## API verification and known caller blockers
v0.1.3 preserves Bearer authentication and the caller route surface observed in sibling source. The release gate runs the complete portfolio-hub source test suite (including auth success/failure, strategy catalog/data, portfolio and ledger APIs), then verifies the built image's health/OpenAPI surface, exact 29-file migration inventory, isolated PostgreSQL migration twice, readiness at `0029`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it.
v0.2.0 preserves Bearer authentication and adds all-source unified-ledger reads. The release gate runs the complete portfolio-hub source test suite (including auth success/failure, strategy catalog/data, portfolio and ledger APIs), then verifies the built image's health/OpenAPI surface, exact 30-file migration inventory, isolated PostgreSQL migration twice, readiness at `0030`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it.
The strategy catalog API remains additive: strategy responses expose `nick`, admin create/update requests may set it, and portfolio responses expose the current strategy as `strategy_nick`. Existing clients may omit `nick`; supplied values are trimmed and blank-only values are rejected. trex-web v0.2.3 displays `strategy_nick`, but that dependency does not resolve or waive the separate caller blockers below.
+9
View File
@@ -343,6 +343,15 @@
- SELL: asset_quantity -> cash_balance.
- REVERSAL: append-only inverse effect of `target_ledger_entry_id`.
### Return Aggregate Semantics
- `total_invested`: source와 무관한 유효 `INVEST`의 gross 합계.
- `total_withdrawn`: source와 무관한 유효 `WITHDRAW` 합계.
- `net_contributed_capital`: `total_invested - total_withdrawn`; `PROFIT`는 원금 계산에서 제외한다.
- `cumulative_profit_withdrawn`: source와 무관한 유효 `PROFIT` 합계이며 원금과 별도로 노출한다.
- 동일 portfolio에서 matching `REVERSAL.target_ledger_entry_id`가 존재하면 original과 reversal의 source와 무관하게 original entry를 집계에서 제외한다.
- `buffer_out` 등 다른 accounting side를 `INVEST`로 재해석하지 않으며 existing ledger row를 mutation/backfill하지 않는다.
### State Rules
- Manual writes lock the portfolio row while calculating/appending/updating snapshots to avoid lost updates.
+11 -4
View File
@@ -347,10 +347,13 @@
- Response: HTTP 200 `PortfolioListResponse {"portfolios": [{"portfolio_id": "<uuid>", "asset_id": "<uuid>", "symbol": "<assets.symbol|null>", "net_contributed_capital": <float>, "cumulative_profit_withdrawn": <float>, ...}], "total": <int>}`
- Note: list items are serialized from the server-side `portfolios JOIN assets` result; `symbol` must equal the joined `assets.symbol` for each returned `asset_id`.
- Ledger aggregate fields:
- `net_contributed_capital` (**순투자현금**) = 유효 manual `INVEST` 합계 - 유효 manual `WITHDRAW` 합계.
- `cumulative_profit_withdrawn` (**누적 수익 인출액**) = 유효 manual `PROFIT` 합계. 매매상 실현손익 전체가 아니다.
- `REVERSAL.target_ledger_entry_id`로 참조된 `INVEST`, `WITHDRAW`, `PROFIT` 원거래는 유효 집계에서 제외하며 `REVERSAL` 자체는 금액 action으로 합산하지 않는다.
- 목록에 반환될 portfolio ID 집합만 한 번의 grouped aggregate로 조회한다. 해당 유효 manual ledger가 없는 항목은 두 필드 모두 `0.0`이다.
- `total_invested` (**총투자원금**, summary response) = source와 무관한 유효 `INVEST`의 gross 합계.
- `total_withdrawn` (**총회수원금**, summary response) = source와 무관한 유효 `WITHDRAW` 합계.
- `net_contributed_capital` (**순투자원금**) = `total_invested - total_withdrawn`. `PROFIT`는 원금 계산에서 제외한다.
- `cumulative_profit_withdrawn` (**누적 수익 인출액**) = source와 무관한 유효 `PROFIT` 합계. 원금 또는 매매상 실현손익 전체가 아니다.
- 동일 portfolio에서 matching `REVERSAL.target_ledger_entry_id`로 참조된 `INVEST`, `WITHDRAW`, `PROFIT` 원거래는 original과 reversal의 source와 무관하게 제외하며 `REVERSAL` 자체는 금액 action으로 합산하지 않는다.
- `buffer_out` 등 `INVEST`/`WITHDRAW`/`PROFIT` 이외 side는 원금·수익 aggregate에 포함하거나 `INVEST`로 재해석하지 않는다.
- 목록에 반환될 portfolio ID 집합만 한 번의 grouped aggregate로 조회한다. 해당 유효 ledger가 없는 항목은 aggregate field가 `0.0`이다.
- 이 필드들은 portfolio-hub가 소유한 ledger 집계값이다. 소비자는 ledger event를 재해석하지 않는다.
### Endpoint: `GET /v1/portfolios/{portfolio_id}/tags`
@@ -438,6 +441,9 @@
- Auth: owner-scoped user/session token.
- Response: `PortfolioReturnSummaryResponse` with `total_invested`, `total_withdrawn`, `net_contributed_capital`, and `cumulative_profit_withdrawn`.
- Aggregate semantics: `total_invested`는 source-independent valid `INVEST`의 gross 합계, `total_withdrawn`은 valid `WITHDRAW` 합계, `net_contributed_capital`은 두 값의 차이며 `PROFIT`는 원금에서 제외하고 `cumulative_profit_withdrawn`으로만 노출한다.
- Reversal semantics: 동일 portfolio의 matching `REVERSAL.target_ledger_entry_id`는 original과 reversal의 source와 무관하게 original을 제외한다. `buffer_out` 등 다른 side는 집계하지 않는다.
- Consistency: `GET /v1/portfolios`와 동일한 grouped repository aggregate를 portfolio ID 하나에 위임한다.
## Interface: `Tag Management API (BAT-004)`
@@ -562,6 +568,7 @@
## Change Log
- 2026-08-19 KST: Issue #28 — portfolio return aggregate를 manual-only에서 source-independent semantics로 교정; gross `total_invested`, `INVEST-WITHDRAW` net, separate `PROFIT`, cross-source reversal과 non-capital side 제외를 명시.
- 2026-07-14 KST: BAT-024 (REQ-056) — added owner-scoped manual ledger API, manual history, and return summary contracts; clarified PROFIT vs WITHDRAW return semantics and service-token separation from `/ledger/trades`.
- 2026-05-21 KST: BAT-011 (REQ-029) — ledger path migration: "POST /v1/assets/{asset_id}/ledger/trades" 명세 → "POST /v1/portfolios/{portfolio_id}/ledger/trades"로 교체; auth 섹션 + daemon authorization 갱신; 구 path HTTP 404 명시.
- 2026-05-21 KST: BAT-010 (REQ-032) — v1 portfolio surface baseline 회복: "v1 Portfolio API" 섹션 신규 추가 (REQ-025~028 CRUD + REQ-030 tag 서브 라우트 + REQ-031 comment CRUD); POST/DELETE /v1/portfolios/{id}/tags에 disabled 보호(409) 및 service/client 403 명세 추가; GET /v1/portfolios/{id}/tags 신설; ledger path "pending REQ-029" 마킹; Source Discovery/Batch 헤더 갱신.
+3 -3
View File
@@ -152,9 +152,9 @@
2. `service` token: `auth.daemon_id is None` → HTTP 403. `repo.list_portfolios(managed_by_daemon_id=auth.daemon_id, ...)`.
3. `user`/`admin` token: admin with `?user_id=<id>` → filter by that user; else filter by `auth.user_id`.
4. `?include_disabled=false` (default) → `status="enabled"` filter applied. `?include_disabled=true` → no status filter.
5. 필터링된 반환 대상 portfolio ID만 `repo.get_return_summaries(ids)`에 전달한다. repository는 한 번의 grouped query로 유효 manual ledger를 집계한다.
6. `REVERSAL.target_ledger_entry_id`로 참조된 `INVEST`/`WITHDRAW`/`PROFIT`은 제외한다. `REVERSAL` 자체는 금액 action으로 합산하지 않는다.
7. 각 `PortfolioItem`에 순투자현금(`net_contributed_capital`)과 누적 수익 인출액(`cumulative_profit_withdrawn`)을 결합한다. 집계 row가 없으면 두 값은 `0.0`이다.
5. 필터링된 반환 대상 portfolio ID만 `repo.get_return_summaries(ids)`에 전달한다. repository는 한 번의 grouped query로 source와 무관한 유효 ledger를 side별 집계한다.
6. 동일 portfolio에서 matching `REVERSAL.target_ledger_entry_id`로 참조된 `INVEST`/`WITHDRAW`/`PROFIT`은 original과 reversal의 source와 무관하게 제외한다. `REVERSAL` 자체는 금액 action으로 합산하지 않는다.
7. `total_invested`는 유효 `INVEST`의 gross 합계, `total_withdrawn`은 유효 `WITHDRAW` 합계, `net_contributed_capital`은 두 값의 차이다. `PROFIT`는 원금 계산에서 제외하고 `cumulative_profit_withdrawn`으로만 집계한다. 집계 row가 없으면 값은 `0.0`이다.
8. Returns HTTP 200 `PortfolioListResponse`.
단건 `GET /v1/portfolios/{portfolio_id}/returns/summary`도 같은 grouped aggregate 구현을 portfolio ID 하나로 호출하므로 목록과 유효 원장 의미가 일치한다.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "portfoliohub"
version = "0.1.3"
version = "0.2.0"
description = "PortfolioHub API for portfolio management and ledger access"
readme = "README.md"
requires-python = ">=3.11"
+7 -7
View File
@@ -50,7 +50,7 @@ def test_workflow_actions_are_commit_pinned_and_verify_version_is_project_derive
assert 'RELEASE_VERSION=v0.1.3' not in verify
def test_release_version_and_migration_contract_are_v013_head_0029():
def test_release_version_and_migration_contract_are_v020_head_0030():
project = (REPO / "pyproject.toml").read_text()
config = (REPO / "app/core/config.py").read_text()
migrations = sorted(
@@ -58,10 +58,10 @@ def test_release_version_and_migration_contract_are_v013_head_0029():
for path in (REPO / "app/db/alembic/versions").glob("*.py")
if path.name != "__init__.py"
)
assert 'version = "0.1.3"' in project
assert 'app_version: str = "0.1.3"' in config
assert len(migrations) == 29
assert migrations[-1].startswith("20260814_0029")
assert 'version = "0.2.0"' in project
assert 'app_version: str = "0.2.0"' in config
assert len(migrations) == 30
assert migrations[-1].startswith("20260820_0030")
def test_dockerignore_is_deny_by_default_required_inputs_only():
lines=(REPO/".dockerignore").read_text().splitlines(); assert lines[0]=="**"
@@ -85,8 +85,8 @@ def test_operations_separate_publication_deployment_migration_and_db_rollback():
"Image rollback is **not** database rollback",
"read-only root filesystem",
"10001:10001",
"PortfolioHub v0.1.3 container contract",
"v0.1.3 introduces no schema migration",
"PortfolioHub v0.2.0 container contract",
"v0.2.0 adds migration `0030`",
"trex-web v0.2.3 displays `strategy_nick`",
):
assert value in text
@@ -3,41 +3,32 @@
from __future__ import annotations
import subprocess
import sys
# REQ-016 AC-1: 후보 소스에 issue_client_token / CreateClientTokenRequest / IssuedClientTokenResponse 잔여 없음
# REQ-016 AC-1: codebase에 issue_client_token / CreateClientTokenRequest / IssuedClientTokenResponse 잔여 없음
def test_no_issue_client_token_symbol_in_codebase() -> None:
def _source_symbol_matches(symbol: str) -> list[str]:
result = subprocess.run(
["grep", "-r", "--include=*.py", "-l", "issue_client_token"],
["grep", "-r", "--exclude-dir=temp", "--exclude-dir=.git", "--exclude-dir=.venv", "--include=*.py", "-l", symbol],
capture_output=True,
check=False,
text=True,
cwd=str(__import__("pathlib").Path(__file__).parent.parent),
)
matches = [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
return [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
def test_no_issue_client_token_symbol_in_codebase() -> None:
matches = _source_symbol_matches("issue_client_token")
assert matches == [], f"issue_client_token 잔여 참조 발견: {matches}"
def test_no_create_client_token_request_symbol_in_codebase() -> None:
result = subprocess.run(
["grep", "-r", "--include=*.py", "-l", "CreateClientTokenRequest"],
capture_output=True,
text=True,
cwd=str(__import__("pathlib").Path(__file__).parent.parent),
)
matches = [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
matches = _source_symbol_matches("CreateClientTokenRequest")
assert matches == [], f"CreateClientTokenRequest 잔여 참조 발견: {matches}"
def test_no_issued_client_token_response_symbol_in_codebase() -> None:
result = subprocess.run(
["grep", "-r", "--include=*.py", "-l", "IssuedClientTokenResponse"],
capture_output=True,
text=True,
cwd=str(__import__("pathlib").Path(__file__).parent.parent),
)
matches = [line for line in result.stdout.splitlines() if not line.startswith("tests/test_auth_surface")]
matches = _source_symbol_matches("IssuedClientTokenResponse")
assert matches == [], f"IssuedClientTokenResponse 잔여 참조 발견: {matches}"
+21 -2
View File
@@ -1,9 +1,12 @@
"""Repository contract tests for portfolio ledger return aggregates."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from app.repositories.postgres import PostgresPortfolioRepository
_ROOT = Path(__file__).parents[1]
def _repo_with_cursor(rows: list[tuple[object, ...]]):
repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository)
@@ -29,7 +32,7 @@ def test_get_return_summaries_empty_ids_skips_database() -> None:
repo.ensure_schema.assert_not_called()
def test_get_return_summaries_groups_portfolios_and_excludes_reversed_sources() -> None:
def test_get_return_summaries_groups_all_sources_and_excludes_reversed_entries() -> None:
repo, connection, cursor = _repo_with_cursor(
[
("p-001", 500.0, 125.0, 40.0),
@@ -60,8 +63,12 @@ def test_get_return_summaries_groups_portfolios_and_excludes_reversed_sources()
assert "group by ledger.portfolio_id" in normalized_sql
assert "not exists" in normalized_sql
assert "reversal.target_ledger_entry_id = ledger.ledger_entry_id" in normalized_sql
assert "ledger.source = 'manual'" in normalized_sql
assert "ledger.source = 'manual'" not in normalized_sql
assert "reversal.source = 'manual'" not in normalized_sql
assert "ledger.side in ('invest', 'withdraw', 'profit')" in normalized_sql
assert "sum(ledger.price) filter (where ledger.side = 'invest')" in normalized_sql
assert "sum(ledger.price) filter (where ledger.side = 'withdraw')" in normalized_sql
assert "sum(ledger.price) filter (where ledger.side = 'profit')" in normalized_sql
assert params == (["p-001", "p-002"],)
@@ -77,3 +84,15 @@ def test_get_return_summary_delegates_to_shared_batch_semantics() -> None:
assert repo.get_return_summary("p-001") == expected
repo.get_return_summaries.assert_called_once_with(["p-001"])
def test_external_contract_documents_all_source_return_semantics() -> None:
contract = (_ROOT / "docs/interface-contract.md").read_text()
assert "source와 무관한 유효 `INVEST`의 gross 합계" in contract
assert "`net_contributed_capital` (**순투자원금**) = `total_invested - total_withdrawn`" in contract
assert "`PROFIT`는 원금 계산에서 제외" in contract
assert "original과 reversal의 source와 무관하게 제외" in contract
assert "`buffer_out` 등 `INVEST`/`WITHDRAW`/`PROFIT` 이외 side" in contract
assert "유효 manual `INVEST` 합계" not in contract
assert "유효 manual `PROFIT` 합계" not in contract
@@ -0,0 +1,95 @@
"""Real PostgreSQL semantics for all-source portfolio return aggregates.
Run with PORTFOLIOHUB_RUN_LEDGER_AGGREGATE_POSTGRES=1 against a disposable/local
PostgreSQL endpoint. Normal unit-test environments skip this module.
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock
import psycopg
import pytest
from app.repositories.postgres import PostgresPortfolioRepository
pytestmark = pytest.mark.skipif(
os.getenv("PORTFOLIOHUB_RUN_LEDGER_AGGREGATE_POSTGRES") != "1",
reason="set PORTFOLIOHUB_RUN_LEDGER_AGGREGATE_POSTGRES=1 to run real PostgreSQL tests",
)
def _connect() -> psycopg.Connection:
return psycopg.connect(
host=os.getenv("TIMESCALEDB_HOST", "127.0.0.1"),
port=int(os.getenv("TIMESCALEDB_PORT", "5432")),
dbname=os.getenv("TIMESCALEDB_NAME", "portfolio_hub_migration_test"),
user=os.getenv("TIMESCALEDB_USER", "portfolio_hub_user"),
password=os.getenv("TIMESCALEDB_PASSWORD", "change-me"),
sslmode=os.getenv("TIMESCALEDB_SSLMODE", "prefer"),
)
def test_all_sources_mixed_sides_and_cross_source_reversals() -> None:
connection = _connect()
try:
with connection.cursor() as cursor:
cursor.execute(
"""
CREATE TEMP TABLE portfolio_ledger (
ledger_entry_id BIGINT PRIMARY KEY,
portfolio_id TEXT NOT NULL,
price DOUBLE PRECISION NOT NULL,
source TEXT NOT NULL,
side TEXT NOT NULL,
target_ledger_entry_id BIGINT
)
"""
)
cursor.executemany(
"""
INSERT INTO portfolio_ledger (
ledger_entry_id, portfolio_id, price, source, side, target_ledger_entry_id
) VALUES (%s, %s, %s, %s, %s, %s)
""",
[
(1, "broker-only", 700, "broker", "invest", None),
(2, "mixed", 1000, "manual", "invest", None),
(3, "mixed", 500, "broker", "invest", None),
(4, "mixed", 200, "broker", "withdraw", None),
(5, "mixed", 50, "manual", "profit", None),
# A manual reversal excludes a broker original.
(6, "mixed", 300, "broker", "invest", None),
(7, "mixed", 300, "manual", "reversal", 6),
# A broker reversal excludes a manual original.
(8, "mixed", 100, "manual", "withdraw", None),
(9, "mixed", 100, "broker", "reversal", 8),
# Non-capital sides are never inferred as INVEST.
(10, "mixed", 800_000, "broker", "buffer_out", None),
],
)
repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository)
repo.ensure_schema = MagicMock()
repo._connect = MagicMock(return_value=connection)
summaries = repo.get_return_summaries(["broker-only", "mixed"])
assert summaries == {
"broker-only": {
"total_invested": 700.0,
"total_withdrawn": 0.0,
"net_contributed_capital": 700.0,
"cumulative_profit_withdrawn": 0.0,
},
"mixed": {
"total_invested": 1500.0,
"total_withdrawn": 200.0,
"net_contributed_capital": 1300.0,
"cumulative_profit_withdrawn": 50.0,
},
}
repo.ensure_schema.assert_called_once_with()
finally:
connection.close()
+80
View File
@@ -158,6 +158,27 @@ class _FakeRepo:
self.get(portfolio_id)
return list(reversed(self.entries))[:limit]
def list_ledger_entries(
self,
portfolio_id: str,
*,
source: str | None = None,
before_recorded_at: datetime | None = None,
before_ledger_entry_id: int | None = None,
limit: int = 50,
) -> list[LedgerEntry]:
self.get(portfolio_id)
entries = sorted(self.entries, key=lambda entry: (entry.recorded_at, entry.ledger_entry_id), reverse=True)
if source is not None:
entries = [entry for entry in entries if entry.source == source]
if before_recorded_at is not None and before_ledger_entry_id is not None:
entries = [
entry
for entry in entries
if (entry.recorded_at, entry.ledger_entry_id) < (before_recorded_at, before_ledger_entry_id)
]
return entries[:limit]
def get_return_summary(self, portfolio_id: str) -> dict[str, float]:
self.get(portfolio_id)
reversed_ids = {
@@ -399,3 +420,62 @@ def test_reversal_rejects_fee_tax_and_missing_target() -> None:
)
assert missing_target.status_code == 404
assert missing_target.json()["detail"]["code"] == "LEDGER_ENTRY_NOT_FOUND"
def test_unified_ledger_paginates_mixed_sources_and_hides_side() -> None:
repo = _FakeRepo()
client = _make_client(repo)
manual = client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 100}, headers=_auth()
)
assert manual.status_code == 201
repo.entries.append(
LedgerEntry(
ledger_entry_id=99, portfolio_id=_PORTFOLIO_ID, side="buy", quantity=2, price=10, source="broker",
recorded_at=datetime.now(timezone.utc), cash_balance_after=900, asset_quantity_after=12,
)
)
first = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1", headers=_auth())
assert first.status_code == 200
first_item = first.json()["items"][0]
assert first_item["source"] == "broker"
assert first_item["action"] == "BUY"
assert "side" not in first_item
assert first.json()["next_cursor"]
second = client.get(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1&cursor={first.json()['next_cursor']}", headers=_auth()
)
assert second.status_code == 200
assert second.json()["items"][0]["ledger_entry_id"] == manual.json()["ledger_entry_id"]
assert second.json()["next_cursor"] is None
def test_unified_ledger_rejects_tampered_or_different_source_cursor() -> None:
repo = _FakeRepo()
client = _make_client(repo)
for amount in (100, 200):
assert client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": amount}, headers=_auth()
).status_code == 201
cursor = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1", headers=_auth()).json()["next_cursor"]
assert client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1&cursor={cursor[:-1]}x", headers=_auth()).status_code == 422
assert client.get(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?limit=1&source=manual&cursor={cursor}", headers=_auth()
).status_code == 422
def test_unified_ledger_source_filter_unknown_value_is_empty_and_owner_scoped() -> None:
repo = _FakeRepo()
client = _make_client(repo)
assert client.post(
f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", json={"action": "INVEST", "amount": 100}, headers=_auth()
).status_code == 201
filtered = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger?source=unknown-producer", headers=_auth())
assert filtered.status_code == 200
assert filtered.json() == {"items": [], "next_cursor": None}
foreign_client = _make_client(_FakeRepo(_portfolio(owner=_OTHER_USER_ID)))
assert foreign_client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger", headers=_auth()).status_code == 403
service_client = _make_client(_FakeRepo(principal=_token_principal(token_type="service")))
assert service_client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/ledger", headers=_auth()).status_code == 403
Generated
+1 -1
View File
@@ -592,7 +592,7 @@ wheels = [
[[package]]
name = "portfoliohub"
version = "0.1.3"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },