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:
2026-08-13 13:23:31 +09:00
parent 34792bdb5c
commit 93840800fa
9 changed files with 264 additions and 21 deletions
+79
View File
@@ -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"])
+65
View File
@@ -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)
# ---------------------------------------------------------------------------
+43 -3
View File
@@ -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)