mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
Initial public release
Aurum — a self-hosted personal finance operating system: cash flow, net worth tracking, budgets, goals, recurring payments, reports, proactive alerts, and a compound-interest returns calculator, all bilingual (RU/EN) and mobile-first. Ships as a three-container Docker Compose stack (Postgres + FastAPI + nginx-served React frontend). See README.md for setup and CONTRIBUTING.md for development.
This commit is contained in:
282
backend/app/services/net_worth_service.py
Normal file
282
backend/app/services/net_worth_service.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""Net worth aggregation: a single trend line plus a percent breakdown by
|
||||
asset class.
|
||||
|
||||
Design note: "Cash" is not stored anywhere — it's derived live from
|
||||
Account/Transaction data (checking/savings/cash accounts only) so the
|
||||
Transactions feature stays the single source of truth for liquid money.
|
||||
Every other class (investments, crypto, real estate, vehicles, precious
|
||||
metals, other) is tracked manually via Asset/AssetValuation rows. Both are
|
||||
collapsed into one
|
||||
daily, forward-filled series so the chart reads as one continuous line even
|
||||
though the two halves are updated at very different cadences.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
from datetime import date as date_
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from itertools import groupby
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.account import Account
|
||||
from app.models.asset import Asset, AssetValuation
|
||||
from app.models.enums import AccountType, AssetClass, CapitalRole, RiskLevel, TransactionType
|
||||
from app.models.transaction import Transaction
|
||||
from app.schemas.net_worth import (
|
||||
CapitalRoleSummary,
|
||||
NetWorthBreakdownItem,
|
||||
NetWorthPoint,
|
||||
NetWorthSummary,
|
||||
RiskLevelItem,
|
||||
RiskLevelSummary,
|
||||
)
|
||||
|
||||
CASH_ACCOUNT_TYPES = {AccountType.CHECKING, AccountType.SAVINGS, AccountType.CASH}
|
||||
|
||||
RANGE_DAYS = {"30d": 30, "90d": 90, "1y": 365, "5y": 365 * 5}
|
||||
|
||||
_CLASS_META: dict[str, tuple[str, str, str]] = {
|
||||
# key -> (display name, color, lucide icon). Colors follow the dataviz
|
||||
# skill's validated 8-slot order (blue, orange, aqua, yellow, magenta,
|
||||
# green, violet, red) in this exact sequence — an adjacent-pair-safe
|
||||
# order for a segmented bar/donut; do not reorder without re-validating.
|
||||
"cash": ("Наличные", "#2a78d6", "wallet"), # slot 1 blue
|
||||
AssetClass.INVESTMENTS.value: ("Инвестиции", "#eb6834", "trending-up"), # slot 2 orange
|
||||
AssetClass.CRYPTO.value: ("Криптовалюта", "#1baf7a", "bitcoin"), # slot 3 aqua
|
||||
AssetClass.REAL_ESTATE.value: ("Недвижимость", "#eda100", "building-2"), # slot 4 yellow
|
||||
AssetClass.VEHICLES.value: ("Транспорт", "#e87ba4", "car"), # slot 5 magenta
|
||||
AssetClass.PRECIOUS_METALS.value: ("Драгметаллы", "#008300", "gem"), # slot 6 green
|
||||
AssetClass.OTHER.value: ("Прочее", "#4a3aa7", "package"), # slot 7 violet
|
||||
}
|
||||
|
||||
_ROLE_META: dict[CapitalRole, tuple[str, str]] = {
|
||||
# role -> (label, color). Status colors, not categorical ones — "good"
|
||||
# and "critical" from the dataviz skill's fixed status palette, reused
|
||||
# verbatim from how StatCard/NetWorthChart already color income vs.
|
||||
# expense elsewhere in the app.
|
||||
CapitalRole.INCOME: ("Доходный", "var(--success)"),
|
||||
CapitalRole.NEUTRAL: ("Нейтральный", "var(--text-muted)"),
|
||||
CapitalRole.DRAIN: ("Убыточный", "var(--danger)"),
|
||||
}
|
||||
|
||||
_RISK_META: dict[RiskLevel, tuple[str, str]] = {
|
||||
# Same status-color reuse as _ROLE_META — low risk reads as "good",
|
||||
# high risk as "critical", not a categorical hue.
|
||||
RiskLevel.LOW: ("Низкий", "var(--success)"),
|
||||
RiskLevel.MEDIUM: ("Средний", "var(--text-muted)"),
|
||||
RiskLevel.HIGH: ("Высокий", "var(--danger)"),
|
||||
}
|
||||
|
||||
|
||||
def _daily_series(events: list[tuple[date_, Decimal]], start: date_, end: date_) -> list[NetWorthPoint]:
|
||||
"""Forward-fills a sparse, date-sorted list of cumulative totals into one
|
||||
point per calendar day. `events` before `start` still count — they just
|
||||
collapse into `start`'s opening value."""
|
||||
events = sorted(events, key=lambda e: e[0])
|
||||
points: list[NetWorthPoint] = []
|
||||
idx, n = 0, len(events)
|
||||
current = Decimal("0")
|
||||
day = start
|
||||
while day <= end:
|
||||
while idx < n and events[idx][0] <= day:
|
||||
current = events[idx][1]
|
||||
idx += 1
|
||||
points.append(NetWorthPoint(date=day, value=current))
|
||||
day += timedelta(days=1)
|
||||
return points
|
||||
|
||||
|
||||
async def _cash_cumulative_events(session: AsyncSession) -> list[tuple[date_, Decimal]]:
|
||||
accounts_result = await session.execute(select(Account.id, Account.type))
|
||||
cash_account_ids = {acc_id for acc_id, acc_type in accounts_result.all() if acc_type in CASH_ACCOUNT_TYPES}
|
||||
|
||||
txns_result = await session.execute(
|
||||
select(Transaction.date, Transaction.type, Transaction.amount, Transaction.account_id, Transaction.transfer_account_id)
|
||||
)
|
||||
|
||||
delta_by_date: dict[date_, Decimal] = defaultdict(Decimal)
|
||||
for tx_date, tx_type, amount, account_id, transfer_account_id in txns_result.all():
|
||||
if tx_type == TransactionType.INCOME and account_id in cash_account_ids:
|
||||
delta_by_date[tx_date] += amount
|
||||
elif tx_type == TransactionType.EXPENSE and account_id in cash_account_ids:
|
||||
delta_by_date[tx_date] -= amount
|
||||
elif tx_type == TransactionType.TRANSFER:
|
||||
if account_id in cash_account_ids:
|
||||
delta_by_date[tx_date] -= amount
|
||||
if transfer_account_id in cash_account_ids:
|
||||
delta_by_date[tx_date] += amount
|
||||
|
||||
events: list[tuple[date_, Decimal]] = []
|
||||
running = Decimal("0")
|
||||
for day in sorted(delta_by_date):
|
||||
running += delta_by_date[day]
|
||||
events.append((day, running))
|
||||
return events
|
||||
|
||||
|
||||
async def _asset_events_and_class_totals(
|
||||
session: AsyncSession,
|
||||
) -> tuple[list[tuple[date_, Decimal]], dict[AssetClass, Decimal], dict[int, Decimal]]:
|
||||
asset_class_result = await session.execute(select(Asset.id, Asset.asset_class))
|
||||
asset_class_map = dict(asset_class_result.all())
|
||||
|
||||
valuations_result = await session.execute(
|
||||
select(AssetValuation.asset_id, AssetValuation.as_of_date, AssetValuation.value).order_by(
|
||||
AssetValuation.as_of_date
|
||||
)
|
||||
)
|
||||
rows = valuations_result.all()
|
||||
|
||||
current_by_asset: dict[int, Decimal] = {}
|
||||
events: list[tuple[date_, Decimal]] = []
|
||||
for day, group in groupby(rows, key=lambda row: row[1]):
|
||||
for asset_id, _, value in group:
|
||||
current_by_asset[asset_id] = value
|
||||
events.append((day, sum(current_by_asset.values(), Decimal("0"))))
|
||||
|
||||
class_totals: dict[AssetClass, Decimal] = defaultdict(Decimal)
|
||||
for asset_id, value in current_by_asset.items():
|
||||
asset_class = asset_class_map.get(asset_id)
|
||||
if asset_class is not None:
|
||||
class_totals[asset_class] += value
|
||||
|
||||
return events, class_totals, current_by_asset
|
||||
|
||||
|
||||
async def _capital_role_summary(session: AsyncSession, current_by_asset: dict[int, Decimal]) -> list[CapitalRoleSummary]:
|
||||
"""Cross-cuts the same assets by how the user tagged them (income /
|
||||
neutral / drain) instead of by asset class — always all three roles,
|
||||
even at zero, so the block reads as a fixed scale rather than a list
|
||||
that shuffles as assets are added."""
|
||||
roles_result = await session.execute(select(Asset.id, Asset.capital_role, Asset.monthly_cash_flow))
|
||||
|
||||
totals_value: dict[CapitalRole, Decimal] = defaultdict(Decimal)
|
||||
totals_flow: dict[CapitalRole, Decimal] = defaultdict(Decimal)
|
||||
counts: dict[CapitalRole, int] = defaultdict(int)
|
||||
for asset_id, role, cash_flow in roles_result.all():
|
||||
totals_value[role] += current_by_asset.get(asset_id, Decimal("0"))
|
||||
totals_flow[role] += cash_flow or Decimal("0")
|
||||
counts[role] += 1
|
||||
|
||||
return [
|
||||
CapitalRoleSummary(
|
||||
role=role.value,
|
||||
label=_ROLE_META[role][0],
|
||||
color=_ROLE_META[role][1],
|
||||
total_value=totals_value.get(role, Decimal("0")),
|
||||
monthly_cash_flow=totals_flow.get(role, Decimal("0")),
|
||||
count=counts.get(role, 0),
|
||||
)
|
||||
for role in CapitalRole
|
||||
]
|
||||
|
||||
|
||||
async def _risk_level_summary(
|
||||
session: AsyncSession, current_by_asset: dict[int, Decimal], cash_today: Decimal
|
||||
) -> list[RiskLevelSummary]:
|
||||
"""Cross-cuts Cash + assets by user-tagged risk of loss — unlike
|
||||
capital_roles, Cash participates here: it's the zero-risk anchor an
|
||||
80/20-style allocation rule ("80% of capital at zero risk, at most 20%
|
||||
exposed") is measured against. Always all three tiers, even at zero,
|
||||
same reasoning as capital_roles. Each tier's item list *is* its
|
||||
diversification view — a tier that's one holding at 100% is
|
||||
concentrated, several even-sized holdings aren't, no separate index."""
|
||||
assets_result = await session.execute(select(Asset.id, Asset.name, Asset.risk_level))
|
||||
asset_rows = assets_result.all()
|
||||
|
||||
totals: dict[RiskLevel, Decimal] = defaultdict(Decimal)
|
||||
items_by_level: dict[RiskLevel, list[tuple[str, str, Decimal]]] = defaultdict(list)
|
||||
|
||||
if cash_today:
|
||||
totals[RiskLevel.LOW] += cash_today
|
||||
items_by_level[RiskLevel.LOW].append(("cash", _CLASS_META["cash"][0], cash_today))
|
||||
|
||||
for asset_id, name, risk_level in asset_rows:
|
||||
value = current_by_asset.get(asset_id, Decimal("0"))
|
||||
if value == 0:
|
||||
continue
|
||||
totals[risk_level] += value
|
||||
items_by_level[risk_level].append((f"asset:{asset_id}", name, value))
|
||||
|
||||
grand_total = sum(totals.values(), Decimal("0"))
|
||||
|
||||
def _share(amount: Decimal, denominator: Decimal) -> float:
|
||||
return float(amount / denominator * 100) if denominator else 0.0
|
||||
|
||||
summaries = []
|
||||
for level in RiskLevel:
|
||||
tier_total = totals.get(level, Decimal("0"))
|
||||
items = [
|
||||
RiskLevelItem(key=key, name=name, amount=amount, percent=_share(amount, tier_total))
|
||||
for key, name, amount in sorted(items_by_level.get(level, []), key=lambda item: item[2], reverse=True)
|
||||
]
|
||||
summaries.append(
|
||||
RiskLevelSummary(
|
||||
risk_level=level.value,
|
||||
label=_RISK_META[level][0],
|
||||
color=_RISK_META[level][1],
|
||||
total_value=tier_total,
|
||||
percent=_share(tier_total, grand_total),
|
||||
items=items,
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
|
||||
def _resolve_start_date(range_key: str, cash_events: list[tuple[date_, Decimal]], asset_events: list[tuple[date_, Decimal]], today: date_) -> date_:
|
||||
if range_key in RANGE_DAYS:
|
||||
return today - timedelta(days=RANGE_DAYS[range_key] - 1)
|
||||
|
||||
all_dates = [e[0] for e in cash_events] + [e[0] for e in asset_events]
|
||||
return min(all_dates) if all_dates else today
|
||||
|
||||
|
||||
async def get_net_worth_summary(session: AsyncSession, range_key: str) -> NetWorthSummary:
|
||||
today = date_.today()
|
||||
cash_events = await _cash_cumulative_events(session)
|
||||
asset_events, class_totals, current_by_asset = await _asset_events_and_class_totals(session)
|
||||
capital_roles = await _capital_role_summary(session, current_by_asset)
|
||||
|
||||
start = _resolve_start_date(range_key, cash_events, asset_events, today)
|
||||
|
||||
cash_series = _daily_series(cash_events, start, today)
|
||||
asset_series = _daily_series(asset_events, start, today)
|
||||
series = [
|
||||
NetWorthPoint(date=c.date, value=c.value + a.value) for c, a in zip(cash_series, asset_series, strict=True)
|
||||
]
|
||||
|
||||
current = series[-1].value if series else Decimal("0")
|
||||
start_value = series[0].value if series else Decimal("0")
|
||||
change_amount = current - start_value
|
||||
change_percent = float(change_amount / start_value * 100) if start_value else None
|
||||
|
||||
# cash_events entries are already cumulative — the last one *is* today's total.
|
||||
cash_today = cash_events[-1][1] if cash_events else Decimal("0")
|
||||
risk_levels = await _risk_level_summary(session, current_by_asset, cash_today)
|
||||
|
||||
total = cash_today + sum(class_totals.values(), Decimal("0"))
|
||||
|
||||
def _percent(amount: Decimal) -> float:
|
||||
return float(amount / total * 100) if total else 0.0
|
||||
|
||||
breakdown = []
|
||||
name, color, icon = _CLASS_META["cash"]
|
||||
breakdown.append(NetWorthBreakdownItem(key="cash", name=name, color=color, icon=icon, amount=cash_today, percent=_percent(cash_today)))
|
||||
for asset_class in AssetClass:
|
||||
name, color, icon = _CLASS_META[asset_class.value]
|
||||
amount = class_totals.get(asset_class, Decimal("0"))
|
||||
breakdown.append(
|
||||
NetWorthBreakdownItem(key=asset_class.value, name=name, color=color, icon=icon, amount=amount, percent=_percent(amount))
|
||||
)
|
||||
|
||||
return NetWorthSummary(
|
||||
range=range_key,
|
||||
current=current,
|
||||
change_amount=change_amount,
|
||||
change_percent=change_percent,
|
||||
series=series,
|
||||
breakdown=breakdown,
|
||||
capital_roles=capital_roles,
|
||||
risk_levels=risk_levels,
|
||||
)
|
||||
Reference in New Issue
Block a user