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 pydantic import BaseModel, ConfigDict, Field
from app.models.enums import CryptoTransactionType from app.models.enums import CryptoTransactionType, RiskLevel
class CryptoTransactionCreate(BaseModel): class CryptoTransactionCreate(BaseModel):
@@ -72,6 +72,10 @@ class CryptoHoldingCreate(BaseModel):
price_per_unit: 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) date: date_ = Field(default_factory=date_.today)
note: str | None = Field(default=None, max_length=500) 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): class CryptoHoldingRead(BaseModel):
@@ -81,6 +85,11 @@ class CryptoHoldingRead(BaseModel):
symbol: str symbol: str
name: str name: str
thumb_url: str | None 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 # Quantity and avg_buy_price are derived from the transaction log (see
# services/crypto_service.py's _compute_position) — never stored. # 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.core.config import get_settings
from app.models.asset import Asset, AssetValuation from app.models.asset import Asset, AssetValuation
from app.models.crypto import CryptoHolding, CryptoPortfolio, CryptoSyncState, CryptoTransaction 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 ( from app.schemas.crypto import (
CryptoHistoryPoint, CryptoHistoryPoint,
CryptoHistoryResponse, CryptoHistoryResponse,
@@ -82,7 +82,7 @@ AUTO_REFRESH_INTERVAL = timedelta(hours=24)
# two points, not the smooth intraday line a denser data source could draw. # 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} 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: def _require_api_key() -> str:
@@ -216,6 +216,7 @@ def _to_read(holding: CryptoHolding) -> CryptoHoldingRead:
symbol=holding.symbol, symbol=holding.symbol,
name=holding.name, name=holding.name,
thumb_url=holding.thumb_url, thumb_url=holding.thumb_url,
risk_level=holding.asset.risk_level,
quantity=quantity, quantity=quantity,
avg_buy_price=avg_buy_price, avg_buy_price=avg_buy_price,
current_price=current_price, current_price=current_price,
@@ -424,10 +425,12 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
asset_class=AssetClass.CRYPTO, asset_class=AssetClass.CRYPTO,
currency=settings.currency, currency=settings.currency,
capital_role=CapitalRole.NEUTRAL, capital_role=CapitalRole.NEUTRAL,
# Defaults to HIGH, not the Asset model's own MEDIUM default — matches # User-chosen at add time (see CryptoHoldingCreate), defaulting to
# this app's own risk-level copy, which names crypto as the textbook # HIGH — not the Asset model's own MEDIUM default — since this app's
# HIGH example (see lib/i18n.ts's netWorth.riskLevelFormHint.high). # own risk-level copy names crypto as the textbook HIGH example (see
risk_level=RiskLevel.HIGH, # 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) session.add(asset)
await session.flush() await session.flush()
@@ -440,6 +443,11 @@ async def create_holding(session: AsyncSession, payload: CryptoHoldingCreate) ->
name=payload.name, name=payload.name,
thumb_url=payload.thumb_url, 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) session.add(holding)
holding.transactions.append( holding.transactions.append(

View File

@@ -11,6 +11,7 @@ import type {
CryptoSyncResult, CryptoSyncResult,
CryptoTransaction, CryptoTransaction,
CryptoTransactionInput, CryptoTransactionInput,
RiskLevel,
} from "@/types"; } from "@/types";
export function fetchCryptoPortfolios(includeArchived = false) { export function fetchCryptoPortfolios(includeArchived = false) {
@@ -71,6 +72,12 @@ export function deleteCryptoHolding(assetId: number) {
return api.delete<void>(`/assets/${assetId}`); return api.delete<void>(`/assets/${assetId}`);
} }
// Risk level lives on the underlying Asset, not CryptoHolding — reuses the
// generic Asset PATCH endpoint, same pattern as deleteCryptoHolding above.
export function updateCryptoHoldingRisk(assetId: number, riskLevel: RiskLevel) {
return api.patch<void>(`/assets/${assetId}`, { risk_level: riskLevel });
}
export function searchCryptoCoins(query: string) { export function searchCryptoCoins(query: string) {
return api.get<CryptoSearchResult[]>(`/crypto/search?q=${encodeURIComponent(query)}`); return api.get<CryptoSearchResult[]>(`/crypto/search?q=${encodeURIComponent(query)}`);
} }

View File

@@ -5,8 +5,10 @@ import { Button } from "@/components/ui/Button";
import { Dialog } from "@/components/ui/Dialog"; import { Dialog } from "@/components/ui/Dialog";
import { Input, Label, Select } from "@/components/ui/Input"; import { Input, Label, Select } from "@/components/ui/Input";
import { useCreateCryptoHolding, useCryptoPortfolios } from "@/hooks/useCrypto"; import { useCreateCryptoHolding, useCryptoPortfolios } from "@/hooks/useCrypto";
import { useTranslation } from "@/lib/i18n"; import { useTranslation, type TranslationKey } from "@/lib/i18n";
import type { CryptoSearchResult } from "@/types"; import type { CryptoSearchResult, RiskLevel } from "@/types";
const RISK_LEVELS: RiskLevel[] = ["low", "medium", "high"];
interface CryptoAddModalProps { interface CryptoAddModalProps {
open: boolean; open: boolean;
@@ -34,6 +36,9 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
const [quantity, setQuantity] = useState(""); const [quantity, setQuantity] = useState("");
const [pricePerUnit, setPricePerUnit] = useState(""); const [pricePerUnit, setPricePerUnit] = useState("");
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10)); const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10));
// Defaults to "high", same as the backend default — crypto is this app's
// own textbook HIGH risk example — but overridable per coin here.
const [riskLevel, setRiskLevel] = useState<RiskLevel>("high");
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -44,6 +49,7 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
setQuantity(""); setQuantity("");
setPricePerUnit(""); setPricePerUnit("");
setDate(new Date().toISOString().slice(0, 10)); setDate(new Date().toISOString().slice(0, 10));
setRiskLevel("high");
setSearchError(null); setSearchError(null);
setSaveError(null); setSaveError(null);
}, [open]); }, [open]);
@@ -99,6 +105,7 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
quantity, quantity,
price_per_unit: pricePerUnit, price_per_unit: pricePerUnit,
date, date,
risk_level: riskLevel,
}); });
onClose(); onClose();
} catch { } catch {
@@ -220,6 +227,21 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
/> />
</div> </div>
<div>
<Label htmlFor="crypto-risk">{t("netWorth.form.riskLevelLabel")}</Label>
<Select
id="crypto-risk"
value={riskLevel}
onChange={(event) => setRiskLevel(event.target.value as RiskLevel)}
>
{RISK_LEVELS.map((level) => (
<option key={level} value={level}>
{t(`netWorth.riskLevel.${level}` as TranslationKey)}
</option>
))}
</Select>
</div>
{saveError && <p className="text-sm text-danger">{saveError}</p>} {saveError && <p className="text-sm text-danger">{saveError}</p>}
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">

View File

@@ -1,7 +1,8 @@
import { Bitcoin, Plus, Trash2 } from "lucide-react"; import { Bitcoin, Plus, Trash2 } from "lucide-react";
import { RiskLevelPicker } from "@/components/crypto/RiskLevelPicker";
import { formatCryptoAmount, maskAmount } from "@/lib/format"; import { formatCryptoAmount, maskAmount } from "@/lib/format";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding, CryptoPortfolio } from "@/types"; import type { CryptoHolding, CryptoPortfolio, RiskLevel } from "@/types";
interface CryptoHoldingsTableProps { interface CryptoHoldingsTableProps {
items: CryptoHolding[]; items: CryptoHolding[];
@@ -9,6 +10,7 @@ interface CryptoHoldingsTableProps {
onTrade: (holding: CryptoHolding) => void; onTrade: (holding: CryptoHolding) => void;
onViewHistory: (holding: CryptoHolding) => void; onViewHistory: (holding: CryptoHolding) => void;
onDelete: (holding: CryptoHolding) => void; onDelete: (holding: CryptoHolding) => void;
onRiskChange: (holding: CryptoHolding, riskLevel: RiskLevel) => void;
// Only passed while viewing the "All" tab — renders each row's portfolio // Only passed while viewing the "All" tab — renders each row's portfolio
// as a small colored badge under the coin name so it's still clear which // as a small colored badge under the coin name so it's still clear which
// portfolio it belongs to. Omitted while a single portfolio is selected, // portfolio it belongs to. Omitted while a single portfolio is selected,
@@ -34,6 +36,7 @@ export function CryptoHoldingsTable({
onTrade, onTrade,
onViewHistory, onViewHistory,
onDelete, onDelete,
onRiskChange,
portfoliosById, portfoliosById,
}: CryptoHoldingsTableProps) { }: CryptoHoldingsTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -44,18 +47,19 @@ export function CryptoHoldingsTable({
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[860px] text-sm"> <table className="w-full min-w-[880px] text-sm">
<thead> <thead>
<tr className="border-b border-border text-left text-xs text-text-muted"> <tr className="border-b border-border text-left text-xs text-text-muted">
<th className="py-2 pr-3 font-medium">{t("crypto.table.name")}</th> <th className="py-2.5 pr-4 font-medium">{t("crypto.table.name")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.price")}</th> <th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.price")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.change1h")}</th> <th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.change1h")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.change24h")}</th> <th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.change24h")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.change7d")}</th> <th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.change7d")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.holdings")}</th> <th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.holdings")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.avgBuyPrice")}</th> <th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.avgBuyPrice")}</th>
<th className="py-2 pr-3 text-right font-medium">{t("crypto.table.profitLoss")}</th> <th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.profitLoss")}</th>
<th className="py-2 pl-3 text-right font-medium">{t("crypto.table.actions")}</th> <th className="whitespace-nowrap py-2.5 pr-4 text-right font-medium">{t("crypto.table.risk")}</th>
<th className="whitespace-nowrap py-2.5 pl-4 text-right font-medium">{t("crypto.table.actions")}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gridline"> <tbody className="divide-y divide-gridline">
@@ -76,7 +80,7 @@ export function CryptoHoldingsTable({
onClick={() => onViewHistory(holding)} onClick={() => onViewHistory(holding)}
className="cursor-pointer hover:bg-surface-2" className="cursor-pointer hover:bg-surface-2"
> >
<td className="py-3 pr-3"> <td className="py-3.5 pr-4 align-middle">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
{holding.thumb_url ? ( {holding.thumb_url ? (
<img src={holding.thumb_url} alt="" className="h-7 w-7 shrink-0 rounded-full" /> <img src={holding.thumb_url} alt="" className="h-7 w-7 shrink-0 rounded-full" />
@@ -86,11 +90,16 @@ export function CryptoHoldingsTable({
</span> </span>
)} )}
<span className="min-w-0"> <span className="min-w-0">
<span className="block truncate text-sm font-medium text-text-primary">{holding.name}</span> <span className="block truncate text-sm font-medium text-text-primary" title={holding.name}>
<span className="flex items-center gap-1.5 text-xs text-text-muted"> {holding.name}
{holding.symbol} </span>
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-text-muted">
<span className="shrink-0">{holding.symbol}</span>
{portfoliosById && ( {portfoliosById && (
<span className="flex items-center gap-1 truncate"> <span
className="flex min-w-0 items-center gap-1"
title={portfoliosById.get(holding.portfolio_id)?.name}
>
<span <span
className="h-1.5 w-1.5 shrink-0 rounded-full" className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ style={{
@@ -104,38 +113,38 @@ export function CryptoHoldingsTable({
</span> </span>
</div> </div>
</td> </td>
<td className="py-3 pr-3 text-right tabular-nums text-text-primary"> <td className="py-3.5 pr-4 text-right align-middle tabular-nums text-text-primary">
{holding.current_price !== null {holding.current_price !== null
? maskAmount(formatCryptoAmount(holding.current_price), hidden) ? maskAmount(formatCryptoAmount(holding.current_price), hidden)
: t("crypto.pendingPrice")} : t("crypto.pendingPrice")}
</td> </td>
<td className="py-3 pr-3 text-right"> <td className="py-3.5 pr-4 text-right align-middle">
<PercentCell value={holding.price_change_1h} /> <PercentCell value={holding.price_change_1h} />
</td> </td>
<td className="py-3 pr-3 text-right"> <td className="py-3.5 pr-4 text-right align-middle">
<PercentCell value={holding.price_change_24h} /> <PercentCell value={holding.price_change_24h} />
</td> </td>
<td className="py-3 pr-3 text-right"> <td className="py-3.5 pr-4 text-right align-middle">
<PercentCell value={holding.price_change_7d} /> <PercentCell value={holding.price_change_7d} />
</td> </td>
<td className="py-3 pr-3 text-right"> <td className="py-3.5 pr-4 text-right align-middle">
<span className="block tabular-nums text-text-primary"> <span className="block tabular-nums text-text-primary">
{holding.value !== null ? maskAmount(formatCryptoAmount(holding.value), hidden) : t("crypto.pendingPrice")} {holding.value !== null ? maskAmount(formatCryptoAmount(holding.value), hidden) : t("crypto.pendingPrice")}
</span> </span>
<span className="block text-xs tabular-nums text-text-muted"> <span className="mt-0.5 block text-xs tabular-nums text-text-muted">
{maskAmount(`${Number(holding.quantity)} ${holding.symbol}`, hidden)} {maskAmount(`${Number(holding.quantity)} ${holding.symbol}`, hidden)}
</span> </span>
</td> </td>
<td className="py-3 pr-3 text-right tabular-nums text-text-primary"> <td className="py-3.5 pr-4 text-right align-middle tabular-nums text-text-primary">
{holding.avg_buy_price !== null ? maskAmount(formatCryptoAmount(holding.avg_buy_price), hidden) : "—"} {holding.avg_buy_price !== null ? maskAmount(formatCryptoAmount(holding.avg_buy_price), hidden) : "—"}
</td> </td>
<td className="py-3 pr-3 text-right"> <td className="py-3.5 pr-4 text-right align-middle">
{holding.profit_loss !== null && holding.profit_loss_percent !== null ? ( {holding.profit_loss !== null && holding.profit_loss_percent !== null ? (
<> <>
<span className="block tabular-nums" style={{ color: profitColor }}> <span className="block tabular-nums" style={{ color: profitColor }}>
{maskAmount(`${sign}${formatCryptoAmount(holding.profit_loss)}`, hidden)} {maskAmount(`${sign}${formatCryptoAmount(holding.profit_loss)}`, hidden)}
</span> </span>
<span className="block text-xs tabular-nums" style={{ color: profitColor }}> <span className="mt-0.5 block text-xs tabular-nums" style={{ color: profitColor }}>
{sign} {sign}
{holding.profit_loss_percent.toFixed(2)}% {holding.profit_loss_percent.toFixed(2)}%
</span> </span>
@@ -144,7 +153,12 @@ export function CryptoHoldingsTable({
<span className="text-text-muted">—</span> <span className="text-text-muted">—</span>
)} )}
</td> </td>
<td className="py-3 pl-3"> <td className="py-3.5 pr-4 align-middle" onClick={(event) => event.stopPropagation()}>
<span className="flex justify-end">
<RiskLevelPicker value={holding.risk_level} onChange={(level) => onRiskChange(holding, level)} />
</span>
</td>
<td className="py-3.5 pl-4 align-middle">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
<button <button
type="button" type="button"

View File

@@ -4,12 +4,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { CryptoAllocationBody } from "@/components/crypto/CryptoAllocationBody"; import { CryptoAllocationBody } from "@/components/crypto/CryptoAllocationBody";
import { CryptoHistoryChartBody } from "@/components/crypto/CryptoHistoryChartBody"; import { CryptoHistoryChartBody } from "@/components/crypto/CryptoHistoryChartBody";
import { CryptoRangeSelector } from "@/components/crypto/CryptoRangeSelector"; import { CryptoRangeSelector } from "@/components/crypto/CryptoRangeSelector";
import { CryptoRiskAllocationBody } from "@/components/crypto/CryptoRiskAllocationBody";
import { PillSelector } from "@/components/layout/PillSelector"; import { PillSelector } from "@/components/layout/PillSelector";
import { formatCurrency, formatSignedCurrency, maskAmount } from "@/lib/format"; import { formatCurrency, formatSignedCurrency, maskAmount } from "@/lib/format";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import type { CryptoHistoryResponse, CryptoHolding, CryptoRange } from "@/types"; import type { CryptoHistoryResponse, CryptoHolding, CryptoRange } from "@/types";
type OverviewTab = "history" | "allocation"; type OverviewTab = "history" | "allocation" | "risk";
interface CryptoOverviewCardProps { interface CryptoOverviewCardProps {
totalValue: number; totalValue: number;
@@ -23,10 +24,11 @@ interface CryptoOverviewCardProps {
onToggleHidden: () => void; onToggleHidden: () => void;
} }
/** One card, two tabs — "History" (the value-over-time chart) and /** One card, three tabs — "History" (the value-over-time chart),
* "Allocation" (a donut broken down by coin), same CoinMarketCap layout the * "Allocation" (a donut broken down by coin), and "Risk levels" (% of the
* user asked for: switching views instead of stacking two separate cards, * portfolio in each RiskLevel tier), same CoinMarketCap-style layout the
* so the tab keeps its footprint on the page instead of doubling it. The * user asked for: switching views instead of stacking separate cards,
* so the tab keeps its footprint on the page instead of growing it. The
* total/eye toggle/change line stay in the shared header regardless of * total/eye toggle/change line stay in the shared header regardless of
* which tab is active — only the content panel below switches. */ * which tab is active — only the content panel below switches. */
export function CryptoOverviewCard({ export function CryptoOverviewCard({
@@ -77,6 +79,7 @@ export function CryptoOverviewCard({
options={[ options={[
{ value: "history" as const, label: t("crypto.overview.history") }, { value: "history" as const, label: t("crypto.overview.history") },
{ value: "allocation" as const, label: t("crypto.overview.allocation") }, { value: "allocation" as const, label: t("crypto.overview.allocation") },
{ value: "risk" as const, label: t("crypto.overview.risk") },
]} ]}
value={tab} value={tab}
onChange={setTab} onChange={setTab}
@@ -87,8 +90,10 @@ export function CryptoOverviewCard({
<CardContent> <CardContent>
{tab === "history" ? ( {tab === "history" ? (
<CryptoHistoryChartBody history={history} isLoading={isHistoryLoading} hidden={hidden} /> <CryptoHistoryChartBody history={history} isLoading={isHistoryLoading} hidden={hidden} />
) : ( ) : tab === "allocation" ? (
<CryptoAllocationBody holdings={holdings} isLoading={isHoldingsLoading} hidden={hidden} /> <CryptoAllocationBody holdings={holdings} isLoading={isHoldingsLoading} hidden={hidden} />
) : (
<CryptoRiskAllocationBody holdings={holdings} isLoading={isHoldingsLoading} hidden={hidden} />
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -0,0 +1,97 @@
import { formatCurrency, maskAmount } from "@/lib/format";
import { useTranslation, type TranslationKey } from "@/lib/i18n";
import type { CryptoHolding, RiskLevel } from "@/types";
interface CryptoRiskAllocationBodyProps {
holdings: CryptoHolding[];
isLoading: boolean;
hidden: boolean;
}
// Same status-color convention as NetWorthPage's own RiskAllocationCard
// (low reads as "good", high as "critical") — not a categorical hue, so
// hardcoded here rather than threaded through from the backend.
const TIER_COLOR: Record<RiskLevel, string> = {
low: "var(--success)",
medium: "var(--text-muted)",
high: "var(--danger)",
};
const RISK_LEVELS: RiskLevel[] = ["low", "medium", "high"];
/** The "Risk levels" tab's content inside CryptoOverviewCard — same
* three-tier layout as NetWorthPage's own RiskAllocationCard, but scoped to
* crypto holdings only (not the whole net worth), so "am I overweight on
* memecoins" doesn't get diluted by cash/real estate/etc. Entirely
* client-side: every holding already carries value + risk_level from
* GET /crypto/holdings, no separate endpoint needed. */
export function CryptoRiskAllocationBody({ holdings, isLoading, hidden }: CryptoRiskAllocationBodyProps) {
const { t } = useTranslation();
if (isLoading) {
return <p className="py-10 text-center text-sm text-text-muted">{t("common.loading")}</p>;
}
if (holdings.length === 0) {
return <p className="py-10 text-center text-sm text-text-muted">{t("crypto.empty")}</p>;
}
// Unpriced holdings (CoinGecko never successfully priced them yet) can't
// contribute a % of portfolio value — same "best data on hand" principle
// the rest of the Crypto tab already follows for a null value/price.
const priced = holdings.filter((h) => h.value !== null);
const totalValue = priced.reduce((sum, h) => sum + Number(h.value), 0);
const tiers = RISK_LEVELS.map((level) => {
const items = priced
.filter((h) => h.risk_level === level)
.map((h) => ({ symbol: h.symbol, name: h.name, value: Number(h.value) }))
.sort((a, b) => b.value - a.value);
const tierValue = items.reduce((sum, item) => sum + item.value, 0);
return {
level,
value: tierValue,
percent: totalValue > 0 ? (tierValue / totalValue) * 100 : 0,
items: items.map((item) => ({
...item,
percent: tierValue > 0 ? (item.value / tierValue) * 100 : 0,
})),
};
});
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{tiers.map((tier) => (
<div key={tier.level} className="rounded-lg border border-border p-3.5">
<p className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-text-muted">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: TIER_COLOR[tier.level] }} />
{t(`netWorth.riskLevel.${tier.level}` as TranslationKey)}
</p>
<p className="mt-1.5 text-xl font-semibold tabular-nums text-text-primary">
{maskAmount(formatCurrency(tier.value), hidden)}
</p>
<p className="mt-1 text-xs text-text-muted">
{tier.percent.toFixed(1)}% {t("crypto.riskAllocationOfPortfolio")}
</p>
{tier.items.length > 0 && (
<ul className="mt-3 space-y-1.5 border-t border-gridline pt-3">
{tier.items.map((item) => (
<li key={item.symbol} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-xs text-text-secondary">{item.symbol}</span>
<span className="h-1 w-10 shrink-0 overflow-hidden rounded-full bg-surface-2">
<span
className="block h-full rounded-full"
style={{ width: `${item.percent}%`, backgroundColor: TIER_COLOR[tier.level] }}
/>
</span>
<span className="w-8 shrink-0 text-right text-[11px] tabular-nums text-text-muted">
{item.percent.toFixed(1)}%
</span>
</li>
))}
</ul>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation, type TranslationKey } from "@/lib/i18n";
import type { RiskLevel } from "@/types";
const RISK_LEVELS: RiskLevel[] = ["low", "medium", "high"];
// Traffic-light metaphor: green/yellow/red, not this app's usual gray-for-
// medium risk convention (see CryptoRiskAllocationBody) — picked for a
// picker that needs to read at a glance. --series-4 is the dataviz skill's
// validated yellow slot.
const RISK_COLOR: Record<RiskLevel, string> = {
low: "var(--success)",
medium: "var(--series-4)",
high: "var(--danger)",
};
// How many of the 3 bars are filled for each level — low is "a little
// risk", not "no risk", so it still lights up one bar rather than none.
const FILLED_BARS: Record<RiskLevel, number> = { low: 1, medium: 2, high: 3 };
const BAR_HEIGHTS = ["h-1.5", "h-2.5", "h-3.5"];
/** Three ascending bars, signal-strength style — same shape Linear uses for
* its Priority column. Filled bars are colored per `level`; the rest sit at
* a flat muted height so the icon always reads as "N of 3", not just a
* color. `size` scales the whole icon (picker trigger vs. menu options). */
function RiskBars({ level, size = 1 }: { level: RiskLevel; size?: number }) {
const filled = FILLED_BARS[level];
return (
<span className="flex items-end gap-0.5" style={{ transform: `scale(${size})` }}>
{BAR_HEIGHTS.map((height, index) => (
<span
key={index}
className={`w-1 rounded-sm ${height}`}
style={{ backgroundColor: index < filled ? RISK_COLOR[level] : "var(--border)" }}
/>
))}
</span>
);
}
interface RiskLevelPickerProps {
value: RiskLevel;
onChange: (level: RiskLevel) => void;
}
/** The Crypto holdings table's risk-level control: a signal-bar icon (1-3
* bars lit, colored green/yellow/red) showing the current level — click it
* to open a compact menu of all three and pick one. Encodes both color and
* magnitude, so it reads at a glance without relying on color alone. */
export function RiskLevelPicker({ value, onChange }: RiskLevelPickerProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (!open) return;
function handleOutsideClick(event: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, [open]);
return (
<span ref={rootRef} className="relative inline-block">
<button
type="button"
aria-label={t(`netWorth.riskLevel.${value}` as TranslationKey)}
title={t(`netWorth.riskLevel.${value}` as TranslationKey)}
onClick={() => setOpen((prev) => !prev)}
className="flex items-center rounded p-1 hover:bg-surface-2"
>
<RiskBars level={value} />
</button>
{open && (
<span className="absolute right-0 top-full z-10 mt-1 flex flex-col gap-0.5 rounded-lg border border-border bg-surface-1 p-1 shadow-md">
{RISK_LEVELS.map((level) => (
<button
key={level}
type="button"
onClick={() => {
onChange(level);
setOpen(false);
}}
className={`flex items-center gap-2 whitespace-nowrap rounded-md px-2 py-1.5 text-left text-xs ${
level === value ? "bg-surface-2 font-medium text-text-primary" : "text-text-secondary hover:bg-surface-2"
}`}
>
<RiskBars level={level} />
{t(`netWorth.riskLevel.${level}` as TranslationKey)}
</button>
))}
</span>
)}
</span>
);
}

View File

@@ -30,7 +30,7 @@ export function RiskAllocationCard({ riskLevels, isLoading }: RiskAllocationCard
<p className="mt-1.5 text-xl font-semibold tabular-nums text-text-primary"> <p className="mt-1.5 text-xl font-semibold tabular-nums text-text-primary">
{formatCurrency(tier.total_value)} {formatCurrency(tier.total_value)}
</p> </p>
<p className="mt-1 text-xs text-text-muted">{tier.percent.toFixed(0)}% {t("netWorth.riskAllocationOfCapital")}</p> <p className="mt-1 text-xs text-text-muted">{tier.percent.toFixed(1)}% {t("netWorth.riskAllocationOfCapital")}</p>
{tier.items.length > 0 && ( {tier.items.length > 0 && (
<ul className="mt-3 space-y-1.5 border-t border-gridline pt-3"> <ul className="mt-3 space-y-1.5 border-t border-gridline pt-3">
@@ -46,7 +46,7 @@ export function RiskAllocationCard({ riskLevels, isLoading }: RiskAllocationCard
/> />
</span> </span>
<span className="w-8 shrink-0 text-right text-[11px] tabular-nums text-text-muted"> <span className="w-8 shrink-0 text-right text-[11px] tabular-nums text-text-muted">
{item.percent.toFixed(0)}% {item.percent.toFixed(1)}%
</span> </span>
</li> </li>
))} ))}

View File

@@ -12,10 +12,11 @@ import {
fetchCryptoPortfolios, fetchCryptoPortfolios,
fetchCryptoTransactions, fetchCryptoTransactions,
refreshCryptoPrices, refreshCryptoPrices,
updateCryptoHoldingRisk,
updateCryptoPortfolio, updateCryptoPortfolio,
updateCryptoTransaction, updateCryptoTransaction,
} from "@/api/crypto"; } from "@/api/crypto";
import type { CryptoHoldingCreateInput, CryptoPortfolioInput, CryptoRange, CryptoTransactionInput } from "@/types"; import type { CryptoHoldingCreateInput, CryptoPortfolioInput, CryptoRange, CryptoTransactionInput, RiskLevel } from "@/types";
function useInvalidateCrypto() { function useInvalidateCrypto() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -143,3 +144,12 @@ export function useDeleteCryptoHolding() {
onSuccess: invalidate, onSuccess: invalidate,
}); });
} }
export function useUpdateCryptoHoldingRisk() {
const invalidate = useInvalidateCrypto();
return useMutation({
mutationFn: ({ assetId, riskLevel }: { assetId: number; riskLevel: RiskLevel }) =>
updateCryptoHoldingRisk(assetId, riskLevel),
onSuccess: invalidate,
});
}

View File

@@ -142,9 +142,11 @@ const ru = {
"crypto.table.change24h": "24ч%", "crypto.table.change24h": "24ч%",
"crypto.table.change7d": "7д%", "crypto.table.change7d": "7д%",
"crypto.table.holdings": "Держите", "crypto.table.holdings": "Держите",
"crypto.table.avgBuyPrice": "Средняя цена покупки", "crypto.table.avgBuyPrice": "Ср. цена",
"crypto.table.profitLoss": "Прибыль/убыток", "crypto.table.profitLoss": "Прибыль/убыток",
"crypto.table.risk": "Риск",
"crypto.table.actions": "Действия", "crypto.table.actions": "Действия",
"crypto.riskAllocationOfPortfolio": "от крипто-портфеля",
"crypto.history.title": "История сделок: {{name}}", "crypto.history.title": "История сделок: {{name}}",
"crypto.history.empty": "Пока нет ни одной сделки.", "crypto.history.empty": "Пока нет ни одной сделки.",
"crypto.history.confirmDelete": "Удалить эту сделку? Количество и средняя цена будут пересчитаны.", "crypto.history.confirmDelete": "Удалить эту сделку? Количество и средняя цена будут пересчитаны.",
@@ -155,6 +157,7 @@ const ru = {
"crypto.allocation.title": "Распределение по монетам", "crypto.allocation.title": "Распределение по монетам",
"crypto.overview.history": "История", "crypto.overview.history": "История",
"crypto.overview.allocation": "Аллокация", "crypto.overview.allocation": "Аллокация",
"crypto.overview.risk": "Уровни риска",
"crypto.allocation.other": "Другое", "crypto.allocation.other": "Другое",
"crypto.stats.allTimeProfit": "Прибыль за всё время", "crypto.stats.allTimeProfit": "Прибыль за всё время",
"crypto.stats.costBasis": "Вложено всего", "crypto.stats.costBasis": "Вложено всего",
@@ -637,9 +640,11 @@ const en: Record<keyof typeof ru, string> = {
"crypto.table.change24h": "24h%", "crypto.table.change24h": "24h%",
"crypto.table.change7d": "7d%", "crypto.table.change7d": "7d%",
"crypto.table.holdings": "Holdings", "crypto.table.holdings": "Holdings",
"crypto.table.avgBuyPrice": "Avg. Buy Price", "crypto.table.avgBuyPrice": "Avg. Price",
"crypto.table.profitLoss": "Profit/Loss", "crypto.table.profitLoss": "Profit/Loss",
"crypto.table.risk": "Risk",
"crypto.table.actions": "Actions", "crypto.table.actions": "Actions",
"crypto.riskAllocationOfPortfolio": "of crypto portfolio",
"crypto.history.title": "Trade history: {{name}}", "crypto.history.title": "Trade history: {{name}}",
"crypto.history.empty": "No trades yet.", "crypto.history.empty": "No trades yet.",
"crypto.history.confirmDelete": "Delete this trade? Quantity and average price will be recalculated.", "crypto.history.confirmDelete": "Delete this trade? Quantity and average price will be recalculated.",
@@ -650,6 +655,7 @@ const en: Record<keyof typeof ru, string> = {
"crypto.allocation.title": "Allocation by coin", "crypto.allocation.title": "Allocation by coin",
"crypto.overview.history": "History", "crypto.overview.history": "History",
"crypto.overview.allocation": "Allocation", "crypto.overview.allocation": "Allocation",
"crypto.overview.risk": "Risk levels",
"crypto.allocation.other": "Other", "crypto.allocation.other": "Other",
"crypto.stats.allTimeProfit": "All-time profit", "crypto.stats.allTimeProfit": "All-time profit",
"crypto.stats.costBasis": "Total invested", "crypto.stats.costBasis": "Total invested",

View File

@@ -17,10 +17,11 @@ import {
useCryptoPortfolios, useCryptoPortfolios,
useDeleteCryptoHolding, useDeleteCryptoHolding,
useRefreshCryptoPrices, useRefreshCryptoPrices,
useUpdateCryptoHoldingRisk,
} from "@/hooks/useCrypto"; } from "@/hooks/useCrypto";
import { getIntlLocale } from "@/lib/format"; import { getIntlLocale } from "@/lib/format";
import { useTranslation } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import type { CryptoHolding, CryptoPortfolio, CryptoRange, CryptoTransaction } from "@/types"; import type { CryptoHolding, CryptoPortfolio, CryptoRange, CryptoTransaction, RiskLevel } from "@/types";
function formatSyncedAt(iso: string): string { function formatSyncedAt(iso: string): string {
return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso)); return new Intl.DateTimeFormat(getIntlLocale(), { dateStyle: "medium", timeStyle: "short" }).format(new Date(iso));
@@ -44,6 +45,7 @@ export function CryptoPage() {
const { data: performance90d, isLoading: isPerformance90dLoading } = useCrypto90dPerformance(range, portfolioFilter); const { data: performance90d, isLoading: isPerformance90dLoading } = useCrypto90dPerformance(range, portfolioFilter);
const refresh = useRefreshCryptoPrices(); const refresh = useRefreshCryptoPrices();
const deleteHolding = useDeleteCryptoHolding(); const deleteHolding = useDeleteCryptoHolding();
const updateRisk = useUpdateCryptoHoldingRisk();
const [addOpen, setAddOpen] = useState(false); const [addOpen, setAddOpen] = useState(false);
const [editingPortfolio, setEditingPortfolio] = useState<CryptoPortfolio | null>(null); const [editingPortfolio, setEditingPortfolio] = useState<CryptoPortfolio | null>(null);
@@ -80,6 +82,10 @@ export function CryptoPage() {
} }
} }
function handleRiskChange(holding: CryptoHolding, riskLevel: RiskLevel) {
updateRisk.mutate({ assetId: holding.asset_id, riskLevel });
}
function handleEditTransaction(transaction: CryptoTransaction) { function handleEditTransaction(transaction: CryptoTransaction) {
setEditingTransaction(transaction); setEditingTransaction(transaction);
setTradingHolding(historyHolding); setTradingHolding(historyHolding);
@@ -160,6 +166,7 @@ export function CryptoPage() {
onTrade={openTradeModal} onTrade={openTradeModal}
onViewHistory={setHistoryHolding} onViewHistory={setHistoryHolding}
onDelete={handleDelete} onDelete={handleDelete}
onRiskChange={handleRiskChange}
portfoliosById={portfolioFilter === null ? portfoliosById : undefined} portfoliosById={portfolioFilter === null ? portfoliosById : undefined}
/> />
</> </>

View File

@@ -442,6 +442,9 @@ export interface CryptoHolding {
symbol: string; symbol: string;
name: string; name: string;
thumb_url: string | null; thumb_url: string | null;
// Lives on the underlying Asset — edited via PATCH /assets/{asset_id},
// same endpoint every other Asset already uses.
risk_level: RiskLevel;
// Both derived from the buy/sell log (see CryptoTransaction) — never // Both derived from the buy/sell log (see CryptoTransaction) — never
// edited directly. // edited directly.
quantity: string; quantity: string;
@@ -477,6 +480,9 @@ export interface CryptoHoldingCreateInput {
price_per_unit: string; price_per_unit: string;
date: string; date: string;
note?: string | null; note?: string | null;
// Omit to default to "high" server-side — crypto's own textbook risk
// tier — but overridable per coin (a large-cap isn't a memecoin).
risk_level?: RiskLevel;
} }
export interface CryptoTransaction { export interface CryptoTransaction {