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

@@ -0,0 +1,55 @@
"""add crypto portfolios
Revision ID: b7d4e9f2a610
Revises: 7b535ef9e189
Create Date: 2026-09-04 12:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b7d4e9f2a610'
down_revision: Union[str, None] = '7b535ef9e189'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'crypto_portfolios',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('color', sa.String(length=7), nullable=True),
sa.Column('is_archived', sa.Boolean(), nullable=False, server_default=sa.text('false')),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id'),
)
# Every crypto holding needs a portfolio_id (see below) — seed one
# default portfolio up front so existing holdings have somewhere to
# land, same "row exists immediately after migrating" reasoning as
# 9f3a2d7c5e11's app_settings seed.
op.execute("INSERT INTO crypto_portfolios (id, name, color, is_archived) VALUES (1, 'Main Portfolio', '#2a78d6', false)")
op.execute("SELECT setval(pg_get_serial_sequence('crypto_portfolios', 'id'), 1)")
op.add_column('crypto_holdings', sa.Column('portfolio_id', sa.Integer(), nullable=True))
op.execute("UPDATE crypto_holdings SET portfolio_id = 1")
op.alter_column('crypto_holdings', 'portfolio_id', nullable=False)
op.create_foreign_key(
'fk_crypto_holdings_portfolio_id',
'crypto_holdings',
'crypto_portfolios',
['portfolio_id'],
['id'],
ondelete='RESTRICT',
)
def downgrade() -> None:
op.drop_constraint('fk_crypto_holdings_portfolio_id', 'crypto_holdings', type_='foreignkey')
op.drop_column('crypto_holdings', 'portfolio_id')
op.drop_table('crypto_portfolios')

View File

@@ -6,6 +6,9 @@ from app.schemas.crypto import (
CryptoHistoryResponse,
CryptoHoldingCreate,
CryptoHoldingRead,
CryptoPortfolioCreate,
CryptoPortfolioRead,
CryptoPortfolioUpdate,
CryptoSearchResult,
CryptoSyncResult,
CryptoTransactionCreate,
@@ -16,31 +19,65 @@ from app.services.crypto_service import (
CRYPTO_RANGE_DAYS,
add_transaction,
create_holding,
create_portfolio,
delete_portfolio,
delete_transaction,
get_crypto_history,
list_portfolios,
list_transactions,
refresh_prices,
search_coins,
update_portfolio,
update_transaction,
)
router = APIRouter(prefix="/crypto", tags=["crypto"])
@router.get("/portfolios", response_model=list[CryptoPortfolioRead])
async def read_portfolios(
include_archived: bool = False, session: AsyncSession = Depends(get_session)
) -> list[CryptoPortfolioRead]:
return await list_portfolios(session, include_archived)
@router.post("/portfolios", response_model=CryptoPortfolioRead, status_code=201)
async def create_portfolio_route(
payload: CryptoPortfolioCreate, session: AsyncSession = Depends(get_session)
) -> CryptoPortfolioRead:
return await create_portfolio(session, payload)
@router.patch("/portfolios/{portfolio_id}", response_model=CryptoPortfolioRead)
async def update_portfolio_route(
portfolio_id: int, payload: CryptoPortfolioUpdate, session: AsyncSession = Depends(get_session)
) -> CryptoPortfolioRead:
return await update_portfolio(session, portfolio_id, payload)
@router.delete("/portfolios/{portfolio_id}", status_code=204)
async def delete_portfolio_route(portfolio_id: int, session: AsyncSession = Depends(get_session)) -> None:
await delete_portfolio(session, portfolio_id)
@router.get("/holdings", response_model=CryptoSyncResult)
async def read_holdings(session: AsyncSession = Depends(get_session)) -> CryptoSyncResult:
async def read_holdings(
portfolio_id: int | None = None, session: AsyncSession = Depends(get_session)
) -> CryptoSyncResult:
"""Opening the Crypto tab lands here — this is also where the lazy
once-a-day auto-refresh happens (see services/crypto_service.py):
prices only actually get re-fetched from CoinGecko if 24h have passed
since the last sync, otherwise this just reads the current cache."""
return await refresh_prices(session, force=False)
return await refresh_prices(session, force=False, portfolio_id=portfolio_id)
@router.post("/refresh", response_model=CryptoSyncResult)
async def refresh_holdings(session: AsyncSession = Depends(get_session)) -> CryptoSyncResult:
async def refresh_holdings(
portfolio_id: int | None = None, session: AsyncSession = Depends(get_session)
) -> CryptoSyncResult:
"""The "Refresh prices" button — always hits CoinGecko regardless of
the once-a-day window."""
return await refresh_prices(session, force=True)
return await refresh_prices(session, force=True, portfolio_id=portfolio_id)
@router.post("/holdings", response_model=CryptoHoldingRead, status_code=201)
@@ -89,6 +126,7 @@ _HISTORY_RANGE_PATTERN = f"^({'|'.join(_HISTORY_RANGES)})$"
@router.get("/history", response_model=CryptoHistoryResponse)
async def read_crypto_history(
range: str = Query(default="30d", pattern=_HISTORY_RANGE_PATTERN),
portfolio_id: int | None = None,
session: AsyncSession = Depends(get_session),
) -> CryptoHistoryResponse:
return await get_crypto_history(session, range)
return await get_crypto_history(session, range, portfolio_id)

View File

@@ -7,7 +7,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
# title, /api/health (which the frontend reads to show it in Settings), and
# embedded in exported backups so an old file can be told apart from a
# current one.
APP_VERSION = "1.1.1"
APP_VERSION = "1.1.2"
class Settings(BaseSettings):

View File

@@ -2,7 +2,7 @@ from app.models.account import Account
from app.models.asset import Asset, AssetValuation
from app.models.budget import Budget
from app.models.category import Category
from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction
from app.models.crypto import CryptoHolding, CryptoPortfolio, CryptoSyncState, CryptoTransaction
from app.models.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings
@@ -17,6 +17,7 @@ __all__ = [
"Budget",
"Category",
"CryptoHolding",
"CryptoPortfolio",
"CryptoSyncState",
"CryptoTransaction",
"Goal",

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

View File

@@ -106,6 +106,15 @@ class AssetValuationBackup(BaseModel):
as_of_date: date_
class CryptoPortfolioBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
color: str | None
is_archived: bool
class CryptoHoldingBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -114,6 +123,11 @@ class CryptoHoldingBackup(BaseModel):
# quantity here — it's derived from crypto_transactions below, not
# stored.
asset_id: int
# Defaulted so a backup exported before crypto portfolios existed still
# imports cleanly under the same format version — restore_backup()
# resolves a missing/unknown portfolio_id to an auto-created fallback
# portfolio (see services/backup_service.py).
portfolio_id: int | None = None
coingecko_id: str
symbol: str
name: str
@@ -217,8 +231,9 @@ class BackupPayload(BaseModel):
transaction_splits: list[TransactionSplitBackup] = Field(default_factory=list)
assets: list[AssetBackup]
asset_valuations: list[AssetValuationBackup]
# Defaulted so a backup exported before crypto holdings existed still
# imports cleanly under the same format version.
# Defaulted so a backup exported before crypto holdings/portfolios
# existed still imports cleanly under the same format version.
crypto_portfolios: list[CryptoPortfolioBackup] = Field(default_factory=list)
crypto_holdings: list[CryptoHoldingBackup] = Field(default_factory=list)
crypto_transactions: list[CryptoTransactionBackup] = Field(default_factory=list)
# Defaulted so a backup exported before budgets existed still imports

View File

@@ -39,7 +39,29 @@ class CryptoTransactionRead(BaseModel):
note: str | None
class CryptoPortfolioCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
class CryptoPortfolioUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=100)
is_archived: bool | None = None
class CryptoPortfolioRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
color: str | None
is_archived: bool
class CryptoHoldingCreate(BaseModel):
# Which portfolio to file this coin under — omit to use the default
# portfolio (the earliest-created one, auto-created if none exists yet;
# see services/crypto_service.py's get_or_create_default_portfolio).
portfolio_id: int | None = None
coingecko_id: str = Field(min_length=1, max_length=100)
symbol: str = Field(min_length=1, max_length=20)
name: str = Field(min_length=1, max_length=150)
@@ -54,6 +76,7 @@ class CryptoHoldingCreate(BaseModel):
class CryptoHoldingRead(BaseModel):
asset_id: int
portfolio_id: int
coingecko_id: str
symbol: str
name: str

View File

@@ -21,7 +21,7 @@ from app.models.account import Account
from app.models.asset import Asset, AssetValuation
from app.models.budget import Budget
from app.models.category import Category
from app.models.crypto import CryptoHolding, CryptoTransaction
from app.models.crypto import CryptoHolding, CryptoPortfolio, CryptoTransaction
from app.models.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings
@@ -36,6 +36,7 @@ from app.schemas.backup import (
BudgetBackup,
CategoryBackup,
CryptoHoldingBackup,
CryptoPortfolioBackup,
CryptoTransactionBackup,
GoalBackup,
GoalContributionBackup,
@@ -56,6 +57,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
transaction_splits = (await session.execute(select(TransactionSplit))).scalars().all()
assets = (await session.execute(select(Asset))).scalars().all()
valuations = (await session.execute(select(AssetValuation))).scalars().all()
crypto_portfolios = (await session.execute(select(CryptoPortfolio))).scalars().all()
crypto_holdings = (await session.execute(select(CryptoHolding))).scalars().all()
crypto_transactions = (await session.execute(select(CryptoTransaction))).scalars().all()
budgets = (await session.execute(select(Budget))).scalars().all()
@@ -80,6 +82,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
transaction_splits=[TransactionSplitBackup.model_validate(row) for row in transaction_splits],
assets=[AssetBackup.model_validate(row) for row in assets],
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
crypto_portfolios=[CryptoPortfolioBackup.model_validate(row) for row in crypto_portfolios],
crypto_holdings=[CryptoHoldingBackup.model_validate(row) for row in crypto_holdings],
crypto_transactions=[CryptoTransactionBackup.model_validate(row) for row in crypto_transactions],
budgets=[BudgetBackup.model_validate(row) for row in budgets],
@@ -124,10 +127,18 @@ def _validate_references(payload: BackupPayload) -> None:
if v.asset_id not in asset_ids:
raise HTTPException(400, f"Asset valuation {v.id} references unknown asset_id {v.asset_id}")
crypto_portfolio_ids = {p.id for p in payload.crypto_portfolios}
crypto_holding_asset_ids = {h.asset_id for h in payload.crypto_holdings}
for h in payload.crypto_holdings:
if h.asset_id not in asset_ids:
raise HTTPException(400, f"Crypto holding {h.asset_id} references unknown asset_id {h.asset_id}")
# portfolio_id is allowed to be None (a pre-portfolios backup) — that
# case is resolved to an auto-created fallback portfolio at restore
# time, not validated here.
if h.portfolio_id is not None and h.portfolio_id not in crypto_portfolio_ids:
raise HTTPException(
400, f"Crypto holding {h.asset_id} references unknown portfolio_id {h.portfolio_id}"
)
for tx in payload.crypto_transactions:
if tx.asset_id not in crypto_holding_asset_ids:
@@ -182,6 +193,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
await session.execute(delete(AssetValuation))
await session.execute(delete(CryptoTransaction))
await session.execute(delete(CryptoHolding))
await session.execute(delete(CryptoPortfolio))
await session.execute(delete(Budget))
await session.execute(delete(GoalContribution))
await session.execute(delete(Goal))
@@ -223,7 +235,30 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
session.add_all(TransactionSplit(**row.model_dump()) for row in payload.transaction_splits)
session.add_all(AssetValuation(**row.model_dump()) for row in payload.asset_valuations)
session.add_all(CryptoHolding(**row.model_dump()) for row in payload.crypto_holdings)
session.add_all(CryptoPortfolio(**row.model_dump()) for row in payload.crypto_portfolios)
# A pre-portfolios backup has no crypto_portfolios and every holding's
# portfolio_id is None — give those holdings a freshly created
# fallback portfolio instead of leaving the NOT NULL column unset.
# Wired up via the `portfolio` relationship (not a raw portfolio_id
# int) because the fallback row has no id yet — same "assign the
# relationship, not the id column, before flush" reasoning as
# transactions_by_id/tags_by_id above.
fallback_portfolio: CryptoPortfolio | None = None
if any(row.portfolio_id is None for row in payload.crypto_holdings):
fallback_portfolio = CryptoPortfolio(name="Main Portfolio", color="#2a78d6")
session.add(fallback_portfolio)
crypto_holdings = []
for row in payload.crypto_holdings:
holding = CryptoHolding(**row.model_dump(exclude={"portfolio_id"}))
if row.portfolio_id is not None:
holding.portfolio_id = row.portfolio_id
else:
holding.portfolio = fallback_portfolio
crypto_holdings.append(holding)
session.add_all(crypto_holdings)
session.add_all(CryptoTransaction(**row.model_dump()) for row in payload.crypto_transactions)
session.add_all(Budget(**row.model_dump()) for row in payload.budgets)
session.add_all(Goal(**row.model_dump()) for row in payload.goals)
@@ -244,6 +279,10 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
await _reset_sequence(session, "transactions", payload.transactions)
await _reset_sequence(session, "transaction_splits", payload.transaction_splits)
await _reset_sequence(session, "asset_valuations", payload.asset_valuations)
# Only needed for explicit-id portfolios from payload — a fallback
# portfolio (no payload row) already got its id from the sequence
# itself, so the sequence is already correctly positioned for it.
await _reset_sequence(session, "crypto_portfolios", payload.crypto_portfolios)
await _reset_sequence(session, "crypto_transactions", payload.crypto_transactions)
await _reset_sequence(session, "budgets", payload.budgets)
await _reset_sequence(session, "goals", payload.goals)

View File

@@ -34,13 +34,16 @@ from sqlalchemy.orm import selectinload
from app.core.config import get_settings
from app.models.asset import Asset, AssetValuation
from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction
from app.models.crypto import CryptoHolding, CryptoPortfolio, CryptoSyncState, CryptoTransaction
from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel
from app.schemas.crypto import (
CryptoHistoryPoint,
CryptoHistoryResponse,
CryptoHoldingCreate,
CryptoHoldingRead,
CryptoPortfolioCreate,
CryptoPortfolioRead,
CryptoPortfolioUpdate,
CryptoSearchResult,
CryptoSyncResult,
CryptoTransactionCreate,
@@ -48,6 +51,21 @@ from app.schemas.crypto import (
)
from app.services.settings_service import get_or_create_app_settings
# Same 8-hue, colorblind-safe categorical set app/db/seed.py assigns default
# categories from — reused here (cycling by creation order) so portfolio tab
# dots are visually distinct out of the box, with no manual color picker to
# build for a v1 feature.
PORTFOLIO_PALETTE = [
"#2a78d6",
"#eb6834",
"#1baf7a",
"#eda100",
"#e87ba4",
"#008300",
"#4a3aa7",
"#e34948",
]
COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
# CoinGecko's own Demo-tier data only refreshes every 60s server-side
# (see docs.coingecko.com/reference/simple-price) — polling more often than
@@ -179,6 +197,7 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
profit_loss_percent = float(profit_loss / cost_basis * 100) if profit_loss is not None and cost_basis else None
return CryptoHoldingRead(
asset_id=holding.asset_id,
portfolio_id=holding.portfolio_id,
coingecko_id=holding.coingecko_id,
symbol=holding.symbol,
name=holding.name,
@@ -196,8 +215,77 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
)
async def list_holdings(session: AsyncSession) -> list[CryptoHolding]:
result = await session.execute(select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name))
def _portfolio_to_read(portfolio: CryptoPortfolio) -> CryptoPortfolioRead:
return CryptoPortfolioRead.model_validate(portfolio)
async def list_portfolios(session: AsyncSession, include_archived: bool) -> list[CryptoPortfolioRead]:
stmt = select(CryptoPortfolio).order_by(CryptoPortfolio.id)
if not include_archived:
stmt = stmt.where(CryptoPortfolio.is_archived.is_(False))
portfolios = (await session.execute(stmt)).scalars().all()
return [_portfolio_to_read(p) for p in portfolios]
async def get_or_create_default_portfolio(session: AsyncSession) -> CryptoPortfolio:
"""The portfolio a new holding lands in when the caller doesn't specify
one (see CryptoHoldingCreate.portfolio_id) — the earliest-created
portfolio, auto-created the first time it's needed. Same self-healing
"create on first use" shape as seed_default_app_settings: a portfolio can
later be deleted once empty (see delete_portfolio), so this can't just
assume row id=1 always exists."""
existing = (
await session.execute(select(CryptoPortfolio).order_by(CryptoPortfolio.id).limit(1))
).scalar_one_or_none()
if existing is not None:
return existing
portfolio = CryptoPortfolio(name="Main Portfolio", color=PORTFOLIO_PALETTE[0])
session.add(portfolio)
await session.flush()
return portfolio
async def create_portfolio(session: AsyncSession, payload: CryptoPortfolioCreate) -> CryptoPortfolioRead:
count = (await session.execute(select(CryptoPortfolio.id))).scalars().all()
color = PORTFOLIO_PALETTE[len(count) % len(PORTFOLIO_PALETTE)]
portfolio = CryptoPortfolio(name=payload.name, color=color)
session.add(portfolio)
await session.commit()
await session.refresh(portfolio)
return _portfolio_to_read(portfolio)
async def update_portfolio(
session: AsyncSession, portfolio_id: int, payload: CryptoPortfolioUpdate
) -> CryptoPortfolioRead:
portfolio = await session.get(CryptoPortfolio, portfolio_id)
if portfolio is None:
raise HTTPException(status_code=404, detail="Crypto portfolio not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(portfolio, field, value)
await session.commit()
await session.refresh(portfolio)
return _portfolio_to_read(portfolio)
async def delete_portfolio(session: AsyncSession, portfolio_id: int) -> None:
portfolio = await session.get(CryptoPortfolio, portfolio_id)
if portfolio is None:
raise HTTPException(status_code=404, detail="Crypto portfolio not found")
has_holding = (
await session.execute(select(CryptoHolding.asset_id).where(CryptoHolding.portfolio_id == portfolio_id).limit(1))
).first()
if has_holding is not None:
raise HTTPException(status_code=400, detail="Move or delete its coins before deleting a portfolio")
await session.delete(portfolio)
await session.commit()
async def list_holdings(session: AsyncSession, portfolio_id: int | None = None) -> list[CryptoHolding]:
stmt = select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name)
if portfolio_id is not None:
stmt = stmt.where(CryptoHolding.portfolio_id == portfolio_id)
result = await session.execute(stmt)
return list(result.scalars().all())
@@ -236,12 +324,17 @@ async def _upsert_valuation(session: AsyncSession, asset_id: int, value: Decimal
await session.execute(upsert_stmt)
async def refresh_prices(session: AsyncSession, *, force: bool) -> CryptoSyncResult:
async def refresh_prices(session: AsyncSession, *, force: bool, portfolio_id: int | None = None) -> CryptoSyncResult:
"""Sync always covers every holding regardless of `portfolio_id` — a
stale price on a coin the user isn't currently looking at would still be
wrong the next time they switch tabs. `portfolio_id` only narrows what's
returned in the response's `holdings` list, for the Crypto tab's
portfolio filter."""
state = await get_or_create_sync_state(session)
now = datetime.now(timezone.utc)
if not force and state.last_synced_at is not None and now - state.last_synced_at < AUTO_REFRESH_INTERVAL:
holdings = await list_holdings(session)
holdings = await list_holdings(session, portfolio_id)
return CryptoSyncResult(synced=False, last_synced_at=state.last_synced_at, holdings=[_to_read(h) for h in holdings])
holdings = await list_holdings(session)
@@ -277,17 +370,26 @@ async def refresh_prices(session: AsyncSession, *, force: bool) -> CryptoSyncRes
# holdings were mutated in place above (ordinary ORM attribute
# assignment, not a raw Core write) — no need to re-query, they already
# reflect what was just committed.
visible = holdings if portfolio_id is None else [h for h in holdings if h.portfolio_id == portfolio_id]
return CryptoSyncResult(
synced=error_key is None,
last_synced_at=state.last_synced_at,
error_key=error_key,
holdings=[_to_read(h) for h in holdings],
holdings=[_to_read(h) for h in visible],
)
async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) -> CryptoHoldingRead:
settings = await get_or_create_app_settings(session)
if payload.portfolio_id is not None:
portfolio = await session.get(CryptoPortfolio, payload.portfolio_id)
if portfolio is None:
raise HTTPException(status_code=400, detail="Crypto portfolio not found")
portfolio_id = portfolio.id
else:
portfolio_id = (await get_or_create_default_portfolio(session)).id
asset = Asset(
name=payload.name,
asset_class=AssetClass.CRYPTO,
@@ -303,6 +405,7 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
holding = CryptoHolding(
asset_id=asset.id,
portfolio_id=portfolio_id,
coingecko_id=payload.coingecko_id,
symbol=payload.symbol.upper(),
name=payload.name,
@@ -440,19 +543,28 @@ async def delete_transaction(session: AsyncSession, transaction_id: int) -> None
await session.commit()
async def get_crypto_history(session: AsyncSession, range_key: str) -> CryptoHistoryResponse:
async def get_crypto_history(
session: AsyncSession, range_key: str, portfolio_id: int | None = None
) -> CryptoHistoryResponse:
"""Total crypto holdings value over time, for the History chart on the
Crypto tab. Same "cumulative point events, forward-filled" approach as
net_worth_service.py's own asset series — duplicated here rather than
imported, so this can't destabilize the already-tested Net Worth engine,
and scoped to crypto-class assets only (a deleted holding's
AssetValuation rows are gone via cascade, so its history naturally
drops out here too, same as it already does for Net Worth)."""
drops out here too, same as it already does for Net Worth). Further
scoped to one portfolio's assets when `portfolio_id` is given, for the
Crypto tab's portfolio filter."""
today = date_.today()
crypto_asset_ids = set(
(await session.execute(select(Asset.id).where(Asset.asset_class == AssetClass.CRYPTO))).scalars().all()
asset_stmt = (
select(Asset.id)
.join(CryptoHolding, CryptoHolding.asset_id == Asset.id)
.where(Asset.asset_class == AssetClass.CRYPTO)
)
if portfolio_id is not None:
asset_stmt = asset_stmt.where(CryptoHolding.portfolio_id == portfolio_id)
crypto_asset_ids = set((await session.execute(asset_stmt)).scalars().all())
if not crypto_asset_ids:
return CryptoHistoryResponse(range=range_key, current=Decimal("0"), change_amount=Decimal("0"), change_percent=None, series=[])

View File

@@ -273,6 +273,59 @@ async def test_backup_roundtrip_preserves_holding_and_transaction_log(client: As
assert money(restored["value"]) == money("100000") # last_price survived too (2 * 50000)
async def test_backup_roundtrip_preserves_portfolio_assignment(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
portfolio = (await client.post("/crypto/portfolios", json={"name": "Long-term"})).json()
resp = await client.post(
"/crypto/holdings",
json={
"portfolio_id": portfolio["id"],
"coingecko_id": "bitcoin",
"symbol": "btc",
"name": "Bitcoin",
"quantity": "1",
"price_per_unit": "40000",
"date": "2026-01-01",
},
)
assert resp.status_code == 201, resp.text
payload = (await client.get("/backup/export")).json()
assert len(payload["crypto_portfolios"]) == 1
assert payload["crypto_holdings"][0]["portfolio_id"] == portfolio["id"]
import_resp = await client.post("/backup/import", json=payload)
assert import_resp.status_code == 200, import_resp.text
restored_portfolios = (await client.get("/crypto/portfolios")).json()
assert len(restored_portfolios) == 1
assert restored_portfolios[0]["name"] == "Long-term"
restored_holding = (await client.get("/crypto/holdings")).json()["holdings"][0]
assert restored_holding["portfolio_id"] == restored_portfolios[0]["id"]
async def test_restoring_a_pre_portfolios_backup_falls_back_to_a_default_portfolio(client: AsyncClient, monkeypatch):
"""A backup exported before crypto portfolios existed has no
crypto_portfolios list and every holding's portfolio_id is absent —
restore_backup() must still produce a valid NOT NULL portfolio_id
instead of failing the import."""
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
holding = await _add_bitcoin(client, "1", "40000")
payload = (await client.get("/backup/export")).json()
payload["crypto_portfolios"] = []
payload["crypto_holdings"][0]["portfolio_id"] = None
import_resp = await client.post("/backup/import", json=payload)
assert import_resp.status_code == 200, import_resp.text
restored_portfolios = (await client.get("/crypto/portfolios")).json()
assert len(restored_portfolios) == 1
restored_holding = (await client.get("/crypto/holdings")).json()["holdings"][0]
assert restored_holding["asset_id"] == holding["asset_id"]
assert restored_holding["portfolio_id"] == restored_portfolios[0]["id"]
async def test_history_reflects_current_total_crypto_value(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
await _add_bitcoin(client, "1", "40000")
@@ -358,3 +411,105 @@ async def test_update_transaction_404_for_unknown_id(client: AsyncClient):
resp = await client.patch("/crypto/transactions/999999", json={"quantity": "1"})
assert resp.status_code == 404
async def test_holding_without_portfolio_id_lands_in_an_auto_created_default(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
holding = await _add_bitcoin(client)
portfolios = (await client.get("/crypto/portfolios")).json()
assert len(portfolios) == 1
assert portfolios[0]["name"] == "Main Portfolio"
assert holding["portfolio_id"] == portfolios[0]["id"]
async def test_create_portfolio_and_file_a_holding_under_it(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
created = await client.post("/crypto/portfolios", json={"name": "Long-term"})
assert created.status_code == 201, created.text
portfolio = created.json()
assert portfolio["name"] == "Long-term"
assert portfolio["is_archived"] is False
resp = await client.post(
"/crypto/holdings",
json={
"portfolio_id": portfolio["id"],
"coingecko_id": "bitcoin",
"symbol": "btc",
"name": "Bitcoin",
"quantity": "0.5",
"price_per_unit": "40000",
"date": "2026-01-01",
},
)
assert resp.status_code == 201, resp.text
assert resp.json()["portfolio_id"] == portfolio["id"]
async def test_same_coin_can_be_tracked_separately_in_two_portfolios(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
memes = (await client.post("/crypto/portfolios", json={"name": "Memes"})).json()
longterm = (await client.post("/crypto/portfolios", json={"name": "Long-term"})).json()
async def add(portfolio_id, quantity):
resp = await client.post(
"/crypto/holdings",
json={
"portfolio_id": portfolio_id,
"coingecko_id": "bitcoin",
"symbol": "btc",
"name": "Bitcoin",
"quantity": quantity,
"price_per_unit": "40000",
"date": "2026-01-01",
},
)
assert resp.status_code == 201, resp.text
return resp.json()
await add(memes["id"], "1")
await add(longterm["id"], "2")
all_holdings = (await client.get("/crypto/holdings")).json()["holdings"]
assert len(all_holdings) == 2
memes_holdings = (await client.get(f"/crypto/holdings?portfolio_id={memes['id']}")).json()["holdings"]
assert len(memes_holdings) == 1
assert money(memes_holdings[0]["quantity"]) == money("1")
longterm_history = (await client.get(f"/crypto/history?range=30d&portfolio_id={longterm['id']}")).json()
assert money(longterm_history["current"]) == money("100000") # 2 * 50000, memes' 1 BTC excluded
async def test_deleting_a_nonempty_portfolio_is_rejected(client: AsyncClient, monkeypatch):
monkeypatch.setattr(crypto_service, "_fetch_market_data", _fake_fetch({"bitcoin": _point("50000")}))
holding = await _add_bitcoin(client)
portfolio_id = holding["portfolio_id"]
resp = await client.delete(f"/crypto/portfolios/{portfolio_id}")
assert resp.status_code == 400
async def test_archived_portfolios_are_excluded_by_default(client: AsyncClient):
portfolio = (await client.post("/crypto/portfolios", json={"name": "Old fund"})).json()
await client.patch(f"/crypto/portfolios/{portfolio['id']}", json={"is_archived": True})
visible = (await client.get("/crypto/portfolios")).json()
assert visible == []
with_archived = (await client.get("/crypto/portfolios?include_archived=true")).json()
assert len(with_archived) == 1
assert with_archived[0]["is_archived"] is True
async def test_empty_archived_portfolio_can_be_deleted(client: AsyncClient):
portfolio = (await client.post("/crypto/portfolios", json={"name": "Unused"})).json()
resp = await client.delete(f"/crypto/portfolios/{portfolio['id']}")
assert resp.status_code == 204
assert (await client.get("/crypto/portfolios")).json() == []