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
+41 -14
View File
@@ -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: