feat: портфели в разделе «Криптовалюта»

Криптовалюту теперь можно разделять на отдельные портфели (например,
долгосрочные инвестиции, накопления на недвижимость, спекулятивные
монеты) вместо одного общего списка — по тому же принципу, что уже
есть у счетов (Account): у портфеля есть имя, цветная метка и
архивация, каждая монета обязательно принадлежит одному портфелю,
один и тот же токен можно держать в нескольких портфелях сразу.
This commit is contained in:
ZProger
2026-09-04 01:39:18 +03:00
parent c2f59c8773
commit aefd6cc035
21 changed files with 933 additions and 50 deletions

View File

@@ -9,11 +9,16 @@ CryptoTransaction log (same "you record it, we derive it" shape as
Goal/GoalContribution), because a sell doesn't just subtract quantity, it
also has to leave the average cost basis of what's still held unchanged
(see services/crypto_service.py's _compute_position).
Every holding also belongs to a CryptoPortfolio (below) — a user-defined
group like "long-term" or "memes" — so the same coin can be tracked
separately in more than one portfolio (each as its own CryptoHolding row,
since coingecko_id is deliberately not unique).
"""
from datetime import date as date_
from datetime import datetime
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Numeric, String, Text
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
@@ -22,6 +27,26 @@ from app.models.enums import CryptoTransactionType
from app.models.mixins import TimestampMixin
class CryptoPortfolio(Base, TimestampMixin):
"""A user-defined grouping of crypto holdings (e.g. "long-term",
"real estate fund", "memes") — same shape as Account (id, name, color,
is_archived), but scoped to the Crypto tab instead of Transactions.
Every CryptoHolding belongs to exactly one portfolio (see
CryptoHolding.portfolio_id below); there's no "ungrouped" state."""
__tablename__ = "crypto_portfolios"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
# Hex color for the portfolio's tab dot — auto-assigned from the app's
# categorical palette at creation time (see services/crypto_service.py's
# _next_portfolio_color), not user-editable, same as Account.color.
color: Mapped[str | None] = mapped_column(String(7), nullable=True)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
holdings: Mapped[list["CryptoHolding"]] = relationship(back_populates="portfolio")
class CryptoHolding(Base, TimestampMixin):
"""1:1 extension of an Asset (asset_class=CRYPTO) — asset_id doubles as
this table's own primary key rather than a separate id+unique
@@ -33,6 +58,13 @@ class CryptoHolding(Base, TimestampMixin):
__tablename__ = "crypto_holdings"
asset_id: Mapped[int] = mapped_column(ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True)
# Which portfolio this coin is tracked under (see CryptoPortfolio above).
# RESTRICT, not CASCADE: deleting a non-empty portfolio is blocked at the
# service layer (services/crypto_service.py's delete_portfolio) rather
# than silently taking its holdings down with it.
portfolio_id: Mapped[int] = mapped_column(
ForeignKey("crypto_portfolios.id", ondelete="RESTRICT"), nullable=False
)
# CoinGecko's stable coin id (e.g. "bitcoin") — NOT the ticker symbol,
# which collides across unrelated coins (CoinGecko lists dozens of
@@ -53,6 +85,7 @@ class CryptoHolding(Base, TimestampMixin):
price_change_7d: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True)
asset: Mapped["Asset"] = relationship()
portfolio: Mapped["CryptoPortfolio"] = relationship(back_populates="holdings")
transactions: Mapped[list["CryptoTransaction"]] = relationship(
back_populates="holding", cascade="all, delete-orphan", order_by="CryptoTransaction.date"
)