feat: добавить уровень риска для крипто-холдингов

Риск (низкий/средний/высокий) задаётся при добавлении монеты и
редактируется прямо в таблице холдингов. Добавлена вкладка "Уровни
риска" в карточке портфеля с разбивкой % по риск-тирам, только для
крипты. Заодно поправлено округление процентов в риск-карточках.
This commit is contained in:
ZProger
2026-09-08 21:46:09 +03:00
parent e4b163df21
commit fcbd217915
13 changed files with 335 additions and 47 deletions

View File

@@ -5,7 +5,7 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from app.models.enums import CryptoTransactionType
from app.models.enums import CryptoTransactionType, RiskLevel
class CryptoTransactionCreate(BaseModel):
@@ -72,6 +72,10 @@ class CryptoHoldingCreate(BaseModel):
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)
# Defaults to HIGH — crypto is this app's textbook HIGH example (see
# 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
class CryptoHoldingRead(BaseModel):
@@ -81,6 +85,11 @@ class CryptoHoldingRead(BaseModel):
symbol: str
name: str
thumb_url: str | None
# Lives on the underlying Asset (see services/crypto_service.py's
# _to_read) — editable via the existing PATCH /assets/{asset_id},
# same endpoint every other Asset already uses, no crypto-specific
# update route needed.
risk_level: RiskLevel
# Quantity and avg_buy_price are derived from the transaction log (see
# services/crypto_service.py's _compute_position) — never stored.

View File

@@ -36,7 +36,7 @@ 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, CryptoPortfolio, CryptoSyncState, CryptoTransaction
from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType, RiskLevel
from app.models.enums import AssetClass, CapitalRole, CryptoTransactionType
from app.schemas.crypto import (
CryptoHistoryPoint,
CryptoHistoryResponse,
@@ -82,7 +82,7 @@ AUTO_REFRESH_INTERVAL = timedelta(hours=24)
# two points, not the smooth intraday line a denser data source could draw.
CRYPTO_RANGE_DAYS: dict[str, int] = {"7d": 7, "30d": 30, "90d": 90}
_EAGER = (selectinload(CryptoHolding.transactions),)
_EAGER = (selectinload(CryptoHolding.transactions), selectinload(CryptoHolding.asset))
def _require_api_key() -> str:
@@ -216,6 +216,7 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
symbol=holding.symbol,
name=holding.name,
thumb_url=holding.thumb_url,
risk_level=holding.asset.risk_level,
quantity=quantity,
avg_buy_price=avg_buy_price,
current_price=current_price,
@@ -424,10 +425,12 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
asset_class=AssetClass.CRYPTO,
currency=settings.currency,
capital_role=CapitalRole.NEUTRAL,
# Defaults to HIGH, not the Asset model's own MEDIUM default — matches
# this app's own risk-level copy, which names crypto as the textbook
# HIGH example (see lib/i18n.ts's netWorth.riskLevelFormHint.high).
risk_level=RiskLevel.HIGH,
# User-chosen at add time (see CryptoHoldingCreate), defaulting to
# HIGH — not the Asset model's own MEDIUM default — since this app's
# own risk-level copy names crypto as the textbook HIGH example (see
# lib/i18n.ts's netWorth.riskLevelFormHint.high). Editable later via
# PATCH /assets/{asset_id}, same as any other Asset.
risk_level=payload.risk_level,
)
session.add(asset)
await session.flush()
@@ -440,6 +443,11 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
name=payload.name,
thumb_url=payload.thumb_url,
)
# 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
# assignment raises MissingGreenlet, and _to_read (called at the end of
# this function) reads holding.asset.risk_level.
holding.asset = asset
session.add(holding)
holding.transactions.append(