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
+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)