mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
feat: портфели в разделе «Криптовалюта»
Криптовалюту теперь можно разделять на отдельные портфели (например, долгосрочные инвестиции, накопления на недвижимость, спекулятивные монеты) вместо одного общего списка — по тому же принципу, что уже есть у счетов (Account): у портфеля есть имя, цветная метка и архивация, каждая монета обязательно принадлежит одному портфелю, один и тот же токен можно держать в нескольких портфелях сразу.
This commit is contained in:
@@ -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')
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=[])
|
||||
|
||||
|
||||
@@ -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() == []
|
||||
|
||||
Reference in New Issue
Block a user