diff --git a/backend/app/schemas/crypto.py b/backend/app/schemas/crypto.py index edbcc90..6f5d337 100644 --- a/backend/app/schemas/crypto.py +++ b/backend/app/schemas/crypto.py @@ -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. diff --git a/backend/app/services/crypto_service.py b/backend/app/services/crypto_service.py index ff5fbf0..cd9855f 100644 --- a/backend/app/services/crypto_service.py +++ b/backend/app/services/crypto_service.py @@ -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( diff --git a/frontend/src/api/crypto.ts b/frontend/src/api/crypto.ts index faa9e9b..50139a1 100644 --- a/frontend/src/api/crypto.ts +++ b/frontend/src/api/crypto.ts @@ -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(`/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(`/assets/${assetId}`, { risk_level: riskLevel }); +} + export function searchCryptoCoins(query: string) { return api.get(`/crypto/search?q=${encodeURIComponent(query)}`); } diff --git a/frontend/src/components/crypto/CryptoAddModal.tsx b/frontend/src/components/crypto/CryptoAddModal.tsx index 4673fc9..1571701 100644 --- a/frontend/src/components/crypto/CryptoAddModal.tsx +++ b/frontend/src/components/crypto/CryptoAddModal.tsx @@ -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("high"); const [saveError, setSaveError] = useState(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 /> +
+ + +
+ {saveError &&

{saveError}

}
diff --git a/frontend/src/components/crypto/CryptoHoldingsTable.tsx b/frontend/src/components/crypto/CryptoHoldingsTable.tsx index cebb983..e6f867e 100644 --- a/frontend/src/components/crypto/CryptoHoldingsTable.tsx +++ b/frontend/src/components/crypto/CryptoHoldingsTable.tsx @@ -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 (
- +
- - - - - - - - - + + + + + + + + + + @@ -76,7 +80,7 @@ export function CryptoHoldingsTable({ onClick={() => onViewHistory(holding)} className="cursor-pointer hover:bg-surface-2" > - - - - - - - - - +
{t("crypto.table.name")}{t("crypto.table.price")}{t("crypto.table.change1h")}{t("crypto.table.change24h")}{t("crypto.table.change7d")}{t("crypto.table.holdings")}{t("crypto.table.avgBuyPrice")}{t("crypto.table.profitLoss")}{t("crypto.table.actions")}{t("crypto.table.name")}{t("crypto.table.price")}{t("crypto.table.change1h")}{t("crypto.table.change24h")}{t("crypto.table.change7d")}{t("crypto.table.holdings")}{t("crypto.table.avgBuyPrice")}{t("crypto.table.profitLoss")}{t("crypto.table.risk")}{t("crypto.table.actions")}
+
{holding.thumb_url ? ( @@ -86,11 +90,16 @@ export function CryptoHoldingsTable({ )} - {holding.name} - - {holding.symbol} + + {holding.name} + + + {holding.symbol} {portfoliosById && ( - +
+ {holding.current_price !== null ? maskAmount(formatCryptoAmount(holding.current_price), hidden) : t("crypto.pendingPrice")} + + + + {holding.value !== null ? maskAmount(formatCryptoAmount(holding.value), hidden) : t("crypto.pendingPrice")} - + {maskAmount(`${Number(holding.quantity)} ${holding.symbol}`, hidden)} + {holding.avg_buy_price !== null ? maskAmount(formatCryptoAmount(holding.avg_buy_price), hidden) : "—"} + {holding.profit_loss !== null && holding.profit_loss_percent !== null ? ( <> {maskAmount(`${sign}${formatCryptoAmount(holding.profit_loss)}`, hidden)} - + {sign} {holding.profit_loss_percent.toFixed(2)}% @@ -144,7 +153,12 @@ export function CryptoHoldingsTable({ — )} + event.stopPropagation()}> + + onRiskChange(holding, level)} /> + +
+ {open && ( + + {RISK_LEVELS.map((level) => ( + + ))} + + )} + + ); +} diff --git a/frontend/src/components/networth/RiskAllocationCard.tsx b/frontend/src/components/networth/RiskAllocationCard.tsx index d8a10da..7693ac5 100644 --- a/frontend/src/components/networth/RiskAllocationCard.tsx +++ b/frontend/src/components/networth/RiskAllocationCard.tsx @@ -30,7 +30,7 @@ export function RiskAllocationCard({ riskLevels, isLoading }: RiskAllocationCard

{formatCurrency(tier.total_value)}

-

{tier.percent.toFixed(0)}% {t("netWorth.riskAllocationOfCapital")}

+

{tier.percent.toFixed(1)}% {t("netWorth.riskAllocationOfCapital")}

{tier.items.length > 0 && (
    @@ -46,7 +46,7 @@ export function RiskAllocationCard({ riskLevels, isLoading }: RiskAllocationCard /> - {item.percent.toFixed(0)}% + {item.percent.toFixed(1)}% ))} diff --git a/frontend/src/hooks/useCrypto.ts b/frontend/src/hooks/useCrypto.ts index 087dc89..c17e1af 100644 --- a/frontend/src/hooks/useCrypto.ts +++ b/frontend/src/hooks/useCrypto.ts @@ -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, + }); +} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 80559d4..88c9238 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -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 = { "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 = { "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", diff --git a/frontend/src/pages/CryptoPage.tsx b/frontend/src/pages/CryptoPage.tsx index 75cbc9e..8ea0a63 100644 --- a/frontend/src/pages/CryptoPage.tsx +++ b/frontend/src/pages/CryptoPage.tsx @@ -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(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} /> diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7d44162..b6a3822 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 {