feat: сеть (network) для крипто-холдингов

Свободное текстовое поле на холдинге — какой сети принадлежит конкретная
монета (Ethereum, Tron и т.д.), с редактированием прямо в таблице и
разбивкой % по сетям на отдельной вкладке. Помогает увидеть, что часть
стейблкоинов лежит на рискованных сетях и стоит перевести на безопасные.
This commit is contained in:
ZProger
2026-09-09 00:52:11 +03:00
parent 526bfe104d
commit c3047c6d6a
16 changed files with 421 additions and 13 deletions

View File

@@ -0,0 +1,26 @@
"""add network field to crypto holdings
Revision ID: d1a6f4c8b729
Revises: c4e8f61a9d23
Create Date: 2026-09-08 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd1a6f4c8b729'
down_revision: Union[str, None] = 'c4e8f61a9d23'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('crypto_holdings', sa.Column('network', sa.String(length=50), nullable=True))
def downgrade() -> None:
op.drop_column('crypto_holdings', 'network')

View File

@@ -6,6 +6,7 @@ from app.schemas.crypto import (
CryptoHistoryResponse,
CryptoHoldingCreate,
CryptoHoldingRead,
CryptoHoldingUpdate,
CryptoPerformanceResponse,
CryptoPortfolioCreate,
CryptoPortfolioRead,
@@ -29,6 +30,7 @@ from app.services.crypto_service import (
list_transactions,
refresh_prices,
search_coins,
update_holding,
update_portfolio,
update_transaction,
)
@@ -89,6 +91,15 @@ 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)
) -> CryptoHoldingRead:
"""Updates holding-only metadata (currently just `network`) — quantity/
price/date go through the /transactions routes below instead."""
return await update_holding(session, asset_id, payload)
@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)

View File

@@ -75,6 +75,14 @@ class CryptoHolding(Base, TimestampMixin):
symbol: Mapped[str] = mapped_column(String(20), nullable=False)
name: Mapped[str] = mapped_column(String(150), nullable=False)
thumb_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
# Free-form, user-entered — which chain this specific holding actually
# sits on (e.g. "Ethereum", "Tron", "Arbitrum"). Deliberately not an
# enum: CoinGecko doesn't expose per-holding chain data (the same
# coingecko_id like "tether" covers the token across every chain it's
# issued on), and new chains appear too often for a fixed list to keep
# up. Lets a stablecoin held across several networks be told apart by
# counterparty/bridge risk, which risk_level alone can't capture.
network: Mapped[str | None] = mapped_column(String(50), nullable=True)
# Cached from the last successful sync (services/crypto_service.py) —
# in the app's display currency (see AppSettings.currency). Null until

View File

@@ -76,6 +76,18 @@ class CryptoHoldingCreate(BaseModel):
# services/crypto_service.py) — but user-overridable at add time, since
# not every coin carries the same risk (a large-cap vs. a memecoin).
risk_level: RiskLevel = RiskLevel.HIGH
# Free-form chain name (e.g. "Ethereum", "Tron") — optional, not
# validated against a fixed list (see CryptoHolding.network).
network: str | None = Field(default=None, max_length=50)
class CryptoHoldingUpdate(BaseModel):
"""Holding-only metadata that isn't part of the buy/sell transaction
log — currently just `network`. Quantity/price/date changes go through
the /transactions routes instead (see add_transaction/update_transaction),
same split as risk_level living on the Asset via PATCH /assets/{id}."""
network: str | None = Field(default=None, max_length=50)
class CryptoHoldingRead(BaseModel):
@@ -90,6 +102,8 @@ class CryptoHoldingRead(BaseModel):
# same endpoint every other Asset already uses, no crypto-specific
# update route needed.
risk_level: RiskLevel
# Lives on CryptoHolding itself — editable via PATCH /crypto/holdings/{asset_id}.
network: str | None
# Quantity and avg_buy_price are derived from the transaction log (see
# services/crypto_service.py's _compute_position) — never stored.

View File

@@ -42,6 +42,7 @@ from app.schemas.crypto import (
CryptoHistoryResponse,
CryptoHoldingCreate,
CryptoHoldingRead,
CryptoHoldingUpdate,
CryptoPerformancePoint,
CryptoPerformanceResponse,
CryptoPortfolioCreate,
@@ -217,6 +218,7 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
name=holding.name,
thumb_url=holding.thumb_url,
risk_level=holding.asset.risk_level,
network=holding.network,
quantity=quantity,
avg_buy_price=avg_buy_price,
current_price=current_price,
@@ -442,6 +444,7 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
symbol=payload.symbol.upper(),
name=payload.name,
thumb_url=payload.thumb_url,
network=payload.network,
)
# Wired explicitly rather than left for a lazy load off asset_id — under
# async SQLAlchemy, a relationship access with no eager load and no prior
@@ -488,6 +491,17 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
return _to_read(holding)
async def update_holding(session: AsyncSession, asset_id: int, payload: CryptoHoldingUpdate) -> CryptoHoldingRead:
"""Updates CryptoHolding-only metadata — currently just `network`.
Quantity/price/date go through add_transaction/update_transaction
instead; risk_level lives on the Asset via PATCH /assets/{asset_id}."""
holding = await _get_holding_or_404(session, asset_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(holding, field, value)
await session.commit()
return _to_read(holding)
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