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
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -2053,28 +2053,55 @@ 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
|
||||
""",
|
||||
(portfolio_id,),
|
||||
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
|
||||
)
|
||||
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)
|
||||
GROUP BY ledger.portfolio_id
|
||||
""",
|
||||
(portfolio_ids,),
|
||||
)
|
||||
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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<id>`
|
||||
- Response: HTTP 200 `PortfolioListResponse {"portfolios": [{"portfolio_id": "<uuid>", "asset_id": "<uuid>", "symbol": "<assets.symbol|null>", ...}], "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`.
|
||||
- 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`
|
||||
|
||||
|
||||
@@ -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=<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`
|
||||
|
||||
|
||||
@@ -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"])
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user