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_READ_TOKENS=read-token-123
PORTFOLIOHUB_TRADE_WRITE_TOKENS=trade-token-456 PORTFOLIOHUB_TRADE_WRITE_TOKENS=trade-token-456
PORTFOLIOHUB_ADMIN_TOKENS=admin-token-789 PORTFOLIOHUB_ADMIN_TOKENS=admin-token-789
PORTFOLIOHUB_LEDGER_CURSOR_SECRET=replace-with-a-long-random-secret
PORTFOLIOHUB_ENVIRONMENT=development PORTFOLIOHUB_ENVIRONMENT=development
# Enable DB-backed repository (uses TIMESCALEDB_* below) # Enable DB-backed repository (uses TIMESCALEDB_* below)
+6 -5
View File
@@ -3,14 +3,15 @@ id: "ac-0001"
status: active status: active
--- ---
# 포트폴리오 목록의 ledger 투자금 집계 필드 # 포트폴리오 목록의 source-independent 투자금 집계 필드
## Criterion ## 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 ## Verification
- 성공 조건: ledger 집계가 있는 portfolio는 두 필드에 해당 집계값을 반환하고, ledger 집계가 없는 portfolio는 두 필드를 `0.0`으로 반환한다. - 성공 조건: broker-only와 manual/broker mixed-source portfolio가 source와 무관한 side별 합계로 두 필드를 반환하고, ledger 집계가 없는 portfolio는 두 필드를 `0.0`으로 반환한다.
- 실패·경계 조건: 빈 목록은 `portfolios: []`, `total: 0`을 반환하며 신규 필드 추가 때문에 기존 응답 필드가 제거되거나 값의 의미가 바뀌지 않는다. - 실패·경계 조건: `PROFIT`이 원금에 포함되거나 `buffer_out` 등 다른 side가 `INVEST`로 재해석되거나, 기존 응답 필드·token/filter 동작이 변경되면 실패한다.
- 검사 방법: `tests/test_v1_portfolios_list.py`의 목록 집계값 결합·기본값·빈 목록·기존 필드 회귀 테스트와 OpenAPI schema의 `PortfolioItem`을 검사한다. - 검사 방법: `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 status: active
--- ---
# REVERSAL-aware manual ledger 산식과 목록·단건 일치 # Source-independent REVERSAL 산식과 목록·단건 일치
## Criterion ## 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 ## Verification
- 성공 조건: 미취소 `INVEST`, `WITHDRAW`, `PROFIT` 조합에서 `PROFIT`은 `WITHDRAW`와 분리되고 목록과 단건 summary의 `total_invested`, `total_withdrawn`, `net_contributed_capital`, `cumulative_profit_withdrawn`이 일치한다. - 성공 조건: broker-only와 mixed-source `INVEST`·`WITHDRAW`·`PROFIT` 조합에서 gross·withdrawn·net·profit 값이 승인 산식과 일치하고 목록과 단건 summary가 일치한다.
- 실패·경계 조건: `INVEST`, `WITHDRAW`, `PROFIT`을 각각 reversal하면 해당 원거래 효과가 제거되며 reversal row 자체가 합계에 더해지지 않는다. - 실패·경계 조건: manual reversal→broker original 또는 broker reversal→manual original에서 original 효과가 남거나, `PROFIT`이 원금에 섞이거나 reversal row 자체가 합산되면 실패한다.
- 검사 방법: `tests/test_portfolio_ledger_aggregates.py`의 shared batch semantics와 `tests/test_v1_portfolios_manual_ledger.py`의 profit 분리 및 세 action reversal 회귀를 실행한다. - 검사 방법: 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 status: active
--- ---
# 다중 포트폴리오 grouped aggregate와 N+1 방지 # 다중 포트폴리오 source-independent grouped aggregate와 N+1 방지
## Criterion ## 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 ## Verification
- 성공 조건: 둘 이상의 portfolio ID를 한 번에 전달했을 때 단일 query가 실행되고 각 ID의 합계가 독립된 mapping으로 반환된다. - 성공 조건: 둘 이상의 portfolio ID를 한 번에 전달했을 때 단일 query가 실행되고 broker/manual source가 섞여도 각 ID의 side별 합계가 독립된 mapping으로 반환된다.
- 실패·경계 조건: 빈 ID 입력은 `{}`를 반환하고 DB connection 및 schema/query 호출이 발생하지 않는다. - 실패·경계 조건: source predicate가 다시 도입되거나 portfolio별 N+1 query가 발생하거나, 빈 ID 입력에서 DB 작업이 발생하거나, portfolio ID 사이 금액이 섞이면 실패한다.
- 검사 방법: `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를 한 번 요청하는지 확인한다. - 검사 방법: `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 status: active
--- ---
# additive API·문서 호환성과 reversal 회귀 보존 # API·문서 호환성과 query-only source-independent 교정
## Criterion ## 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 ## Verification
- 성공 조건: 기존 목록 필드와 token/filter 동작이 보존되고 `docs/interface-contract.md`, `docs/runtime-flows.md`, OpenAPI schema가 두 필드의 산식·REVERSAL·권위 경계를 일관되게 설명한다. - 성공 조건: `app/schemas/v1.py`, `docs/interface-contract.md`, `docs/data-model.md`, `docs/runtime-flows.md`가 같은 산식·reversal·권위 경계를 설명하고 기존 field·auth·filter 계약을 보존한다.
- 실패·경계 조건: `PROFIT`을 `WITHDRAW` 또는 전체 실현손익으로 설명하지 않고, 동일 원거래를 두 번 reversal하려는 기존 요청은 계속 거부된다. - 실패·경계 조건: stale manual-only 문구가 남거나 `PROFIT`을 원금/전체 실현손익으로 설명하거나, API rename, ledger mutation/backfill, `buffer_out=INVEST` 재분류 또는 권한 우회가 발생하면 실패한다.
- 검사 방법: 목록·manual ledger 회귀 테스트와 문서·OpenAPI 검사를 실행하고 focused suite 및 전체 프로젝트 suite를 사용해 기존 동작 회귀 여부를 판정한다. - 검사 방법: 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, "schema_version": 1,
"next_line_number": 5, "next_line_number": 7,
"next_ac_number": 32 "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 from __future__ import annotations
import base64
import binascii
import hashlib
import hmac
import json
import logging import logging
import uuid import uuid
from datetime import date, datetime, timedelta, timezone 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 fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from app.core.auth import AuthContext, Permission, require_read, require_trade_write 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.errors import DaemonInactiveError, DaemonNotFoundError
from app.domain.models import Portfolio, PortfolioComment from app.domain.models import Portfolio, PortfolioComment
from app.domain.pnl import calculate_current_wac, calculate_realized_pnl from app.domain.pnl import calculate_current_wac, calculate_realized_pnl
from app.repositories.factory import get_portfolio_repository_from_settings from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import ( from app.schemas.v1 import (
HoldingsCostBasisResponse,
LedgerHistoryItem,
LedgerHistoryResponse,
LedgerTradeRequest, LedgerTradeRequest,
LedgerTradeResponse, LedgerTradeResponse,
ManualLedgerHistoryResponse, ManualLedgerHistoryResponse,
@@ -35,7 +44,6 @@ from app.schemas.v1 import (
PortfolioReturnSummaryResponse, PortfolioReturnSummaryResponse,
PortfolioTagListResponse, PortfolioTagListResponse,
PortfolioUpdateRequest, PortfolioUpdateRequest,
HoldingsCostBasisResponse,
RealizedPnlItem, RealizedPnlItem,
RealizedPnlResponse, RealizedPnlResponse,
TagApplyRequest, TagApplyRequest,
@@ -44,7 +52,10 @@ from app.schemas.v1 import (
TradeHistoryItem, TradeHistoryItem,
TradeHistoryResponse, TradeHistoryResponse,
) )
from app.services.strategy_set_service import StrategyInactiveError, StrategyNotFoundError from app.services.strategy_set_service import (
StrategyInactiveError,
StrategyNotFoundError,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/portfolios", tags=["v1"]) router = APIRouter(prefix="/v1/portfolios", tags=["v1"])
@@ -710,6 +721,62 @@ def _manual_error_code(message: str) -> str:
return "MANUAL_LEDGER_CONFLICT" 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( @router.post(
"/{portfolio_id}/ledger/manual", "/{portfolio_id}/ledger/manual",
response_model=ManualLedgerResponse, response_model=ManualLedgerResponse,
@@ -788,6 +855,43 @@ def list_manual_ledger_v1(
return ManualLedgerHistoryResponse(items=items, total=len(items)) 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) @router.get("/{portfolio_id}/returns/summary", response_model=PortfolioReturnSummaryResponse)
def get_portfolio_return_summary_v1( def get_portfolio_return_summary_v1(
portfolio_id: str, portfolio_id: str,
+2 -1
View File
@@ -18,7 +18,7 @@ if env_file.exists():
class Settings(BaseSettings): class Settings(BaseSettings):
app_name: str = "PortfolioHub" app_name: str = "PortfolioHub"
app_version: str = "0.1.3" app_version: str = "0.2.0"
environment: str = "development" environment: str = "development"
allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS") allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS")
bootstrap_username: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_USERNAME") 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") read_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_READ_TOKENS")
trade_write_tokens_raw: str = Field(default="", validation_alias="PORTFOLIOHUB_TRADE_WRITE_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") 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") 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") 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") 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_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_summary(self, portfolio_id: str) -> dict[str, float]: ...
def get_return_summaries(self, portfolio_ids: list[str]) -> dict[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()] 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]]: def get_return_summaries(self, portfolio_ids: list[str]) -> dict[str, dict[str, float]]:
if not portfolio_ids: if not portfolio_ids:
return {} return {}
@@ -2223,13 +2258,11 @@ class PostgresPortfolioRepository:
COALESCE(SUM(ledger.price) FILTER (WHERE ledger.side = 'profit'), 0) COALESCE(SUM(ledger.price) FILTER (WHERE ledger.side = 'profit'), 0)
FROM portfolio_ledger AS ledger FROM portfolio_ledger AS ledger
WHERE ledger.portfolio_id = ANY(%s) WHERE ledger.portfolio_id = ANY(%s)
AND ledger.source = 'manual'
AND ledger.side IN ('invest', 'withdraw', 'profit') AND ledger.side IN ('invest', 'withdraw', 'profit')
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 SELECT 1
FROM portfolio_ledger AS reversal FROM portfolio_ledger AS reversal
WHERE reversal.portfolio_id = ledger.portfolio_id WHERE reversal.portfolio_id = ledger.portfolio_id
AND reversal.source = 'manual'
AND reversal.side = 'reversal' AND reversal.side = 'reversal'
AND reversal.target_ledger_entry_id = ledger.ledger_entry_id AND reversal.target_ledger_entry_id = ledger.ledger_entry_id
) )
+47 -6
View File
@@ -220,12 +220,53 @@ class ManualLedgerHistoryResponse(BaseModel):
total: int 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): class PortfolioReturnSummaryResponse(BaseModel):
portfolio_id: str portfolio_id: str
total_invested: float = 0.0 total_invested: float = Field(
total_withdrawn: float = 0.0 default=0.0,
net_contributed_capital: float = 0.0 description="총투자원금: source와 무관한 유효 INVEST의 gross 합계",
cumulative_profit_withdrawn: float = 0.0 )
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 profit_buffer: float = 0.0
net_contributed_capital: float = Field( net_contributed_capital: float = Field(
default=0.0, default=0.0,
description="순투자현금: 유효 manual INVEST 합계 - 유효 manual WITHDRAW 합계", description="순투자원금: source와 무관한 유효 INVEST 합계 - 유효 WITHDRAW 합계 (PROFIT 제외)",
) )
cumulative_profit_withdrawn: float = Field( cumulative_profit_withdrawn: float = Field(
default=0.0, default=0.0,
description="누적 수익 인출액: 유효 manual PROFIT 합계", description="누적 수익 인출액: source와 무관한 유효 PROFIT 합계 (원금 계산 제외)",
) )
tag_ids: list[str] = Field(default_factory=list) tag_ids: list[str] = Field(default_factory=list)
managed_by_daemon_id: str | None = None 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 ## 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:<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: 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> 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 ## 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_BOOTSTRAP_PASSWORD` | optional; empty | **yes** | bootstrap password; inject temporarily and remove after first successful start |
| `PORTFOLIOHUB_ENVIRONMENT` | optional; `development` | no | reported environment metadata | | `PORTFOLIOHUB_ENVIRONMENT` | optional; `development` | no | reported environment metadata |
| `PORTFOLIOHUB_APP_NAME` | optional; `PortfolioHub` | no | application/OpenAPI display name | | `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 ### 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 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. - **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. - **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. 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`; 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; 3. stop writes or otherwise establish an operator-approved maintenance boundary;
4. execute `pfhub migrate-db` once with restart disabled; 4. execute `pfhub migrate-db` once with restart disabled;
5. stop rollout on any nonzero result and preserve sanitized logs; 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; 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. 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. 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 ## 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. 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. - SELL: asset_quantity -> cash_balance.
- REVERSAL: append-only inverse effect of `target_ledger_entry_id`. - 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 ### State Rules
- Manual writes lock the portfolio row while calculating/appending/updating snapshots to avoid lost updates. - 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>}` - 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`. - 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: - Ledger aggregate fields:
- `net_contributed_capital` (**순투자현금**) = 유효 manual `INVEST` 합계 - 유효 manual `WITHDRAW` 합계. - `total_invested` (**총투자원금**, summary response) = source와 무관한 유효 `INVEST`의 gross 합계.
- `cumulative_profit_withdrawn` (**누적 수익 인출액**) = 유효 manual `PROFIT` 합계. 매매상 실현손익 전체가 아니다. - `total_withdrawn` (**총회수원금**, summary response) = source와 무관한 유효 `WITHDRAW` 합계.
- `REVERSAL.target_ledger_entry_id`로 참조된 `INVEST`, `WITHDRAW`, `PROFIT` 원거래는 유효 집계에서 제외하며 `REVERSAL` 자체는 금액 action으로 합산하지 않는다. - `net_contributed_capital` (**순투자원금**) = `total_invested - total_withdrawn`. `PROFIT`는 원금 계산에서 제외한다.
- 목록에 반환될 portfolio ID 집합만 한 번의 grouped aggregate로 조회한다. 해당 유효 manual ledger가 없는 항목은 두 필드 모두 `0.0`이다. - `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를 재해석하지 않는다. - 이 필드들은 portfolio-hub가 소유한 ledger 집계값이다. 소비자는 ledger event를 재해석하지 않는다.
### Endpoint: `GET /v1/portfolios/{portfolio_id}/tags` ### Endpoint: `GET /v1/portfolios/{portfolio_id}/tags`
@@ -438,6 +441,9 @@
- Auth: owner-scoped user/session token. - Auth: owner-scoped user/session token.
- Response: `PortfolioReturnSummaryResponse` with `total_invested`, `total_withdrawn`, `net_contributed_capital`, and `cumulative_profit_withdrawn`. - 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)` ## Interface: `Tag Management API (BAT-004)`
@@ -562,6 +568,7 @@
## Change Log ## 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-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-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 헤더 갱신. - 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, ...)`. 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`. 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. 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를 집계한다. 5. 필터링된 반환 대상 portfolio ID만 `repo.get_return_summaries(ids)`에 전달한다. repository는 한 번의 grouped query로 source와 무관한 유효 ledger를 side별 집계한다.
6. `REVERSAL.target_ledger_entry_id`로 참조된 `INVEST`/`WITHDRAW`/`PROFIT`은 제외한다. `REVERSAL` 자체는 금액 action으로 합산하지 않는다. 6. 동일 portfolio에서 matching `REVERSAL.target_ledger_entry_id`로 참조된 `INVEST`/`WITHDRAW`/`PROFIT`은 original과 reversal의 source와 무관하게 제외한다. `REVERSAL` 자체는 금액 action으로 합산하지 않는다.
7. 각 `PortfolioItem`에 순투자현금(`net_contributed_capital`)과 누적 수익 인출액(`cumulative_profit_withdrawn`)을 결합한다. 집계 row가 없으면 두 값은 `0.0`이다. 7. `total_invested`는 유효 `INVEST`의 gross 합계, `total_withdrawn`은 유효 `WITHDRAW` 합계, `net_contributed_capital`은 두 값의 차이다. `PROFIT`는 원금 계산에서 제외하고 `cumulative_profit_withdrawn`으로만 집계한다. 집계 row가 없으면 값은 `0.0`이다.
8. Returns HTTP 200 `PortfolioListResponse`. 8. Returns HTTP 200 `PortfolioListResponse`.
단건 `GET /v1/portfolios/{portfolio_id}/returns/summary`도 같은 grouped aggregate 구현을 portfolio ID 하나로 호출하므로 목록과 유효 원장 의미가 일치한다. 단건 `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] [project]
name = "portfoliohub" name = "portfoliohub"
version = "0.1.3" version = "0.2.0"
description = "PortfolioHub API for portfolio management and ledger access" description = "PortfolioHub API for portfolio management and ledger access"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" 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 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() project = (REPO / "pyproject.toml").read_text()
config = (REPO / "app/core/config.py").read_text() config = (REPO / "app/core/config.py").read_text()
migrations = sorted( 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") for path in (REPO / "app/db/alembic/versions").glob("*.py")
if path.name != "__init__.py" if path.name != "__init__.py"
) )
assert 'version = "0.1.3"' in project assert 'version = "0.2.0"' in project
assert 'app_version: str = "0.1.3"' in config assert 'app_version: str = "0.2.0"' in config
assert len(migrations) == 29 assert len(migrations) == 30
assert migrations[-1].startswith("20260814_0029") assert migrations[-1].startswith("20260820_0030")
def test_dockerignore_is_deny_by_default_required_inputs_only(): def test_dockerignore_is_deny_by_default_required_inputs_only():
lines=(REPO/".dockerignore").read_text().splitlines(); assert lines[0]=="**" 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", "Image rollback is **not** database rollback",
"read-only root filesystem", "read-only root filesystem",
"10001:10001", "10001:10001",
"PortfolioHub v0.1.3 container contract", "PortfolioHub v0.2.0 container contract",
"v0.1.3 introduces no schema migration", "v0.2.0 adds migration `0030`",
"trex-web v0.2.3 displays `strategy_nick`", "trex-web v0.2.3 displays `strategy_nick`",
): ):
assert value in text assert value in text
@@ -3,41 +3,32 @@
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
import sys
# REQ-016 AC-1: 후보 소스에 issue_client_token / CreateClientTokenRequest / IssuedClientTokenResponse 잔여 없음
# REQ-016 AC-1: codebase에 issue_client_token / CreateClientTokenRequest / IssuedClientTokenResponse 잔여 없음 def _source_symbol_matches(symbol: str) -> list[str]:
def test_no_issue_client_token_symbol_in_codebase() -> None:
result = subprocess.run( 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, capture_output=True,
check=False,
text=True, text=True,
cwd=str(__import__("pathlib").Path(__file__).parent.parent), 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}" assert matches == [], f"issue_client_token 잔여 참조 발견: {matches}"
def test_no_create_client_token_request_symbol_in_codebase() -> None: def test_no_create_client_token_request_symbol_in_codebase() -> None:
result = subprocess.run( matches = _source_symbol_matches("CreateClientTokenRequest")
["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")]
assert matches == [], f"CreateClientTokenRequest 잔여 참조 발견: {matches}" assert matches == [], f"CreateClientTokenRequest 잔여 참조 발견: {matches}"
def test_no_issued_client_token_response_symbol_in_codebase() -> None: def test_no_issued_client_token_response_symbol_in_codebase() -> None:
result = subprocess.run( matches = _source_symbol_matches("IssuedClientTokenResponse")
["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")]
assert matches == [], f"IssuedClientTokenResponse 잔여 참조 발견: {matches}" assert matches == [], f"IssuedClientTokenResponse 잔여 참조 발견: {matches}"
+21 -2
View File
@@ -1,9 +1,12 @@
"""Repository contract tests for portfolio ledger return aggregates.""" """Repository contract tests for portfolio ledger return aggregates."""
from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from app.repositories.postgres import PostgresPortfolioRepository from app.repositories.postgres import PostgresPortfolioRepository
_ROOT = Path(__file__).parents[1]
def _repo_with_cursor(rows: list[tuple[object, ...]]): def _repo_with_cursor(rows: list[tuple[object, ...]]):
repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository) repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository)
@@ -29,7 +32,7 @@ def test_get_return_summaries_empty_ids_skips_database() -> None:
repo.ensure_schema.assert_not_called() 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( repo, connection, cursor = _repo_with_cursor(
[ [
("p-001", 500.0, 125.0, 40.0), ("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 "group by ledger.portfolio_id" in normalized_sql
assert "not exists" in normalized_sql assert "not exists" in normalized_sql
assert "reversal.target_ledger_entry_id = ledger.ledger_entry_id" 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 "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"],) 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 assert repo.get_return_summary("p-001") == expected
repo.get_return_summaries.assert_called_once_with(["p-001"]) 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) self.get(portfolio_id)
return list(reversed(self.entries))[:limit] 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]: def get_return_summary(self, portfolio_id: str) -> dict[str, float]:
self.get(portfolio_id) self.get(portfolio_id)
reversed_ids = { 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.status_code == 404
assert missing_target.json()["detail"]["code"] == "LEDGER_ENTRY_NOT_FOUND" 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]] [[package]]
name = "portfoliohub" name = "portfoliohub"
version = "0.1.3" version = "0.2.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },