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
+13 -2
View File
@@ -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))
+2
View File
@@ -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: ...
+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:
+8
View File
@@ -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