feat: full CoinMarketCap-style crypto portfolio (buy/sell log, 1h/24h/7d%, P&L)

Replaces the plain quantity field with a CryptoTransaction log (same
"record events, derive the total" shape as Goal/GoalContribution): quantity
and average buy price are now computed via weighted-average cost — a buy
blends into the running average, a sell reduces quantity but leaves the
average cost of what's still held unchanged.

Switches price fetching from /simple/price to CoinGecko's /coins/markets
(still one batched call for every coin), which also returns 1h/24h/7d %
change in the same request. Old PATCH /crypto/holdings/{id} is replaced by
POST .../transactions (buy/sell, rejects selling more than held), GET
.../transactions (history), and DELETE /crypto/transactions/{id}.

Frontend: CryptoHoldingsTable replaces the simple list with a real table
(Name/Price/1h%/24h%/7d%/Holdings/Avg Buy Price/Profit-Loss/Actions),
horizontally scrollable on mobile rather than dropping columns. New
CryptoTransactionModal (buy/sell) and CryptoTransactionHistoryModal.

Backend: 79/79 tests passing (13 in test_crypto.py, up from 9). Also
verified live end-to-end against the real CoinGecko API with the user's own
key — create, buy, sell, oversell rejection, history, manual refresh, all
against real BTC/ETH prices in EUR — test data cleaned up after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01855PiW5Wd8n4aRW4n9kByY
This commit is contained in:
ZProger
2026-08-30 04:25:03 +03:00
parent 7e4508d1de
commit e8cd536584
24 changed files with 1154 additions and 374 deletions

View File

