From 93840800fa214632ec458b6470a999275bcac926 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Thu, 13 Aug 2026 13:23:31 +0900 Subject: [PATCH] feat(portfolios): expose ledger investment aggregates Add reversal-aware grouped manual-ledger aggregates to portfolio list responses and align the single-portfolio return summary with the same semantics.\n\nRefs #23 --- app/api/v1_portfolios.py | 15 ++++- app/repositories/portfolio_repository.py | 2 + app/repositories/postgres.py | 55 ++++++++++++---- app/schemas/v1.py | 8 +++ docs/interface-contract.md | 8 ++- docs/runtime-flows.md | 7 +- tests/test_portfolio_ledger_aggregates.py | 79 +++++++++++++++++++++++ tests/test_v1_portfolios_list.py | 65 +++++++++++++++++++ tests/test_v1_portfolios_manual_ledger.py | 46 ++++++++++++- 9 files changed, 264 insertions(+), 21 deletions(-) create mode 100644 tests/test_portfolio_ledger_aggregates.py diff --git a/app/api/v1_portfolios.py b/app/api/v1_portfolios.py index b6e339b..4ba168e 100644 --- a/app/api/v1_portfolios.py +++ b/app/api/v1_portfolios.py @@ -67,7 +67,12 @@ def _require_write_token(auth: AuthContext) -> None: ) -def _portfolio_to_item(portfolio: Portfolio) -> PortfolioItem: +def _portfolio_to_item( + portfolio: Portfolio, + *, + return_summary: dict[str, float] | None = None, +) -> PortfolioItem: + summary = return_summary or {} return PortfolioItem( portfolio_id=portfolio.portfolio_id, asset_id=portfolio.asset_id, @@ -81,6 +86,8 @@ def _portfolio_to_item(portfolio: Portfolio) -> PortfolioItem: asset_quantity=portfolio.asset_quantity, price=portfolio.price, profit_buffer=portfolio.profit_buffer, + net_contributed_capital=summary.get("net_contributed_capital", 0.0), + cumulative_profit_withdrawn=summary.get("cumulative_profit_withdrawn", 0.0), tag_ids=[str(t) for t in (portfolio.tag_ids or [])], managed_by_daemon_id=portfolio.managed_by_daemon_id, created_at=portfolio.created_at, @@ -295,12 +302,16 @@ def list_portfolios_v1( market=market, status=status_filter, ) + return_summaries = repo.get_return_summaries([portfolio.portfolio_id for portfolio in portfolios]) except HTTPException: raise finally: _close_repo(repo) - items = [_portfolio_to_item(p) for p in portfolios] + items = [ + _portfolio_to_item(p, return_summary=return_summaries.get(p.portfolio_id)) + for p in portfolios + ] return PortfolioListResponse(portfolios=items, total=len(items)) diff --git a/app/repositories/portfolio_repository.py b/app/repositories/portfolio_repository.py index dba14f8..d37d715 100644 --- a/app/repositories/portfolio_repository.py +++ b/app/repositories/portfolio_repository.py @@ -315,6 +315,8 @@ class PortfolioRepository(Protocol): 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 reset(self, portfolio_id: str) -> Portfolio: ... def adjust_buffer(self, portfolio_id: str, amount: float, action: str, note: str = "") -> Portfolio: ... diff --git a/app/repositories/postgres.py b/app/repositories/postgres.py index 2f6f3f6..24335ec 100644 --- a/app/repositories/postgres.py +++ b/app/repositories/postgres.py @@ -2053,29 +2053,56 @@ class PostgresPortfolioRepository: ) return [self._row_to_ledger_entry(row) for row in cur.fetchall()] - 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]]: + if not portfolio_ids: + return {} self.ensure_schema() with self._connect().cursor() as cur: cur.execute( """ - SELECT side, COALESCE(SUM(price), 0) - FROM portfolio_ledger - WHERE portfolio_id = %s AND source = 'manual' AND side IN ('invest', 'withdraw', 'profit') - GROUP BY side + SELECT ledger.portfolio_id, + COALESCE(SUM(ledger.price) FILTER (WHERE ledger.side = 'invest'), 0), + COALESCE(SUM(ledger.price) FILTER (WHERE ledger.side = 'withdraw'), 0), + COALESCE(SUM(ledger.price) FILTER (WHERE ledger.side = 'profit'), 0) + FROM portfolio_ledger AS ledger + WHERE ledger.portfolio_id = ANY(%s) + AND ledger.source = 'manual' + AND ledger.side IN ('invest', 'withdraw', 'profit') + AND NOT EXISTS ( + SELECT 1 + FROM portfolio_ledger AS reversal + WHERE reversal.portfolio_id = ledger.portfolio_id + AND reversal.source = 'manual' + AND reversal.side = 'reversal' + AND reversal.target_ledger_entry_id = ledger.ledger_entry_id + ) + GROUP BY ledger.portfolio_id """, - (portfolio_id,), + (portfolio_ids,), ) - sums = {str(side).upper(): float(total) for side, total in cur.fetchall()} - invested = sums.get("INVEST", 0.0) - withdrawn = sums.get("WITHDRAW", 0.0) - profit = sums.get("PROFIT", 0.0) + rows = cur.fetchall() return { - "total_invested": invested, - "total_withdrawn": withdrawn, - "net_contributed_capital": invested - withdrawn, - "cumulative_profit_withdrawn": profit, + str(portfolio_id): { + "total_invested": float(invested), + "total_withdrawn": float(withdrawn), + "net_contributed_capital": float(invested) - float(withdrawn), + "cumulative_profit_withdrawn": float(profit), + } + for portfolio_id, invested, withdrawn, profit in rows } + def get_return_summary(self, portfolio_id: str) -> dict[str, float]: + summaries = self.get_return_summaries([portfolio_id]) + return summaries.get( + portfolio_id, + { + "total_invested": 0.0, + "total_withdrawn": 0.0, + "net_contributed_capital": 0.0, + "cumulative_profit_withdrawn": 0.0, + }, + ) + def get_asset_by_id(self, asset_id: str) -> Asset | None: self.ensure_schema() with self._connect().cursor() as cur: diff --git a/app/schemas/v1.py b/app/schemas/v1.py index 6ffe10d..23ed964 100644 --- a/app/schemas/v1.py +++ b/app/schemas/v1.py @@ -269,6 +269,14 @@ class PortfolioItem(BaseModel): asset_quantity: float = 0.0 price: float = 0.0 profit_buffer: float = 0.0 + net_contributed_capital: float = Field( + default=0.0, + description="순투자현금: 유효 manual INVEST 합계 - 유효 manual WITHDRAW 합계", + ) + cumulative_profit_withdrawn: float = Field( + default=0.0, + description="누적 수익 인출액: 유효 manual PROFIT 합계", + ) tag_ids: list[str] = Field(default_factory=list) managed_by_daemon_id: str | None = None created_at: datetime | None = None diff --git a/docs/interface-contract.md b/docs/interface-contract.md index f47b031..01c50f5 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -328,8 +328,14 @@ - Branching by token type: - `service`: returns portfolios where `managed_by_daemon_id = token.daemon_id`; NULL daemon_id → HTTP 403 - `user/admin`: returns owner's own portfolios; admin may override with `?user_id=` -- Response: HTTP 200 `PortfolioListResponse {"portfolios": [{"portfolio_id": "", "asset_id": "", "symbol": "", ...}], "total": }` +- Response: HTTP 200 `PortfolioListResponse {"portfolios": [{"portfolio_id": "", "asset_id": "", "symbol": "", "net_contributed_capital": , "cumulative_profit_withdrawn": , ...}], "total": }` - Note: list items are serialized from the server-side `portfolios JOIN assets` result; `symbol` must equal the joined `assets.symbol` for each returned `asset_id`. +- Ledger aggregate fields: + - `net_contributed_capital` (**순투자현금**) = 유효 manual `INVEST` 합계 - 유효 manual `WITHDRAW` 합계. + - `cumulative_profit_withdrawn` (**누적 수익 인출액**) = 유효 manual `PROFIT` 합계. 매매상 실현손익 전체가 아니다. + - `REVERSAL.target_ledger_entry_id`로 참조된 `INVEST`, `WITHDRAW`, `PROFIT` 원거래는 유효 집계에서 제외하며 `REVERSAL` 자체는 금액 action으로 합산하지 않는다. + - 목록에 반환될 portfolio ID 집합만 한 번의 grouped aggregate로 조회한다. 해당 유효 manual ledger가 없는 항목은 두 필드 모두 `0.0`이다. + - 이 필드들은 portfolio-hub가 소유한 ledger 집계값이다. 소비자는 ledger event를 재해석하지 않는다. ### Endpoint: `GET /v1/portfolios/{portfolio_id}/tags` diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index f89fa78..a803de0 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -141,7 +141,12 @@ 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=` → filter by that user; else filter by `auth.user_id`. 4. `?include_disabled=false` (default) → `status="enabled"` filter applied. `?include_disabled=true` → no status filter. -5. Returns HTTP 200 `PortfolioListResponse`. +5. 필터링된 반환 대상 portfolio ID만 `repo.get_return_summaries(ids)`에 전달한다. repository는 한 번의 grouped query로 유효 manual ledger를 집계한다. +6. `REVERSAL.target_ledger_entry_id`로 참조된 `INVEST`/`WITHDRAW`/`PROFIT`은 제외한다. `REVERSAL` 자체는 금액 action으로 합산하지 않는다. +7. 각 `PortfolioItem`에 순투자현금(`net_contributed_capital`)과 누적 수익 인출액(`cumulative_profit_withdrawn`)을 결합한다. 집계 row가 없으면 두 값은 `0.0`이다. +8. Returns HTTP 200 `PortfolioListResponse`. + +단건 `GET /v1/portfolios/{portfolio_id}/returns/summary`도 같은 grouped aggregate 구현을 portfolio ID 하나로 호출하므로 목록과 유효 원장 의미가 일치한다. ## Flow: `v1 API — Soft Delete and Dependent Data Preservation` diff --git a/tests/test_portfolio_ledger_aggregates.py b/tests/test_portfolio_ledger_aggregates.py new file mode 100644 index 0000000..455bd96 --- /dev/null +++ b/tests/test_portfolio_ledger_aggregates.py @@ -0,0 +1,79 @@ +"""Repository contract tests for portfolio ledger return aggregates.""" + +from unittest.mock import MagicMock, patch + +from app.repositories.postgres import PostgresPortfolioRepository + + +def _repo_with_cursor(rows: list[tuple[object, ...]]): + repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository) + repo.ensure_schema = MagicMock() + cursor = MagicMock() + cursor.__enter__.return_value = cursor + cursor.__exit__.return_value = None + cursor.fetchall.return_value = rows + connection = MagicMock() + connection.cursor.return_value = cursor + return repo, connection, cursor + + +def test_get_return_summaries_empty_ids_skips_database() -> None: + repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository) + repo.ensure_schema = MagicMock() + + with patch.object(repo, "_connect") as connect: + result = repo.get_return_summaries([]) + + assert result == {} + connect.assert_not_called() + repo.ensure_schema.assert_not_called() + + +def test_get_return_summaries_groups_portfolios_and_excludes_reversed_sources() -> None: + repo, connection, cursor = _repo_with_cursor( + [ + ("p-001", 500.0, 125.0, 40.0), + ("p-002", 200.0, 0.0, 5.0), + ] + ) + + with patch.object(repo, "_connect", return_value=connection): + result = repo.get_return_summaries(["p-001", "p-002"]) + + assert result == { + "p-001": { + "total_invested": 500.0, + "total_withdrawn": 125.0, + "net_contributed_capital": 375.0, + "cumulative_profit_withdrawn": 40.0, + }, + "p-002": { + "total_invested": 200.0, + "total_withdrawn": 0.0, + "net_contributed_capital": 200.0, + "cumulative_profit_withdrawn": 5.0, + }, + } + cursor.execute.assert_called_once() + sql, params = cursor.execute.call_args.args + normalized_sql = " ".join(sql.split()).lower() + assert "group by ledger.portfolio_id" in normalized_sql + assert "not exists" in normalized_sql + assert "reversal.target_ledger_entry_id = ledger.ledger_entry_id" in normalized_sql + assert "ledger.source = 'manual'" in normalized_sql + assert "ledger.side in ('invest', 'withdraw', 'profit')" in normalized_sql + assert params == (["p-001", "p-002"],) + + +def test_get_return_summary_delegates_to_shared_batch_semantics() -> None: + repo = PostgresPortfolioRepository.__new__(PostgresPortfolioRepository) + expected = { + "total_invested": 300.0, + "total_withdrawn": 100.0, + "net_contributed_capital": 200.0, + "cumulative_profit_withdrawn": 50.0, + } + repo.get_return_summaries = MagicMock(return_value={"p-001": expected}) + + assert repo.get_return_summary("p-001") == expected + repo.get_return_summaries.assert_called_once_with(["p-001"]) \ No newline at end of file diff --git a/tests/test_v1_portfolios_list.py b/tests/test_v1_portfolios_list.py index 5be0114..0c70277 100644 --- a/tests/test_v1_portfolios_list.py +++ b/tests/test_v1_portfolios_list.py @@ -81,6 +81,8 @@ class _FakeListRepo: ) -> None: self._portfolios: list[Portfolio] = portfolios or [] self._token_principal = token_principal + self.return_summaries: dict[str, dict[str, float]] = {} + self.summary_requests: list[list[str]] = [] def close(self) -> None: pass @@ -106,6 +108,14 @@ class _FakeListRepo: results = [p for p in results if p.status == status] return results + def get_return_summaries(self, portfolio_ids: list[str]) -> dict[str, dict[str, float]]: + self.summary_requests.append(list(portfolio_ids)) + return { + portfolio_id: self.return_summaries[portfolio_id] + for portfolio_id in portfolio_ids + if portfolio_id in self.return_summaries + } + def _make_client( admin_tokens: list[str] | None = None, @@ -296,6 +306,61 @@ def test_get_response_has_asset_id_field() -> None: assert item["symbol"] == "005930" +def test_get_list_includes_return_summary_fields_from_one_filtered_batch() -> None: + own = _make_portfolio("p-001", owner=_USER_ID) + other = _make_portfolio("p-002", owner=_OTHER_USER_ID) + repo = _FakeListRepo(portfolios=[own, other]) + repo.return_summaries = { + "p-001": { + "total_invested": 500.0, + "total_withdrawn": 125.0, + "net_contributed_capital": 375.0, + "cumulative_profit_withdrawn": 40.0, + }, + "p-002": { + "total_invested": 999.0, + "total_withdrawn": 0.0, + "net_contributed_capital": 999.0, + "cumulative_profit_withdrawn": 0.0, + }, + } + client = _make_client() + + with _with_repo(repo): + resp = client.get(f"/v1/portfolios?user_id={_USER_ID}", headers=_auth("admin-tok")) + + assert resp.status_code == 200 + assert repo.summary_requests == [["p-001"]] + item = resp.json()["portfolios"][0] + assert item["net_contributed_capital"] == 375.0 + assert item["cumulative_profit_withdrawn"] == 40.0 + + +def test_get_list_defaults_missing_return_summary_to_zero() -> None: + repo = _FakeListRepo(portfolios=[_make_portfolio("p-001")]) + client = _make_client() + + with _with_repo(repo): + resp = client.get("/v1/portfolios", headers=_auth("admin-tok")) + + assert resp.status_code == 200 + item = resp.json()["portfolios"][0] + assert item["net_contributed_capital"] == 0.0 + assert item["cumulative_profit_withdrawn"] == 0.0 + + +def test_get_empty_list_uses_empty_batch_without_n_plus_one() -> None: + repo = _FakeListRepo() + client = _make_client() + + with _with_repo(repo): + resp = client.get("/v1/portfolios", headers=_auth("admin-tok")) + + assert resp.status_code == 200 + assert resp.json() == {"portfolios": [], "total": 0} + assert repo.summary_requests == [[]] + + # --------------------------------------------------------------------------- # REQ-055: GET /v1/portfolios 응답 전략·시장 메타데이터 additive 필드 (BAT-023) # --------------------------------------------------------------------------- diff --git a/tests/test_v1_portfolios_manual_ledger.py b/tests/test_v1_portfolios_manual_ledger.py index c496804..428a94a 100644 --- a/tests/test_v1_portfolios_manual_ledger.py +++ b/tests/test_v1_portfolios_manual_ledger.py @@ -160,9 +160,15 @@ class _FakeRepo: def get_return_summary(self, portfolio_id: str) -> dict[str, float]: self.get(portfolio_id) - invested = sum(e.price for e in self.entries if e.action == "INVEST") - withdrawn = sum(e.price for e in self.entries if e.action == "WITHDRAW") - profit = sum(e.price for e in self.entries if e.action == "PROFIT") + reversed_ids = { + e.target_ledger_entry_id + for e in self.entries + if e.action == "REVERSAL" and e.target_ledger_entry_id is not None + } + active = [e for e in self.entries if e.ledger_entry_id not in reversed_ids] + invested = sum(e.price for e in active if e.action == "INVEST") + withdrawn = sum(e.price for e in active if e.action == "WITHDRAW") + profit = sum(e.price for e in active if e.action == "PROFIT") return { "total_invested": invested, "total_withdrawn": withdrawn, @@ -257,6 +263,40 @@ def test_profit_is_exposed_separately_from_net_contributed_capital() -> None: assert data["cumulative_profit_withdrawn"] == 50.0 +def test_return_summary_excludes_reversed_invest_withdraw_and_profit() -> None: + repo = _FakeRepo(_portfolio(buffer=1_000)) + client = _make_client(repo) + + entries = [] + for action, amount in (("INVEST", 300), ("WITHDRAW", 100), ("PROFIT", 50)): + response = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", + json={"action": action, "amount": amount}, + headers=_auth(), + ) + assert response.status_code == 201 + entries.append(response.json()["ledger_entry_id"]) + + for target in entries: + response = client.post( + f"/v1/portfolios/{_PORTFOLIO_ID}/ledger/manual", + json={"action": "REVERSAL", "target_ledger_entry_id": target}, + headers=_auth(), + ) + assert response.status_code == 201 + + resp = client.get(f"/v1/portfolios/{_PORTFOLIO_ID}/returns/summary", headers=_auth()) + + assert resp.status_code == 200 + assert resp.json() == { + "portfolio_id": _PORTFOLIO_ID, + "total_invested": 0.0, + "total_withdrawn": 0.0, + "net_contributed_capital": 0.0, + "cumulative_profit_withdrawn": 0.0, + } + + def test_insufficient_cash_rejected_as_409() -> None: repo = _FakeRepo(_portfolio(cash=10, quantity=0, price=0)) client = _make_client(repo)