fix: aggregate investment totals across ledger sources

This commit is contained in:
2026-08-19 17:34:13 +09:00
parent 02bfa60299
commit 5eb8c04266
7 changed files with 158 additions and 18 deletions
-2
View File
@@ -2223,13 +2223,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
) )
+18 -6
View File
@@ -222,10 +222,22 @@ class ManualLedgerHistoryResponse(BaseModel):
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 +285,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
+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 하나로 호출하므로 목록과 유효 원장 의미가 일치한다.
+22 -3
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"],)
@@ -76,4 +83,16 @@ def test_get_return_summary_delegates_to_shared_batch_semantics() -> None:
repo.get_return_summaries = MagicMock(return_value={"p-001": expected}) repo.get_return_summaries = MagicMock(return_value={"p-001": expected})
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()