@@ -2,8 +2,22 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_session
from app.schemas.crypto import CryptoHoldingCreate, CryptoHoldingRead, CryptoHoldingUpdate, CryptoSearchResult, CryptoSyncResult
from app.services.crypto_service import create_holding, refresh_prices, search_coins, update_holding_quantity
from app.schemas.crypto import (
CryptoHoldingCreate,
CryptoHoldingRead,
CryptoSearchResult,
CryptoSyncResult,
CryptoTransactionCreate,
CryptoTransactionRead,
)
from app.services.crypto_service import (
add_transaction,
create_holding,
delete_transaction,
list_transactions,
refresh_prices,
search_coins,
)
router = APIRouter(prefix="/crypto", tags=["crypto"])
@@ -31,11 +45,24 @@ async def create_holding_route(
return await create_holding(session, payload)
@router.patch("/holdings/{asset_id}", response_model=CryptoHoldingRead)
async def update_holding_route(
asset_id: int, payload: CryptoHoldingUpdate, session: AsyncSession = Depends(get_session)
@router.post("/holdings/{asset_id}/transactions", response_model=CryptoHoldingRead, status_code=201)
async def add_transaction_route(
asset_id: int, payload: CryptoTransactionCreate, session: AsyncSession = Depends(get_session)
) -> CryptoHoldingRead:
return await update_holding_quantity(session, asset_id, payload.quantity)
"""Buy more of, or sell some of, a coin already being tracked."""
return await add_transaction(session, asset_id, payload)
@router.get("/holdings/{asset_id}/transactions", response_model=list[CryptoTransactionRead])
async def list_transactions_route(
asset_id: int, session: AsyncSession = Depends(get_session)
) -> list[CryptoTransactionRead]:
return await list_transactions(session, asset_id)
@router.delete("/transactions/{transaction_id}", status_code=204)
async def delete_transaction_route(transaction_id: int, session: AsyncSession = Depends(get_session)) -> None:
await delete_transaction(session, transaction_id)
@router.get("/search", response_model=list[CryptoSearchResult])

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
from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction
from app.models.goal import Goal, GoalContribution
from app.models.recurring import RecurringTransaction
from app.models.settings import AppSettings
@@ -18,6 +18,7 @@ __all__ = [
"Category",
"CryptoHolding",
"CryptoSyncState",
"CryptoTransaction",
"Goal",
"GoalContribution",
"RecurringTransaction",

View File

@@ -1,17 +1,24 @@
"""Crypto holdings: a thin layer on top of the existing Asset/AssetValuation
engine (see models/asset.py) rather than a parallel system. A CryptoHolding
only records identity (which coin) and quantity (how much) — the actual
value lives as ordinary AssetValuation rows, kept current by
services/crypto_service.py, so Net Worth requires zero changes to treat a
crypto holding like any other manually tracked asset.
engine (see models/asset.py) rather than a parallel system. Net worth's
number for a holding still lives as ordinary AssetValuation rows, kept
current by services/crypto_service.py — Net Worth requires zero changes to
treat a crypto holding like any other manually tracked asset.
Quantity and average buy price are NOT stored — they're computed from the
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).
"""
from datetime import date as date_
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Numeric, String
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Numeric, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.asset import Asset
from app.models.enums import CryptoTransactionType
from app.models.mixins import TimestampMixin
@@ -20,7 +27,8 @@ class CryptoHolding(Base, TimestampMixin):
this table's own primary key rather than a separate id+unique
constraint, so the relationship can never drift into a 1:many by
accident. Deleting the Asset (the existing DELETE /assets/{id}) cascades
here too — there's no separate delete endpoint for a holding."""
here too, and cascades again to every CryptoTransaction — there's no
separate "delete the whole holding" endpoint."""
__tablename__ = "crypto_holdings"
@@ -36,12 +44,43 @@ class CryptoHolding(Base, TimestampMixin):
name: Mapped[str] = mapped_column(String(150), nullable=False)
thumb_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Bitcoin has 8 decimal places; several tokens go to 18 (wei-level
# amounts). Deliberately NOT Numeric(14, 2) like every money field in
# this app — this is a coin count, not a currency amount.
quantity: Mapped[Numeric] = mapped_column(Numeric(38, 18), nullable=False)
# Cached from the last successful sync (services/crypto_service.py) —
# in the app's display currency (see AppSettings.currency). Null until
# the first sync completes.
last_price: Mapped[Numeric | None] = mapped_column(Numeric(38, 18), nullable=True)
price_change_1h: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True)
price_change_24h: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True)
price_change_7d: Mapped[Numeric | None] = mapped_column(Numeric(10, 4), nullable=True)
asset: Mapped["Asset"] = relationship()
transactions: Mapped[list["CryptoTransaction"]] = relationship(
back_populates="holding", cascade="all, delete-orphan", order_by="CryptoTransaction.date"
)
class CryptoTransaction(Base, TimestampMixin):
"""One buy or sell against a holding. Quantity held and average buy
price are both derived by replaying this log in date order — see
services/crypto_service.py's _compute_position for why a sell can't
just be "negative quantity" the way it can for a Goal contribution: it
must leave the remaining coins' average cost basis alone."""
__tablename__ = "crypto_transactions"
id: Mapped[int] = mapped_column(primary_key=True)
asset_id: Mapped[int] = mapped_column(ForeignKey("crypto_holdings.asset_id", ondelete="CASCADE"), nullable=False)
type: Mapped[CryptoTransactionType] = mapped_column(
Enum(CryptoTransactionType, name="crypto_transaction_type", native_enum=False, length=10), nullable=False
)
quantity: Mapped[Numeric] = mapped_column(Numeric(38, 18), nullable=False)
# Price paid (buy) or received (sell) per unit, in the app's display
# currency at the time of the transaction — not re-derived later, since
# what you actually paid doesn't change with today's market price.
price_per_unit: Mapped[Numeric] = mapped_column(Numeric(38, 18), nullable=False)
date: Mapped[date_] = mapped_column(Date, nullable=False)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
holding: Mapped["CryptoHolding"] = relationship(back_populates="transactions")
class CryptoSyncState(Base):

View File

@@ -51,6 +51,11 @@ class RecurringFrequency(str, enum.Enum):
YEARLY = "yearly"
class CryptoTransactionType(str, enum.Enum):
BUY = "buy"
SELL = "sell"
class RiskLevel(str, enum.Enum):
"""Risk of loss, not asset class — set by the user, not inferred: real
estate can be a paid-off primary home (LOW) or a leveraged rental

View File

@@ -10,6 +10,7 @@ from app.models.enums import (
AssetClass,
CapitalRole,
CategoryKind,
CryptoTransactionType,
RecurringFrequency,
RiskLevel,
TransactionType,
@@ -99,13 +100,30 @@ class CryptoHoldingBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
# asset_id doubles as this row's own primary key (see
# models/crypto.py's CryptoHolding) — there's no separate `id`.
# models/crypto.py's CryptoHolding) — there's no separate `id`. No
# quantity here — it's derived from crypto_transactions below, not
# stored.
asset_id: int
coingecko_id: str
symbol: str
name: str
thumb_url: str | None
last_price: Decimal | None = None
price_change_1h: Decimal | None = None
price_change_24h: Decimal | None = None
price_change_7d: Decimal | None = None
class CryptoTransactionBackup(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
asset_id: int
type: CryptoTransactionType
quantity: Decimal
price_per_unit: Decimal
date: date_
note: str | None
class BudgetBackup(BaseModel):
@@ -189,6 +207,7 @@ class BackupPayload(BaseModel):
# Defaulted so a backup exported before crypto holdings existed still
# imports cleanly under the same format version.
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
# cleanly under the same format version.
budgets: list[BudgetBackup] = Field(default_factory=list)

View File

@@ -1,39 +1,74 @@
from datetime import date as date_
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from app.models.enums import CryptoTransactionType
class CryptoTransactionCreate(BaseModel):
type: CryptoTransactionType
quantity: Decimal = Field(gt=0, max_digits=38, decimal_places=18)
# Price paid (buy) / received (sell) per unit, in the app's display
# currency — not fetched from CoinGecko, this is what the user actually
# paid, which the market price today has nothing to do with.
price_per_unit: Decimal = Field(gt=0, max_digits=38, decimal_places=18)
date: date_
note: str | None = Field(default=None, max_length=500)
class CryptoTransactionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
asset_id: int
type: CryptoTransactionType
quantity: Decimal
price_per_unit: Decimal
date: date_
note: str | None
class CryptoHoldingCreate(BaseModel):
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)
thumb_url: str | None = Field(default=None, max_length=500)
# 18 decimal places covers wei-level token amounts; still gt=0 since a
# zero/negative holding isn't a holding.
quantity: Decimal = Field(gt=0, max_digits=38, decimal_places=18)
class CryptoHoldingUpdate(BaseModel):
# A holding always starts with a buy — there's nothing to "start
# tracking" with a sell.
quantity: Decimal = Field(gt=0, max_digits=38, decimal_places=18)
price_per_unit: Decimal = Field(gt=0, max_digits=38, decimal_places=18)
date: date_ = Field(default_factory=date_.today)
note: str | None = Field(default=None, max_length=500)
class CryptoHoldingRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
asset_id: int
coingecko_id: str
symbol: str
name: str
thumb_url: str | None
# Quantity and avg_buy_price are derived from the transaction log (see
# services/crypto_service.py's _compute_position) — never stored.
quantity: Decimal
# Both derived from the linked Asset's latest AssetValuation — null
# value/unit_price means "added but never priced yet" (CoinGecko was
# unreachable at creation time), not zero.
avg_buy_price: Decimal | None
# Cached from the last successful CoinGecko sync — null until the
# first one completes.
current_price: Decimal | None
price_change_1h: Decimal | None
price_change_24h: Decimal | None
price_change_7d: Decimal | None
# All derived from the above: value = current_price * quantity,
# cost_basis = avg_buy_price * quantity, profit_loss = value - cost_basis.
value: Decimal | None
unit_price: Decimal | None
as_of_date: str | None
cost_basis: Decimal | None
profit_loss: Decimal | None
profit_loss_percent: float | None
class CryptoSyncResult(BaseModel):

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
from app.models.crypto import CryptoHolding, 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,
CryptoTransactionBackup,
GoalBackup,
GoalContributionBackup,
RecurringTransactionBackup,
@@ -54,6 +55,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
assets = (await session.execute(select(Asset))).scalars().all()
valuations = (await session.execute(select(AssetValuation))).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()
goals = (await session.execute(select(Goal))).scalars().all()
goal_contributions = (await session.execute(select(GoalContribution))).scalars().all()
@@ -76,6 +78,7 @@ async def build_backup(session: AsyncSession) -> BackupPayload:
assets=[AssetBackup.model_validate(row) for row in assets],
asset_valuations=[AssetValuationBackup.model_validate(row) for row in valuations],
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],
goals=[GoalBackup.model_validate(row) for row in goals],
goal_contributions=[GoalContributionBackup.model_validate(row) for row in goal_contributions],
@@ -111,10 +114,15 @@ 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_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}")
for tx in payload.crypto_transactions:
if tx.asset_id not in crypto_holding_asset_ids:
raise HTTPException(400, f"Crypto transaction {tx.id} references unknown asset_id {tx.asset_id}")
for b in payload.budgets:
if b.category_id not in category_ids:
raise HTTPException(400, f"Budget {b.id} references unknown category_id {b.category_id}")
@@ -162,6 +170,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
try:
# Children before parents.
await session.execute(delete(AssetValuation))
await session.execute(delete(CryptoTransaction))
await session.execute(delete(CryptoHolding))
await session.execute(delete(Budget))
await session.execute(delete(GoalContribution))
@@ -202,6 +211,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
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(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)
session.add_all(GoalContribution(**row.model_dump()) for row in payload.goal_contributions)
@@ -220,6 +230,7 @@ async def restore_backup(session: AsyncSession, payload: BackupPayload) -> None:
await _reset_sequence(session, "assets", payload.assets)
await _reset_sequence(session, "transactions", payload.transactions)
await _reset_sequence(session, "asset_valuations", payload.asset_valuations)
await _reset_sequence(session, "crypto_transactions", payload.crypto_transactions)
await _reset_sequence(session, "budgets", payload.budgets)
await _reset_sequence(session, "goals", payload.goals)
await _reset_sequence(session, "goal_contributions", payload.goal_contributions)

View File

@@ -1,23 +1,28 @@
"""Crypto price sync: CoinGecko Demo API calls, written into the *existing*
Asset/AssetValuation engine (see models/asset.py) rather than a parallel
value-tracking system. A CryptoHolding only ever records identity (which
coin) and quantity (how much) — the actual value is an ordinary
AssetValuation row, so Net Worth, reports, and everything else that already
walks Asset rows needs zero changes to treat a crypto holding like any
other manually tracked asset.
"""Crypto price sync + position accounting: CoinGecko Demo API calls,
written into the *existing* Asset/AssetValuation engine (see
models/asset.py) rather than a parallel value-tracking system. A holding's
value is an ordinary AssetValuation row, so Net Worth, reports, and
everything else that already walks Asset rows needs zero changes to treat a
crypto holding like any other manually tracked asset.
Quantity and average buy price are never stored — they're derived from the
CryptoTransaction log every time (see _compute_position), the same
"you record it, we derive it" shape as Goal/GoalContribution.
Refreshes are deliberately never automatic in the background — same
reasoning as recurring transactions (see services/recurring_service.py):
there's no scheduler/worker in this stack, and a rate-limited free API key
makes "poll constantly" actively counterproductive anyway. Two triggers
only: the "Refresh prices" button (force=True) and a lazy once-a-day check
that piggybacks on GET /crypto/holdings (force=False) — see
routes/crypto.py.
makes "poll constantly" actively counterproductive anyway. Three triggers
only: the "Refresh prices" button (force=True), a lazy once-a-day check
that piggybacks on GET /crypto/holdings (force=False), and adding a brand
new holding (a one-off seed fetch, justified by the explicit user action).
Editing quantity via a buy/sell transaction never calls CoinGecko — it
reuses the last cached price.
"""
from datetime import date as date_
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import Literal
from typing import Literal, NamedTuple
import httpx
from fastapi import HTTPException
@@ -28,9 +33,15 @@ 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
from app.models.enums import AssetClass, CapitalRole, RiskLevel
from app.schemas.crypto import CryptoHoldingCreate, CryptoHoldingRead, CryptoSearchResult, CryptoSyncResult
from app.models.crypto import CryptoHolding, CryptoSyncState, CryptoTransaction
from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel
from app.schemas.crypto import (
CryptoHoldingCreate,
CryptoHoldingRead,
CryptoSearchResult,
CryptoSyncResult,
CryptoTransactionCreate,
)
from app.services.settings_service import get_or_create_app_settings
COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
@@ -40,7 +51,7 @@ COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
# reason (see module docstring on why there's no background poller at all).
AUTO_REFRESH_INTERVAL = timedelta(hours=24)
_EAGER = (selectinload(CryptoHolding.asset).selectinload(Asset.valuations),)
_EAGER = (selectinload(CryptoHolding.transactions),)
def _require_api_key() -> str:
@@ -56,27 +67,51 @@ def _require_api_key() -> str:
return api_key
async def _fetch_prices_batch(coingecko_ids: list[str], vs_currency: str) -> dict[str, Decimal]:
"""One CoinGecko call for every tracked coin at once (up to 515 ids per
call) — never one call per coin, that's what would actually burn
through a free-tier rate limit."""
class _MarketPoint(NamedTuple):
price: Decimal
change_1h: Decimal | None
change_24h: Decimal | None
change_7d: Decimal | None
def _decimal_or_none(value: object) -> Decimal | None:
return Decimal(str(value)) if value is not None else None
async def _fetch_market_data(coingecko_ids: list[str], vs_currency: str) -> dict[str, _MarketPoint]:
"""One CoinGecko call for every tracked coin at once (up to 250 ids per
/coins/markets call) — never one call per coin, that's what would
actually burn through a free-tier rate limit. Also returns 1h/24h/7d %
change in the same call, so this fully replaces a separate
/simple/price lookup."""
if not coingecko_ids:
return {}
api_key = _require_api_key()
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{COINGECKO_BASE_URL}/simple/price",
params={"ids": ",".join(coingecko_ids), "vs_currencies": vs_currency},
f"{COINGECKO_BASE_URL}/coins/markets",
params={
"vs_currency": vs_currency,
"ids": ",".join(coingecko_ids),
"price_change_percentage": "1h,24h,7d",
},
headers={"x-cg-demo-api-key": api_key},
)
response.raise_for_status()
data = response.json()
prices: dict[str, Decimal] = {}
for coin_id, values in data.items():
price = values.get(vs_currency)
if price is not None:
prices[coin_id] = Decimal(str(price))
return prices
points: dict[str, _MarketPoint] = {}
for coin in data:
price = _decimal_or_none(coin.get("current_price"))
if price is None:
continue
points[coin["id"]] = _MarketPoint(
price=price,
change_1h=_decimal_or_none(coin.get("price_change_percentage_1h_in_currency")),
change_24h=_decimal_or_none(coin.get("price_change_percentage_24h_in_currency")),
change_7d=_decimal_or_none(coin.get("price_change_percentage_7d_in_currency")),
)
return points
async def search_coins(query: str) -> list[CryptoSearchResult]:
@@ -103,35 +138,67 @@ async def search_coins(query: str) -> list[CryptoSearchResult]:
]
def _compute_position(transactions: list[CryptoTransaction]) -> tuple[Decimal, Decimal | None]:
"""Weighted-average-cost method, replayed over the transaction log in
date order: a BUY blends into the running average cost; a SELL reduces
quantity but leaves the average cost of what's still held unchanged —
selling some coins doesn't retroactively change what you paid for the
ones you kept. Returns (quantity, avg_buy_price); avg_buy_price is None
once quantity hits zero (nothing left to have a cost basis)."""
quantity = Decimal("0")
avg_price: Decimal | None = None
for tx in sorted(transactions, key=lambda t: (t.date, t.id)):
if tx.type == CryptoTransactionType.BUY:
existing_cost = (avg_price or Decimal("0")) * quantity
quantity += tx.quantity
avg_price = (existing_cost + tx.price_per_unit * tx.quantity) / quantity if quantity else None
else:
quantity -= tx.quantity
if quantity <= 0:
quantity = Decimal("0")
avg_price = None
return quantity, avg_price
def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
latest = holding.asset.valuations[-1] if holding.asset.valuations else None
value = latest.value if latest else None
unit_price = (value / holding.quantity) if value is not None and holding.quantity else None
quantity, avg_buy_price = _compute_position(holding.transactions)
current_price = holding.last_price
value = current_price * quantity if current_price is not None else None
cost_basis = avg_buy_price * quantity if avg_buy_price is not None else None
profit_loss = (value - cost_basis) if value is not None and cost_basis is not None else None
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,
coingecko_id=holding.coingecko_id,
symbol=holding.symbol,
name=holding.name,
thumb_url=holding.thumb_url,
quantity=holding.quantity,
quantity=quantity,
avg_buy_price=avg_buy_price,
current_price=current_price,
price_change_1h=holding.price_change_1h,
price_change_24h=holding.price_change_24h,
price_change_7d=holding.price_change_7d,
value=value,
unit_price=unit_price,
as_of_date=latest.as_of_date.isoformat() if latest else None,
cost_basis=cost_basis,
profit_loss=profit_loss,
profit_loss_percent=profit_loss_percent,
)
async def list_holdings(session: AsyncSession) -> list[CryptoHolding]:
# populate_existing=True: _upsert_valuation() below writes AssetValuation
# rows via a raw Core statement, which the ORM's identity map doesn't
# know happened. Without this, a session that already loaded a holding's
# valuations once (list_holdings gets called both before and after a
# refresh's writes) would keep serving the pre-refresh value here.
result = await session.execute(
select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name).execution_options(populate_existing=True)
)
result = await session.execute(select(CryptoHolding).options(*_EAGER).order_by(CryptoHolding.name))
return list(result.scalars().all())
async def _get_holding_or_404(session: AsyncSession, asset_id: int) -> CryptoHolding:
result = await session.execute(select(CryptoHolding).options(*_EAGER).where(CryptoHolding.asset_id == asset_id))
holding = result.scalar_one_or_none()
if holding is None:
raise HTTPException(status_code=404, detail="Crypto holding not found")
return holding
async def get_or_create_sync_state(session: AsyncSession) -> CryptoSyncState:
state = await session.get(CryptoSyncState, 1)
if state is None:
@@ -145,7 +212,9 @@ async def get_or_create_sync_state(session: AsyncSession) -> CryptoSyncState:
async def _upsert_valuation(session: AsyncSession, asset_id: int, value: Decimal, as_of_date: date_) -> None:
"""Same upsert-by-date pattern as routes/assets.py's POST
/assets/{id}/valuations — re-syncing the same day updates that day's
value instead of erroring."""
value instead of erroring. Still needed even though quantity/price
aren't stored on CryptoHolding: Net Worth's whole engine reads this
table, not CryptoHolding, for a coin's value history."""
upsert_stmt = (
pg_insert(AssetValuation)
.values(asset_id=asset_id, value=value, as_of_date=as_of_date)
@@ -170,26 +239,34 @@ async def refresh_prices(session: AsyncSession, *, force: bool) -> CryptoSyncRes
if holdings:
settings = await get_or_create_app_settings(session)
try:
prices = await _fetch_prices_batch([h.coingecko_id for h in holdings], settings.currency.lower())
market_data = await _fetch_market_data([h.coingecko_id for h in holdings], settings.currency.lower())
except httpx.HTTPError:
# CoinGecko down/rate-limited — don't touch last_synced_at (the
# next open of the tab retries) and don't touch any existing
# AssetValuation. The tab still shows the last known values
# instead of an empty/broken page over an external outage.
# price/AssetValuation. The tab still shows the last known
# values instead of an empty/broken page over an external outage.
error_key = "unreachable"
else:
today = date_.today()
for holding in holdings:
price = prices.get(holding.coingecko_id)
if price is None:
continue # coin missing from the response — keep its last known value, don't zero it out
await _upsert_valuation(session, holding.asset_id, holding.quantity * price, today)
point = market_data.get(holding.coingecko_id)
if point is None:
continue # coin missing from the response — keep its last known values, don't zero them out
holding.last_price = point.price
holding.price_change_1h = point.change_1h
holding.price_change_24h = point.change_24h
holding.price_change_7d = point.change_7d
quantity, _ = _compute_position(holding.transactions)
if quantity > 0:
await _upsert_valuation(session, holding.asset_id, quantity * point.price, today)
if error_key is None:
state.last_synced_at = now
await session.commit()
holdings = await list_holdings(session) # re-fetch so the response carries whatever was just written
# 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.
return CryptoSyncResult(
synced=error_key is None,
last_synced_at=state.last_synced_at,
@@ -220,18 +297,33 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
symbol=payload.symbol.upper(),
name=payload.name,
thumb_url=payload.thumb_url,
quantity=payload.quantity,
)
session.add(holding)
# Seed today's value immediately — a coin the user just added sitting at
# 0 until tomorrow's auto-sync would be confusing, and this one call is
# clearly justified by an explicit user action.
holding.transactions.append(
CryptoTransaction(
type=CryptoTransactionType.BUY,
quantity=payload.quantity,
price_per_unit=payload.price_per_unit,
date=payload.date,
note=payload.note,
)
)
await session.flush()
# Seed today's live price immediately — a coin the user just added
# sitting at "no price yet" until tomorrow's auto-sync would be
# confusing, and this one call is clearly justified by an explicit
# user action.
try:
prices = await _fetch_prices_batch([payload.coingecko_id], settings.currency.lower())
price = prices.get(payload.coingecko_id)
if price is not None:
await _upsert_valuation(session, asset.id, payload.quantity * price, date_.today())
market_data = await _fetch_market_data([payload.coingecko_id], settings.currency.lower())
point = market_data.get(payload.coingecko_id)
if point is not None:
holding.last_price = point.price
holding.price_change_1h = point.change_1h
holding.price_change_24h = point.change_24h
holding.price_change_7d = point.change_7d
await _upsert_valuation(session, asset.id, payload.quantity * point.price, date_.today())
# This counts as a real sync — bump the shared timestamp so the next
# GET /crypto/holdings doesn't immediately re-fetch every holding
# again a moment later (see refresh_prices' 24h window).
@@ -241,35 +333,60 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
pass # holding is still created — the next daily/manual sync will price it
await session.commit()
result = await session.execute(select(CryptoHolding).options(*_EAGER).where(CryptoHolding.asset_id == asset.id))
return _to_read(result.scalar_one())
return _to_read(holding)
async def update_holding_quantity(session: AsyncSession, asset_id: int, quantity: Decimal) -> CryptoHoldingRead:
result = await session.execute(select(CryptoHolding).options(*_EAGER).where(CryptoHolding.asset_id == asset_id))
holding = result.scalar_one_or_none()
if holding is None:
raise HTTPException(status_code=404, detail="Crypto holding not found")
async def add_transaction(session: AsyncSession, asset_id: int, payload: CryptoTransactionCreate) -> CryptoHoldingRead:
"""A buy or sell against an existing holding — never calls CoinGecko
(see module docstring): value is recomputed from the last cached
price, same principle as the old quantity-only edit it replaces."""
holding = await _get_holding_or_404(session, asset_id)
latest = holding.asset.valuations[-1] if holding.asset.valuations else None
if latest is not None and holding.quantity:
# Recompute from the last known unit price — editing quantity must
# NOT call CoinGecko (see module docstring: only the button, adding
# a coin, and the once-a-day check are allowed to).
unit_price = latest.value / holding.quantity
await _upsert_valuation(session, asset_id, quantity * unit_price, date_.today())
if payload.type == CryptoTransactionType.SELL:
current_quantity, _ = _compute_position(holding.transactions)
if payload.quantity > current_quantity:
raise HTTPException(status_code=400, detail="Cannot sell more than you currently hold")
holding.quantity = quantity
await session.commit()
# populate_existing=True — see list_holdings()'s comment: the AssetValuation
# loaded above (before _upsert_valuation's raw Core write) is otherwise
# served stale here.
result = await session.execute(
select(CryptoHolding)
.options(*_EAGER)
.where(CryptoHolding.asset_id == asset_id)
.execution_options(populate_existing=True)
holding.transactions.append(
CryptoTransaction(
type=payload.type,
quantity=payload.quantity,
price_per_unit=payload.price_per_unit,
date=payload.date,
note=payload.note,
)
)
return _to_read(result.scalar_one())
await session.flush()
quantity, _ = _compute_position(holding.transactions)
if holding.last_price is not None:
await _upsert_valuation(session, asset_id, quantity * holding.last_price, date_.today())
await session.commit()
return _to_read(holding)
async def list_transactions(session: AsyncSession, asset_id: int) -> list[CryptoTransaction]:
await _get_holding_or_404(session, asset_id) # 404s if the holding itself doesn't exist
result = await session.execute(
select(CryptoTransaction)
.where(CryptoTransaction.asset_id == asset_id)
.order_by(CryptoTransaction.date.desc(), CryptoTransaction.id.desc())
)
return list(result.scalars().all())
async def delete_transaction(session: AsyncSession, transaction_id: int) -> None:
transaction = await session.get(CryptoTransaction, transaction_id)
if transaction is None:
raise HTTPException(status_code=404, detail="Crypto transaction not found")
asset_id = transaction.asset_id
await session.delete(transaction)
await session.flush()
holding = await _get_holding_or_404(session, asset_id)
quantity, _ = _compute_position(holding.transactions)
if holding.last_price is not None:
await _upsert_valuation(session, asset_id, quantity * holding.last_price, date_.today())
await session.commit()