mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-12 18:46:40 +07:00
feat: добавить уровень риска для крипто-холдингов
Риск (низкий/средний/высокий) задаётся при добавлении монеты и редактируется прямо в таблице холдингов. Добавлена вкладка "Уровни риска" в карточке портфеля с разбивкой % по риск-тирам, только для крипты. Заодно поправлено округление процентов в риск-карточках.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
CryptoSyncResult,
|
||||
CryptoTransaction,
|
||||
CryptoTransactionInput,
|
||||
RiskLevel,
|
||||
} from "@/types";
|
||||
|
||||
export function fetchCryptoPortfolios(includeArchived = false) {
|
||||
@@ -71,6 +72,12 @@ export function deleteCryptoHolding(assetId: number) {
|
||||
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) {
|
||||
return api.get<CryptoSearchResult[]>(`/crypto/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import { Button } from "@/components/ui/Button";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { Input, Label, Select } from "@/components/ui/Input";
|
||||
import { useCreateCryptoHolding, useCryptoPortfolios } from "@/hooks/useCrypto";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { CryptoSearchResult } from "@/types";
|
||||
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
||||
import type { CryptoSearchResult, RiskLevel } from "@/types";
|
||||
|
||||
const RISK_LEVELS: RiskLevel[] = ["low", "medium", "high"];
|
||||
|
||||
interface CryptoAddModalProps {
|
||||
open: boolean;
|
||||
@@ -34,6 +36,9 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
|
||||
const [quantity, setQuantity] = useState("");
|
||||
const [pricePerUnit, setPricePerUnit] = useState("");
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,6 +49,7 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
|
||||
setQuantity("");
|
||||
setPricePerUnit("");
|
||||
setDate(new Date().toISOString().slice(0, 10));
|
||||
setRiskLevel("high");
|
||||
setSearchError(null);
|
||||
setSaveError(null);
|
||||
}, [open]);
|
||||
@@ -99,6 +105,7 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
|
||||
quantity,
|
||||
price_per_unit: pricePerUnit,
|
||||
date,
|
||||
risk_level: riskLevel,
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
@@ -220,6 +227,21 @@ export function CryptoAddModal({ open, onClose, defaultPortfolioId }: CryptoAddM
|
||||
/>
|
||||
</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>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Bitcoin, Plus, Trash2 } from "lucide-react";
|
||||
import { RiskLevelPicker } from "@/components/crypto/RiskLevelPicker";
|
||||
import { formatCryptoAmount, maskAmount } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { CryptoHolding, CryptoPortfolio } from "@/types";
|
||||
import type { CryptoHolding, CryptoPortfolio, RiskLevel } from "@/types";
|
||||
|
||||
interface CryptoHoldingsTableProps {
|
||||
items: CryptoHolding[];
|
||||
@@ -9,6 +10,7 @@ interface CryptoHoldingsTableProps {
|
||||
onTrade: (holding: CryptoHolding) => void;
|
||||
onViewHistory: (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
|
||||
// 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,
|
||||
@@ -34,6 +36,7 @@ export function CryptoHoldingsTable({
|
||||
onTrade,
|
||||
onViewHistory,
|
||||
onDelete,
|
||||
onRiskChange,
|
||||
portfoliosById,
|
||||
}: CryptoHoldingsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -44,18 +47,19 @@ export function CryptoHoldingsTable({
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[860px] text-sm">
|
||||
<table className="w-full min-w-[880px] text-sm">
|
||||
<thead>
|
||||
<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 pr-3 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="py-2 pr-3 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="py-2 pr-3 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="py-2 pr-3 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="py-2.5 pr-4 font-medium">{t("crypto.table.name")}</th>
|
||||
<th className="whitespace-nowrap py-2.5 pr-4 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.change1h")}</th>
|
||||
<th className="whitespace-nowrap py-2.5 pr-4 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.change7d")}</th>
|
||||
<th className="whitespace-nowrap py-2.5 pr-4 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.avgBuyPrice")}</th>
|
||||
<th className="whitespace-nowrap py-2.5 pr-4 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.risk")}</th>
|
||||
<th className="whitespace-nowrap py-2.5 pl-4 text-right font-medium">{t("crypto.table.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gridline">
|
||||
@@ -76,7 +80,7 @@ export function CryptoHoldingsTable({
|
||||
onClick={() => onViewHistory(holding)}
|
||||
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">
|
||||
{holding.thumb_url ? (
|
||||
<img src={holding.thumb_url} alt="" className="h-7 w-7 shrink-0 rounded-full" />
|
||||
@@ -86,11 +90,16 @@ export function CryptoHoldingsTable({
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium text-text-primary">{holding.name}</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-text-muted">
|
||||
{holding.symbol}
|
||||
<span className="block truncate text-sm font-medium text-text-primary" title={holding.name}>
|
||||
{holding.name}
|
||||
</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 && (
|
||||
<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
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={{
|
||||
@@ -104,38 +113,38 @@ export function CryptoHoldingsTable({
|
||||
</span>
|
||||
</div>
|
||||
</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
|
||||
? maskAmount(formatCryptoAmount(holding.current_price), hidden)
|
||||
: t("crypto.pendingPrice")}
|
||||
</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} />
|
||||
</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} />
|
||||
</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} />
|
||||
</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">
|
||||
{holding.value !== null ? maskAmount(formatCryptoAmount(holding.value), hidden) : t("crypto.pendingPrice")}
|
||||
</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)}
|
||||
</span>
|
||||
</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) : "—"}
|
||||
</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 ? (
|
||||
<>
|
||||
<span className="block tabular-nums" style={{ color: profitColor }}>
|
||||
{maskAmount(`${sign}${formatCryptoAmount(holding.profit_loss)}`, hidden)}
|
||||
</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}
|
||||
{holding.profit_loss_percent.toFixed(2)}%
|
||||
</span>
|
||||
@@ -144,7 +153,12 @@ export function CryptoHoldingsTable({
|
||||
<span className="text-text-muted">—</span>
|
||||
)}
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,12 +4,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
|
||||
import { CryptoAllocationBody } from "@/components/crypto/CryptoAllocationBody";
|
||||
import { CryptoHistoryChartBody } from "@/components/crypto/CryptoHistoryChartBody";
|
||||
import { CryptoRangeSelector } from "@/components/crypto/CryptoRangeSelector";
|
||||
import { CryptoRiskAllocationBody } from "@/components/crypto/CryptoRiskAllocationBody";
|
||||
import { PillSelector } from "@/components/layout/PillSelector";
|
||||
import { formatCurrency, formatSignedCurrency, maskAmount } from "@/lib/format";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import type { CryptoHistoryResponse, CryptoHolding, CryptoRange } from "@/types";
|
||||
|
||||
type OverviewTab = "history" | "allocation";
|
||||
type OverviewTab = "history" | "allocation" | "risk";
|
||||
|
||||
interface CryptoOverviewCardProps {
|
||||
totalValue: number;
|
||||
@@ -23,10 +24,11 @@ interface CryptoOverviewCardProps {
|
||||
onToggleHidden: () => void;
|
||||
}
|
||||
|
||||
/** One card, two tabs — "History" (the value-over-time chart) and
|
||||
* "Allocation" (a donut broken down by coin), same CoinMarketCap layout the
|
||||
* user asked for: switching views instead of stacking two separate cards,
|
||||
* so the tab keeps its footprint on the page instead of doubling it. The
|
||||
/** One card, three tabs — "History" (the value-over-time chart),
|
||||
* "Allocation" (a donut broken down by coin), and "Risk levels" (% of the
|
||||
* portfolio in each RiskLevel tier), same CoinMarketCap-style layout 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
|
||||
* which tab is active — only the content panel below switches. */
|
||||
export function CryptoOverviewCard({
|
||||
@@ -77,6 +79,7 @@ export function CryptoOverviewCard({
|
||||
options={[
|
||||
{ value: "history" as const, label: t("crypto.overview.history") },
|
||||
{ value: "allocation" as const, label: t("crypto.overview.allocation") },
|
||||
{ value: "risk" as const, label: t("crypto.overview.risk") },
|
||||
]}
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
@@ -87,8 +90,10 @@ export function CryptoOverviewCard({
|
||||
<CardContent>
|
||||
{tab === "history" ? (
|
||||
<CryptoHistoryChartBody history={history} isLoading={isHistoryLoading} hidden={hidden} />
|
||||
) : (
|
||||
) : tab === "allocation" ? (
|
||||
<CryptoAllocationBody holdings={holdings} isLoading={isHoldingsLoading} hidden={hidden} />
|
||||
) : (
|
||||
<CryptoRiskAllocationBody holdings={holdings} isLoading={isHoldingsLoading} hidden={hidden} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
97
frontend/src/components/crypto/CryptoRiskAllocationBody.tsx
Normal file
97
frontend/src/components/crypto/CryptoRiskAllocationBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
frontend/src/components/crypto/RiskLevelPicker.tsx
Normal file
97
frontend/src/components/crypto/RiskLevelPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function RiskAllocationCard({ riskLevels, isLoading }: RiskAllocationCard
|
||||
<p className="mt-1.5 text-xl font-semibold tabular-nums text-text-primary">
|
||||
{formatCurrency(tier.total_value)}
|
||||
</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 && (
|
||||
<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 className="w-8 shrink-0 text-right text-[11px] tabular-nums text-text-muted">
|
||||
{item.percent.toFixed(0)}%
|
||||
{item.percent.toFixed(1)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
fetchCryptoPortfolios,
|
||||
fetchCryptoTransactions,
|
||||
refreshCryptoPrices,
|
||||
updateCryptoHoldingRisk,
|
||||
updateCryptoPortfolio,
|
||||
updateCryptoTransaction,
|
||||
} from "@/api/crypto";
|
||||
import type { CryptoHoldingCreateInput, CryptoPortfolioInput, CryptoRange, CryptoTransactionInput } from "@/types";
|
||||
import type { CryptoHoldingCreateInput, CryptoPortfolioInput, CryptoRange, CryptoTransactionInput, RiskLevel } from "@/types";
|
||||
|
||||
function useInvalidateCrypto() {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -143,3 +144,12 @@ export function useDeleteCryptoHolding() {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCryptoHoldingRisk() {
|
||||
const invalidate = useInvalidateCrypto();
|
||||
return useMutation({
|
||||
mutationFn: ({ assetId, riskLevel }: { assetId: number; riskLevel: RiskLevel }) =>
|
||||
updateCryptoHoldingRisk(assetId, riskLevel),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,9 +142,11 @@ const ru = {
|
||||
"crypto.table.change24h": "24ч%",
|
||||
"crypto.table.change7d": "7д%",
|
||||
"crypto.table.holdings": "Держите",
|
||||
"crypto.table.avgBuyPrice": "Средняя цена покупки",
|
||||
"crypto.table.avgBuyPrice": "Ср. цена",
|
||||
"crypto.table.profitLoss": "Прибыль/убыток",
|
||||
"crypto.table.risk": "Риск",
|
||||
"crypto.table.actions": "Действия",
|
||||
"crypto.riskAllocationOfPortfolio": "от крипто-портфеля",
|
||||
"crypto.history.title": "История сделок: {{name}}",
|
||||
"crypto.history.empty": "Пока нет ни одной сделки.",
|
||||
"crypto.history.confirmDelete": "Удалить эту сделку? Количество и средняя цена будут пересчитаны.",
|
||||
@@ -155,6 +157,7 @@ const ru = {
|
||||
"crypto.allocation.title": "Распределение по монетам",
|
||||
"crypto.overview.history": "История",
|
||||
"crypto.overview.allocation": "Аллокация",
|
||||
"crypto.overview.risk": "Уровни риска",
|
||||
"crypto.allocation.other": "Другое",
|
||||
"crypto.stats.allTimeProfit": "Прибыль за всё время",
|
||||
"crypto.stats.costBasis": "Вложено всего",
|
||||
@@ -637,9 +640,11 @@ const en: Record<keyof typeof ru, string> = {
|
||||
"crypto.table.change24h": "24h%",
|
||||
"crypto.table.change7d": "7d%",
|
||||
"crypto.table.holdings": "Holdings",
|
||||
"crypto.table.avgBuyPrice": "Avg. Buy Price",
|
||||
"crypto.table.avgBuyPrice": "Avg. Price",
|
||||
"crypto.table.profitLoss": "Profit/Loss",
|
||||
"crypto.table.risk": "Risk",
|
||||
"crypto.table.actions": "Actions",
|
||||
"crypto.riskAllocationOfPortfolio": "of crypto portfolio",
|
||||
"crypto.history.title": "Trade history: {{name}}",
|
||||
"crypto.history.empty": "No trades yet.",
|
||||
"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.overview.history": "History",
|
||||
"crypto.overview.allocation": "Allocation",
|
||||
"crypto.overview.risk": "Risk levels",
|
||||
"crypto.allocation.other": "Other",
|
||||
"crypto.stats.allTimeProfit": "All-time profit",
|
||||
"crypto.stats.costBasis": "Total invested",
|
||||
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
useCryptoPortfolios,
|
||||
useDeleteCryptoHolding,
|
||||
useRefreshCryptoPrices,
|
||||
useUpdateCryptoHoldingRisk,
|
||||
} from "@/hooks/useCrypto";
|
||||
import { getIntlLocale } from "@/lib/format";
|
||||
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 {
|
||||
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 refresh = useRefreshCryptoPrices();
|
||||
const deleteHolding = useDeleteCryptoHolding();
|
||||
const updateRisk = useUpdateCryptoHoldingRisk();
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
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) {
|
||||
setEditingTransaction(transaction);
|
||||
setTradingHolding(historyHolding);
|
||||
@@ -160,6 +166,7 @@ export function CryptoPage() {
|
||||
onTrade={openTradeModal}
|
||||
onViewHistory={setHistoryHolding}
|
||||
onDelete={handleDelete}
|
||||
onRiskChange={handleRiskChange}
|
||||
portfoliosById={portfolioFilter === null ? portfoliosById : undefined}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -442,6 +442,9 @@ export interface CryptoHolding {
|
||||
symbol: string;
|
||||
name: string;
|
||||
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
|
||||
// edited directly.
|
||||
quantity: string;
|
||||
@@ -477,6 +480,9 @@ export interface CryptoHoldingCreateInput {
|
||||
price_per_unit: string;
|
||||
date: string;
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user