From 2abbb3e3290fe184c38e42b2f53ceb81a4761bc7 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 8 Nov 2026 14:21:01 +0300 Subject: [PATCH] feat(account): add Result tab with return vs Moscow Exchange index (WealthLedger/main#54) --- src/pages/account/model/performance.ts | 105 ++++++++++ .../components/AccountPageLoaded.view.tsx | 7 + .../Performance/Performance.module.css | 3 + .../Performance/Performance.stories.tsx | 106 ++++++++++ .../Performance/Performance.view.tsx | 185 +++++++++++++++++ .../Performance/PerformanceTab.test.tsx | 193 ++++++++++++++++++ .../components/Performance/PerformanceTab.tsx | 66 ++++++ src/shared/i18n/locales/ru.ts | 33 +++ 8 files changed, 698 insertions(+) create mode 100644 src/pages/account/model/performance.ts create mode 100644 src/pages/account/ui/AccountPage/components/Performance/Performance.module.css create mode 100644 src/pages/account/ui/AccountPage/components/Performance/Performance.stories.tsx create mode 100644 src/pages/account/ui/AccountPage/components/Performance/Performance.view.tsx create mode 100644 src/pages/account/ui/AccountPage/components/Performance/PerformanceTab.test.tsx create mode 100644 src/pages/account/ui/AccountPage/components/Performance/PerformanceTab.tsx diff --git a/src/pages/account/model/performance.ts b/src/pages/account/model/performance.ts new file mode 100644 index 0000000..a2a39b9 --- /dev/null +++ b/src/pages/account/model/performance.ts @@ -0,0 +1,105 @@ +import type { Performance } from "@/entities/performance"; + +import { formatDate, formatMoney, formatPercentagePoints, formatSignedPercent } from "@/shared/lib"; + +type TextValue = { tone: Tone; value: string }; + +type Tone + = | "negative" + | "neutral" + | "positive"; + +const reasonLabels: Record = { + ACCOUNT_VALUATION_DATES_DIFFER: "Оценки счетов сделаны в разные дни", + AMBIGUOUS_RETURN: "У расчёта доходности несколько возможных решений", + HISTORY_START_UNKNOWN: "Начало истории счёта неизвестно", + INDEX_HISTORY_UNAVAILABLE: "История индекса временно недоступна", + NO_SOLUTION: "Расчёт доходности не сходится", + NON_RUB_ACTIVITY: "Есть операции или позиции не в рублях", + OPERATIONS_HISTORY_INCOMPLETE: "История операций ещё не получена полностью", + PORTFOLIO_VALUATION_INCOMPLETE: "Нет полной оценки портфеля", + UNCONFIRMED_TRANSFER: "Перевод между счетами не удалось подтвердить", + UNVALUED_SECURITIES_TRANSFER: "Бумаги переведены без денежной оценки", +}; + +const unknownReason = "Причину недоступности не удалось определить"; + +function performanceVm(data: Performance) { + return { + basis: data.return.basis, + comparison: data.outperformance + ? { + money: signedMoney(data.outperformance.money), + points: valueText( + data.outperformance.percentagePoints, + formatPercentagePoints, + ), + } + : null, + current: formatMoney(data.currentValue), + currentPoint: data.series.length > 0 + ? { date: formatDate(`${data.series.at(-1)?.date ?? data.period.to}T00:00:00Z`) } + : null, + days: data.period.days, + excluded: data.excludedAccounts.map(item => ({ + id: item.accountId, + name: item.name, + reason: reasonText(item.reason), + })), + from: data.period.from, + indexReason: data.benchmark.unavailableReason + ? reasonText(data.benchmark.unavailableReason) + : null, + indexReturn: valueText(data.benchmark.return.value, formatSignedPercent), + indexValue: data.benchmark.value ? formatMoney(data.benchmark.value) : "—", + invested: formatMoney(data.netInvested), + profit: signedMoney(data.profit), + returnReason: data.return.unavailableReason + ? reasonText(data.return.unavailableReason) + : null, + returnValue: valueText(data.return.value, formatSignedPercent), + series: data.series.map(item => ({ + benchmarkValue: Number(item.benchmarkValue), + date: item.date, + label: formatDate(`${item.date}T00:00:00Z`), + netInvested: Number(item.netInvested), + referenceValue: item.referenceValue === null + ? null + : Number(item.referenceValue), + })), + to: data.period.to, + }; +} + +function reasonText(reason: string): string { + // eslint-disable-next-line security/detect-object-injection -- Lookup accepts only an API reason, with a safe fallback. + return reasonLabels[reason] ?? unknownReason; +} + +function signedMoney(value: Performance["profit"]): TextValue { + return { + tone: tone(value.value), + value: formatMoney(value, { withSign: true }), + }; +} + +function tone(value: string): Tone { + const number = Number(value); + if (number > 0) return "positive"; + + return number < 0 ? "negative" : "neutral"; +} + +// Tone follows the displayed text, so "0,0 %" is never coloured. +function valueText( + value: null | string, + format: (value: string) => string, +): TextValue { + if (value === null) return { tone: "neutral", value: "—" }; + const text = format(value); + if (text.startsWith("+")) return { tone: "positive", value: text }; + + return { tone: text.startsWith("−") ? "negative" : "neutral", value: text }; +} + +export { performanceVm, reasonText }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx index 3611ae5..101c6ad 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -30,6 +30,7 @@ import { AccountHeading } from "./AccountHeading.view"; import { Concentration } from "./Concentration/Concentration.view"; import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view"; import { IncomeSummary } from "./IncomeSummary.view"; +import { PerformanceTab } from "./Performance/PerformanceTab"; import { PortfolioStructure } from "./PortfolioStructure/PortfolioStructure"; import { PositionSection } from "./PositionSection.view"; import { RecentOperations } from "./RecentOperations.view"; @@ -148,6 +149,8 @@ const AccountPageLoaded: FC = (props) => { {t("portfolio.tabs.events")} {t("portfolio.tabs.income")} + + {t("portfolio.tabs.performance")} @@ -211,6 +214,10 @@ const AccountPageLoaded: FC = (props) => { /> )} + + + {props.accountId && } + ); diff --git a/src/pages/account/ui/AccountPage/components/Performance/Performance.module.css b/src/pages/account/ui/AccountPage/components/Performance/Performance.module.css new file mode 100644 index 0000000..e987704 --- /dev/null +++ b/src/pages/account/ui/AccountPage/components/Performance/Performance.module.css @@ -0,0 +1,3 @@ +.positive { color: var(--wl-color-positive); } +.negative { color: var(--wl-color-negative); } +.neutral { color: var(--mantine-color-dark-7); } diff --git a/src/pages/account/ui/AccountPage/components/Performance/Performance.stories.tsx b/src/pages/account/ui/AccountPage/components/Performance/Performance.stories.tsx new file mode 100644 index 0000000..7cd4bd1 --- /dev/null +++ b/src/pages/account/ui/AccountPage/components/Performance/Performance.stories.tsx @@ -0,0 +1,106 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import type { Performance } from "@/entities/performance"; + +import { PerformanceView } from "./Performance.view"; + +const rub = (value: string) => ({ currency: "RUB", value }); + +const series = Array.from({ length: 24 }, (_, month) => { + const date = new Date(Date.UTC(2024, month, 1)).toISOString().slice(0, 10); + const invested = 300_000 + Math.floor(month / 6) * 150_000; + + return { + benchmarkValue: String(Math.round(invested * (1 + month * 0.009))), + date, + netInvested: String(invested), + referenceValue: String(Math.round(invested * (1 + month * 0.004))), + }; +}); + +const data: Performance = { + accountId: "account-1", + benchmark: { + asOf: "2025-12-01", + code: "MCFTRR", + return: { basis: "annual", unavailableReason: null, value: "0.108" }, + unavailableReason: null, + value: rub("1387973"), + }, + calculatedAt: "2025-12-01T00:00:00Z", + completeness: "complete", + currentValue: rub("1536400"), + excludedAccounts: [], + netInvested: rub("1060000"), + outperformance: { money: rub("148427"), percentagePoints: "0.043" }, + period: { days: 700, from: "2024-01-01", to: "2025-12-01" }, + profit: rub("476400"), + reference: { + asOf: "2025-12-01", + code: "IMOEX", + return: { basis: "annual", unavailableReason: null, value: "0.047" }, + unavailableReason: null, + value: rub("1150000"), + }, + return: { basis: "annual", unavailableReason: null, value: "0.151" }, + series, +}; + +const meta = { + args: { data }, + component: PerformanceView, + parameters: { + docs: { + description: { + component: + "Вкладка «Результат»: доходность, прибыль, те же деньги в индексе Мосбиржи и опережение. Синтетический серверный ответ без расчётов на клиенте.", + }, + }, + }, + tags: ["autodocs"], + title: "Product/Performance", +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Ahead: Story = {}; + +export const Behind: Story = { + args: { + data: { + ...data, + currentValue: rub("1172400"), + outperformance: { money: rub("-215573"), percentagePoints: "-0.068" }, + profit: rub("112400"), + return: { basis: "annual", unavailableReason: null, value: "0.04" }, + }, + }, +}; + +export const ShortHistory: Story = { + args: { + data: { + ...data, + benchmark: { + ...data.benchmark, + return: { basis: "period", unavailableReason: null, value: "0.112" }, + }, + period: { days: 277, from: "2025-02-27", to: "2025-12-01" }, + return: { basis: "period", unavailableReason: null, value: "0.074" }, + }, + }, +}; + +export const Incomplete: Story = { + args: { + data: { + ...data, + completeness: "incomplete", + excludedAccounts: [ + { accountId: "usd", name: "Валютный", reason: "NON_RUB_ACTIVITY" }, + ], + }, + }, +}; diff --git a/src/pages/account/ui/AccountPage/components/Performance/Performance.view.tsx b/src/pages/account/ui/AccountPage/components/Performance/Performance.view.tsx new file mode 100644 index 0000000..63ad60e --- /dev/null +++ b/src/pages/account/ui/AccountPage/components/Performance/Performance.view.tsx @@ -0,0 +1,185 @@ +import { LineChart } from "@mantine/charts"; +import { + Accordion, + ActionIcon, + Alert, + Group, + Paper, + SimpleGrid, + Stack, + Text, + Title, + Tooltip, +} from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +import type { Performance } from "@/entities/performance"; + +import { formatChartValue, formatDate } from "@/shared/lib"; + +import { performanceVm } from "../../../../model/performance"; +import classes from "./Performance.module.css"; + +const formatRub = (value: number) => `${formatChartValue(Math.round(value))} ₽`; + +function PerformanceView({ data }: Readonly<{ data: Performance }>) { + const { t } = useTranslation(); + const vm = performanceVm(data); + + const basis = vm.basis === "annual" + ? t("portfolio.performance.yearly") + : t("portfolio.performance.periodLabel", { days: vm.days }); + + const comparisonTone = vm.comparison?.points.tone ?? "neutral"; + let comparisonTitle = t("portfolio.performance.marketEqual"); + if (comparisonTone === "positive") comparisonTitle = t("portfolio.performance.marketAhead"); + else if (comparisonTone === "negative") comparisonTitle = t("portfolio.performance.marketBehind"); + const percentText = vm.returnValue.value; + const indexText = vm.indexReturn.value; + + const currentLines = vm.currentPoint + ? [{ color: "blue.6", label: t("portfolio.performance.current"), x: vm.currentPoint.date }] + : []; + + const profitCaption = t("portfolio.performance.profitCaption", { current: vm.current, invested: vm.invested }); + + const chartLabel = [ + `${t("portfolio.performance.invested")}: ${vm.invested}`, + `${t("portfolio.performance.benchmark")}: ${vm.indexValue}`, + `${t("portfolio.performance.current")}: ${vm.current}`, + `${t("portfolio.performance.returnLabel")}: ${percentText}`, + ].join("; "); + + return ( + + {vm.excluded.length > 0 && ( + + {vm.excluded.map(item => ( + + {t("portfolio.performance.unavailableReason", item)} + + ))} + + )} + + {vm.basis === "period" && ( + + {t("portfolio.performance.periodNote", { days: vm.days })} + + )} + + + + {t("portfolio.performance.returnLabel")} + + + {percentText} + + + {vm.returnReason ?? basis} + + + + {t("portfolio.performance.profit")} + + + {vm.profit.value} + + + {profitCaption} + + + + + {t("portfolio.performance.benchmark")} + + + ? + + + + + {indexText} + + + {vm.indexReason ?? `${basis} · ${vm.indexValue}`} + + + + + + {comparisonTitle} + + + + {vm.comparison?.points.value ?? "—"} + + + {vm.comparison?.money.value ?? vm.indexReason ?? vm.returnReason} + + + + + {t("portfolio.performance.chartTitle")} + + {vm.series.length > 0 + ? ( + + ) + : {t("portfolio.performance.benchmarkUnavailable")}} + + + {t("portfolio.performance.missingHistory")} + + + + + + {t("portfolio.performance.calculation")} + + + + {t("portfolio.performance.datePeriod", { from: formatDate(`${vm.from}T00:00:00Z`), to: formatDate(`${vm.to}T00:00:00Z`) })} + + + {t("portfolio.performance.method")} + + + + + ); +} + +export { PerformanceView }; diff --git a/src/pages/account/ui/AccountPage/components/Performance/PerformanceTab.test.tsx b/src/pages/account/ui/AccountPage/components/Performance/PerformanceTab.test.tsx new file mode 100644 index 0000000..0d0575d --- /dev/null +++ b/src/pages/account/ui/AccountPage/components/Performance/PerformanceTab.test.tsx @@ -0,0 +1,193 @@ +import { MantineProvider } from "@mantine/core"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { http, HttpResponse, type JsonBodyType } from "msw"; +import { describe, expect, it, vi } from "vitest"; + +import type { Performance } from "@/entities/performance"; + +import { configureAuthClient } from "@/shared/api"; +import { createTestQueryClient } from "@/test/query"; +import { server } from "@/test/server"; + +import { PerformanceTab } from "./PerformanceTab"; + +const url = "*/api/v1/brokerage-accounts/:accountId/performance"; + +const rub = (value: string) => ({ currency: "RUB", value }); + +function performance(overrides: Partial = {}): Performance { + return { + accountId: "account-1", + benchmark: { + asOf: "2025-12-31", + code: "MCFTRR", + return: { basis: "annual", unavailableReason: null, value: "0.048809" }, + unavailableReason: null, + value: rub("110000"), + }, + calculatedAt: "2026-01-01T00:00:00Z", + completeness: "complete", + currentValue: rub("121000"), + excludedAccounts: [], + netInvested: rub("100000"), + outperformance: { money: rub("11000"), percentagePoints: "0.051191" }, + period: { days: 730, from: "2024-01-01", to: "2025-12-31" }, + profit: rub("21000"), + reference: { + asOf: "2025-12-31", + code: "IMOEX", + return: { basis: "annual", unavailableReason: null, value: "0.02" }, + unavailableReason: null, + value: rub("105000"), + }, + return: { basis: "annual", unavailableReason: null, value: "0.1" }, + series: [ + { benchmarkValue: "100000", date: "2024-01-01", netInvested: "100000", referenceValue: "100000" }, + { benchmarkValue: "110000", date: "2025-12-31", netInvested: "100000", referenceValue: "105000" }, + ], + ...overrides, + }; +} + +function renderTab(body: JsonBodyType, status = 200) { + configureAuthClient({ + accessToken: () => "test-token", + clear: vi.fn(), + refresh: () => Promise.resolve(true), + }); + server.use(http.get(url, () => HttpResponse.json(body, { status }))); + + return render( + + + + + , + ); +} + +const meta = { dependencies: [], unavailable: [] }; + +describe("PerformanceTab", () => { + it("shows return, profit, the same money in the index and outperformance with signs", async () => { + renderTab({ data: performance(), meta }); + + expect(await screen.findByText("+10,0 %")).toBeInTheDocument(); + expect(screen.getByText("+21 000 ₽")).toBeInTheDocument(); + expect(screen.getByText("+4,9 %")).toBeInTheDocument(); + expect(screen.getByText("Опережение рынка")).toBeInTheDocument(); + expect(screen.getByText("+5,1 п.п.")).toBeInTheDocument(); + expect(screen.getByText("+11 000 ₽")).toBeInTheDocument(); + // Index codes are allowed only inside "Как считали", not as captions. + expect(screen.getByText("Те же деньги в индексе Мосбиржи")).toBeInTheDocument(); + expect(screen.queryByText("MCFTRR")).not.toBeInTheDocument(); + }); + + it("labels lagging the market without colour-only meaning", async () => { + renderTab({ + data: performance({ + outperformance: { money: rub("-5000"), percentagePoints: "-0.02" }, + }), + meta, + }); + + expect(await screen.findByText("Отставание от рынка")).toBeInTheDocument(); + expect(screen.getByText("−2,0 п.п.")).toBeInTheDocument(); + }); + + it("never says annual when history is shorter than a year", async () => { + renderTab({ + data: performance({ + benchmark: { ...performance().benchmark, return: { basis: "period", unavailableReason: null, value: "0.05" } }, + period: { days: 200, from: "2025-06-14", to: "2025-12-31" }, + return: { basis: "period", unavailableReason: null, value: "0.07" }, + }), + meta, + }); + + expect(await screen.findByText(/проценты показаны за 200 дн\./)).toBeInTheDocument(); + expect(screen.queryByText(/годовых/)).not.toBeInTheDocument(); + }); + + it("lists excluded accounts when the all-accounts result is incomplete", async () => { + renderTab({ + data: performance({ + completeness: "incomplete", + excludedAccounts: [{ accountId: "usd", name: "Валютный", reason: "NON_RUB_ACTIVITY" }], + }), + meta, + }); + + expect(await screen.findByText("Результат неполный")).toBeInTheDocument(); + expect(screen.getByText("Валютный: Есть операции или позиции не в рублях")).toBeInTheDocument(); + }); + + it("explains unavailability instead of showing zeros, including unknown reasons", async () => { + renderTab({ + data: null, + meta: { + dependencies: [], + unavailable: [ + { accountId: "iis", name: "ИИС", reason: "HISTORY_START_UNKNOWN" }, + { accountId: "x", name: "Новый", reason: "SOMETHING_NEW" }, + ], + }, + }); + + expect(await screen.findByText("Доходность недоступна")).toBeInTheDocument(); + expect(screen.getByText("ИИС: Начало истории счёта неизвестно")).toBeInTheDocument(); + expect(screen.getByText("Новый: Причину недоступности не удалось определить")).toBeInTheDocument(); + expect(screen.queryByText(/0,0 %/)).not.toBeInTheDocument(); + }); + + it("keeps profit and hides the comparison when the index is unavailable", async () => { + renderTab({ + data: performance({ + benchmark: { + asOf: null, + code: "MCFTRR", + return: { basis: "annual", unavailableReason: "INDEX_HISTORY_UNAVAILABLE", value: null }, + unavailableReason: "INDEX_HISTORY_UNAVAILABLE", + value: null, + }, + outperformance: null, + series: [], + }), + meta, + }); + + expect(await screen.findByText("+21 000 ₽")).toBeInTheDocument(); + expect(screen.getByText("Сравнение с индексом сейчас недоступно")).toBeInTheDocument(); + }); + + it("does not colour or call a rounded-to-zero difference outperformance", async () => { + renderTab({ + data: performance({ + outperformance: { money: rub("0"), percentagePoints: "-0.0004" }, + }), + meta, + }); + + expect(await screen.findByText("Сравнение с рынком")).toBeInTheDocument(); + expect(screen.getByText("0,0 п.п.")).toBeInTheDocument(); + }); + + it("explains a pending sync when no account reasons are available", async () => { + renderTab({ data: null, meta }); + + expect(await screen.findByText(/ещё синхронизируются/)).toBeInTheDocument(); + }); + + it("shows a local error with retry", async () => { + renderTab({ code: "INTERNAL_SERVER_ERROR", status: 500 }, 500); + + expect(await screen.findByText("Не удалось загрузить результат инвестирования", {}, { timeout: 8000 })).toBeInTheDocument(); + server.use(http.get( + url, + () => HttpResponse.json({ data: performance(), meta }), + )); + fireEvent.click(screen.getByRole("button", { name: "Повторить" })); + expect(await screen.findByText("+10,0 %")).toBeInTheDocument(); + }, 10_000); +}); diff --git a/src/pages/account/ui/AccountPage/components/Performance/PerformanceTab.tsx b/src/pages/account/ui/AccountPage/components/Performance/PerformanceTab.tsx new file mode 100644 index 0000000..c2099c5 --- /dev/null +++ b/src/pages/account/ui/AccountPage/components/Performance/PerformanceTab.tsx @@ -0,0 +1,66 @@ +import { Button, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +import { usePerformance } from "@/entities/performance"; + +import { reasonText } from "../../../../model/performance"; +import { PerformanceView } from "./Performance.view"; + +function PerformanceTab({ accountId }: Readonly<{ accountId: string }>) { + const { t } = useTranslation(); + const query = usePerformance(accountId); + + if (query.isPending) return ( + + + {["rate", "profit", "benchmark", "difference"].map(key => ( + + + + + + ))} + + + + + ); + + const onRetry = () => { + void query.refetch(); + }; + + if (query.isError) return ( + + + {t("portfolio.performance.error")} + + + + + ); + if (!query.data.data) return ( + + {t("portfolio.performance.unavailable")} + + {query.data.meta.unavailable.length === 0 && ( + {t("portfolio.performance.syncing")} + )} + + {query.data.meta.unavailable.map(item => ( + + {t("portfolio.performance.unavailableReason", { + name: item.name, + reason: reasonText(item.reason), + })} + + ))} + + ); + + return ; +} + +export { PerformanceTab }; diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 0970b59..1830e8d 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -543,6 +543,38 @@ const ru = { emptyTitle: "Портфель пока пуст", more: "Подробнее", otherPositions: "Другие позиции", + performance: { + benchmark: "Те же деньги в индексе Мосбиржи", + benchmarkHint: + "Если бы каждое пополнение вкладывалось в индекс Мосбиржи с учётом дивидендов после налогов", + benchmarkUnavailable: "Сравнение с индексом сейчас недоступно", + calculation: "Как считали", + chartTitle: "Ваш портфель против индекса", + current: "Ваш портфель сейчас", + datePeriod: "С {{from}} по {{to}}", + error: "Не удалось загрузить результат инвестирования", + incomplete: "Результат неполный", + invested: "Вложено (нетто)", + marketAhead: "Опережение рынка", + marketBehind: "Отставание от рынка", + marketEqual: "Сравнение с рынком", + method: + "Доходность учитывает суммы и даты пополнений и выводов. Купоны, дивиденды, налоги и комиссии уже входят в текущую стоимость. Для сравнения те же потоки вложены в индекс полной доходности Мосбиржи с дивидендами после налогов (MCFTRR). Ценовой индекс IMOEX дивиденды не учитывает и показан только для справки. Источник: MOEX ISS. Это не налоговый расчёт.", + missingHistory: + "Линии прошлой стоимости вашего портфеля нет: такая история ещё не накоплена. Точка справа — текущая оценка.", + periodLabel: "за {{days}} дн.", + periodNote: + "История меньше года: проценты показаны за {{days}} дн. без пересчёта в годовые.", + profit: "Прибыль", + profitCaption: "{{current}} сейчас · вложено {{invested}}", + reference: "Индекс Мосбиржи без дивидендов", + retry: "Повторить", + returnLabel: "Доходность", + syncing: "Данные счетов ещё синхронизируются. Результат появится после загрузки.", + unavailable: "Доходность недоступна", + unavailableReason: "{{name}}: {{reason}}", + yearly: "годовых", + }, positionFilter: "Тип позиции", positionKinds: { bonds: "Облигации", @@ -586,6 +618,7 @@ const ru = { events: "Предстоящие события", income: "Доход", operations: "История операций", + performance: "Результат", positions: "Позиции", }, title: "Портфель",