From e2062ce8372d196e00e4dc4e75e072d40b56db21 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Sun, 8 Nov 2026 16:49:39 +0300 Subject: [PATCH] feat(account): add performance tab vs Moscow Exchange index (WealthLedger/main#54) (#167) --- .../performance/api/usePerformance.ts | 20 + src/entities/performance/index.ts | 1 + 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/api/generated/schema.d.ts | 445 ++++---- src/shared/api/spec/openapi.json | 966 ++++++++---------- src/shared/i18n/locales/ru.ts | 33 + src/shared/lib/display.test.ts | 10 + src/shared/lib/display.ts | 27 + src/shared/lib/index.ts | 2 + 15 files changed, 1453 insertions(+), 716 deletions(-) create mode 100644 src/entities/performance/api/usePerformance.ts create mode 100644 src/entities/performance/index.ts 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/entities/performance/api/usePerformance.ts b/src/entities/performance/api/usePerformance.ts new file mode 100644 index 0000000..223cd0e --- /dev/null +++ b/src/entities/performance/api/usePerformance.ts @@ -0,0 +1,20 @@ +import { apiQueryClient, type paths, useAuthenticatedQuery } from "@/shared/api"; + +type Performance = NonNullable< + paths["/api/v1/brokerage-accounts/{accountId}/performance"]["get"]["responses"][200]["content"]["application/json"]["data"] +>; + +function usePerformance(accountId: string) { + return useAuthenticatedQuery({ + meta: { accessGroup: "brokerage" }, + ...apiQueryClient.queryOptions( + "get", + "/api/v1/brokerage-accounts/{accountId}/performance", + { params: { path: { accountId } } }, + ), + enabled: Boolean(accountId), + staleTime: 30_000, + }); +} + +export { type Performance, usePerformance }; diff --git a/src/entities/performance/index.ts b/src/entities/performance/index.ts new file mode 100644 index 0000000..4857b05 --- /dev/null +++ b/src/entities/performance/index.ts @@ -0,0 +1 @@ +export { type Performance, usePerformance } from "./api/usePerformance"; 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/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 05ca2e8..0232dd1 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -276,6 +276,38 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/brokerage-accounts/all/performance": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["PerformanceController_all"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/brokerage-accounts/{accountId}/performance": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["PerformanceController_account"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/brokerage-accounts/all/operations": { parameters: { query?: never; @@ -708,22 +740,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/tbank-sync/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["TbankSyncController_status"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; } export type webhooks = Record; export interface components { @@ -1138,6 +1154,73 @@ export interface components { data: components["schemas"]["PortfolioDto"] | null; meta: components["schemas"]["InvestmentResourceMetaDto"]; }; + PerformancePeriodDto: { + /** Format: date */ + from: string; + /** Format: date */ + to: string; + days: number; + }; + /** @enum {string} */ + PerformanceUnavailableReason: "ACCOUNT_VALUATION_DATES_DIFFER" | "AMBIGUOUS_RETURN" | "HISTORY_START_UNKNOWN" | "INDEX_HISTORY_UNAVAILABLE" | "NON_RUB_ACTIVITY" | "NO_SOLUTION" | "OPERATIONS_HISTORY_INCOMPLETE" | "PORTFOLIO_VALUATION_INCOMPLETE" | "UNCONFIRMED_TRANSFER" | "UNVALUED_SECURITIES_TRANSFER"; + PerformanceReturnDto: { + /** @description Decimal fraction: 0.151 = 15.1% */ + value: string | null; + /** @enum {string} */ + basis: "annual" | "period"; + unavailableReason: components["schemas"]["PerformanceUnavailableReason"] | null; + }; + PerformanceIndexDto: { + /** @enum {string} */ + code: "MCFTRR" | "IMOEX"; + value: components["schemas"]["MoneyDto"] | null; + return: components["schemas"]["PerformanceReturnDto"]; + /** Format: date */ + asOf: string | null; + unavailableReason: components["schemas"]["PerformanceUnavailableReason"] | null; + }; + PerformanceOutperformanceDto: { + /** @description Difference between rates as decimal fractions */ + percentagePoints: string; + money: components["schemas"]["MoneyDto"]; + }; + PerformanceSeriesPointDto: { + /** Format: date */ + date: string; + netInvested: string; + benchmarkValue: string; + referenceValue: string | null; + }; + PerformanceExcludedAccountDto: { + accountId: string; + name: string; + reason: components["schemas"]["PerformanceUnavailableReason"]; + }; + PerformanceDto: { + accountId: string; + period: components["schemas"]["PerformancePeriodDto"]; + netInvested: components["schemas"]["MoneyDto"]; + currentValue: components["schemas"]["MoneyDto"]; + profit: components["schemas"]["MoneyDto"]; + return: components["schemas"]["PerformanceReturnDto"]; + benchmark: components["schemas"]["PerformanceIndexDto"]; + reference: components["schemas"]["PerformanceIndexDto"]; + outperformance: components["schemas"]["PerformanceOutperformanceDto"] | null; + series: components["schemas"]["PerformanceSeriesPointDto"][]; + /** @enum {string} */ + completeness: "complete" | "incomplete"; + excludedAccounts: components["schemas"]["PerformanceExcludedAccountDto"][]; + /** Format: date-time */ + calculatedAt: string; + }; + PerformanceMetaDto: { + dependencies: components["schemas"]["InvestmentDependencyDto"][]; + unavailable: components["schemas"]["PerformanceExcludedAccountDto"][]; + }; + PerformanceResourceDto: { + data: components["schemas"]["PerformanceDto"] | null; + meta: components["schemas"]["PerformanceMetaDto"]; + }; BrokerageOperationDto: { account?: components["schemas"]["AccountReferenceDto"]; id: string; @@ -1708,84 +1791,6 @@ export interface components { PutTbankConnectionDto: { token: string; }; - EventsInvestmentScreenDto: { - accountId: string; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - screen: "events"; - /** - * Format: date - * @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. - */ - from?: string; - /** - * Format: date - * @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. - */ - to?: string; - }; - IncomeInvestmentScreenDto: { - accountId: string; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - screen: "income"; - /** - * Format: date - * @description Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month. - */ - from?: string; - /** - * Format: date - * @description Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now. - */ - to?: string; - }; - SyncCandleRangeDto: { - /** Format: date-time */ - from: string; - /** Format: date-time */ - to: string; - }; - TimestampInvestmentScreenDto: { - accountId?: string; - /** Format: uuid */ - instrumentUid?: string; - query?: string; - /** @enum {string} */ - candlePeriod?: "month" | "halfYear" | "year" | "all"; - candleRange?: components["schemas"]["SyncCandleRangeDto"]; - categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[]; - limit?: number; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - screen: "accounts" | "account" | "operations" | "stocks" | "bonds" | "stock" | "bond"; - /** - * Format: date-time - * @description Start instant for operations. - */ - from?: string; - /** - * Format: date-time - * @description End instant for operations. - */ - to?: string; - }; - TbankSyncStatusDto: { - /** Format: uuid */ - id: string; - scope: (components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"]) | null; - /** Format: date-time */ - now: string; - /** @enum {string} */ - state: "running" | "succeeded" | "failed" | "cancelled"; - errorCode: string | null; - }; }; responses: never; parameters: never; @@ -3525,6 +3530,136 @@ export interface operations { }; }; }; + PerformanceController_all: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PerformanceResourceDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + PerformanceController_account: { + parameters: { + query?: never; + header?: never; + path: { + accountId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PerformanceResourceDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; BrokerageOperationsController_all: { parameters: { query?: { @@ -3673,9 +3808,7 @@ export interface operations { }; IncomeAnalyticsController_allSummary: { parameters: { - query?: { - sync?: "observe"; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -3740,7 +3873,6 @@ export interface operations { IncomeAnalyticsController_allAnalytics: { parameters: { query?: { - sync?: "observe"; /** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */ from?: string; /** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */ @@ -3809,9 +3941,7 @@ export interface operations { }; IncomeAnalyticsController_summary: { parameters: { - query?: { - sync?: "observe"; - }; + query?: never; header?: never; path: { accountId: string; @@ -3878,7 +4008,6 @@ export interface operations { IncomeAnalyticsController_analytics: { parameters: { query?: { - sync?: "observe"; /** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */ from?: string; /** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */ @@ -3950,7 +4079,6 @@ export interface operations { UpcomingEventsController_all: { parameters: { query?: { - sync?: "observe"; /** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */ from?: string; /** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */ @@ -4020,7 +4148,6 @@ export interface operations { UpcomingEventsController_get: { parameters: { query?: { - sync?: "observe"; /** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */ from?: string; /** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */ @@ -4092,7 +4219,6 @@ export interface operations { StockController_search: { parameters: { query: { - sync?: "observe"; query: string; }; header?: never; @@ -4158,9 +4284,7 @@ export interface operations { }; StockController_getByTicker: { parameters: { - query?: { - sync?: "observe"; - }; + query?: never; header?: never; path: { ticker: string; @@ -4362,9 +4486,7 @@ export interface operations { }; StockController_get: { parameters: { - query?: { - sync?: "observe"; - }; + query?: never; header?: never; path: { uid: string; @@ -4430,9 +4552,7 @@ export interface operations { }; StockController_quote: { parameters: { - query?: { - sync?: "observe"; - }; + query?: never; header?: never; path: { uid: string; @@ -4499,7 +4619,6 @@ export interface operations { StockController_candles: { parameters: { query?: { - sync?: "observe"; period?: "month" | "halfYear" | "year" | "all"; }; header?: never; @@ -4633,9 +4752,7 @@ export interface operations { }; StockController_company: { parameters: { - query?: { - sync?: "observe"; - }; + query?: never; header?: never; path: { uid: string; @@ -4701,9 +4818,7 @@ export interface operations { }; StockController_dividends: { parameters: { - query?: { - sync?: "observe"; - }; + query?: never; header?: never; path: { uid: string; @@ -4769,9 +4884,7 @@ export interface operations { }; BondController_metrics: { parameters: { - query?: { - sync?: "observe"; - }; + query?: never; header?: never; path: { uid: string; @@ -4838,7 +4951,6 @@ export interface operations { BondController_search: { parameters: { query: { - sync?: "observe"; query: string; }; header?: never; @@ -4904,9 +5016,7 @@ export interface operations { }; BondController_getByTicker: { parameters: { - query?: { - sync?: "observe"; - }; + query?: never; header?: never; path: { ticker: string; @@ -4972,9 +5082,7 @@ export interface operations { }; BondController_get: { parameters: { - query?: { - sync?: "observe"; - }; + query?: never; header?: never; path: { uid: string; @@ -5040,9 +5148,7 @@ export interface operations { }; BondController_quote: { parameters: { - query?: { - sync?: "observe"; - }; + query?: never; header?: never; path: { uid: string; @@ -5109,7 +5215,6 @@ export interface operations { BondController_candles: { parameters: { query?: { - sync?: "observe"; period?: "month" | "halfYear" | "year" | "all"; }; header?: never; @@ -5243,9 +5348,7 @@ export interface operations { }; BondController_coupons: { parameters: { - query?: { - sync?: "observe"; - }; + query?: never; header?: never; path: { uid: string; @@ -5503,70 +5606,4 @@ export interface operations { }; }; }; - TbankSyncController_status: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["TbankSyncStatusDto"]; - }; - }; - /** @description Invalid request */ - 400: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; - /** @description Authentication required */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; - /** @description Resource not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; - /** @description Internal server error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; - /** @description Upstream service unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/problem+json": components["schemas"]["ProblemDetailsDto"]; - }; - }; - }; - }; } diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index 128b737..9c24e25 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -2232,6 +2232,167 @@ ] } }, + "/api/v1/brokerage-accounts/all/performance": { + "get": { + "operationId": "PerformanceController_all", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PerformanceResourceDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "brokerage-accounts" + ] + } + }, + "/api/v1/brokerage-accounts/{accountId}/performance": { + "get": { + "operationId": "PerformanceController_account", + "parameters": [ + { + "name": "accountId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PerformanceResourceDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "brokerage-accounts" + ] + } + }, "/api/v1/brokerage-accounts/all/operations": { "get": { "operationId": "BrokerageOperationsController_all", @@ -2529,19 +2690,7 @@ "/api/v1/brokerage-accounts/all/income-summary": { "get": { "operationId": "IncomeAnalyticsController_allSummary", - "parameters": [ - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } - } - ], + "parameters": [], "responses": { "200": { "description": "", @@ -2618,17 +2767,6 @@ "get": { "operationId": "IncomeAnalyticsController_allAnalytics", "parameters": [ - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } - }, { "name": "from", "required": false, @@ -2733,17 +2871,6 @@ "schema": { "type": "string" } - }, - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } } ], "responses": { @@ -2830,17 +2957,6 @@ "type": "string" } }, - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } - }, { "name": "from", "required": false, @@ -2938,17 +3054,6 @@ "get": { "operationId": "UpcomingEventsController_all", "parameters": [ - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } - }, { "name": "from", "required": false, @@ -3054,17 +3159,6 @@ "type": "string" } }, - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } - }, { "name": "from", "required": false, @@ -3162,17 +3256,6 @@ "get": { "operationId": "StockController_search", "parameters": [ - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } - }, { "name": "query", "required": true, @@ -3270,17 +3353,6 @@ "pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]*$", "type": "string" } - }, - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } } ], "responses": { @@ -3548,17 +3620,6 @@ "schema": { "type": "string" } - }, - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } } ], "responses": { @@ -3644,17 +3705,6 @@ "schema": { "type": "string" } - }, - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } } ], "responses": { @@ -3741,17 +3791,6 @@ "type": "string" } }, - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } - }, { "name": "period", "required": false, @@ -3936,17 +3975,6 @@ "schema": { "type": "string" } - }, - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } } ], "responses": { @@ -4032,17 +4060,6 @@ "schema": { "type": "string" } - }, - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } } ], "responses": { @@ -4128,17 +4145,6 @@ "schema": { "type": "string" } - }, - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } } ], "responses": { @@ -4217,17 +4223,6 @@ "get": { "operationId": "BondController_search", "parameters": [ - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } - }, { "name": "query", "required": true, @@ -4325,17 +4320,6 @@ "pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]*$", "type": "string" } - }, - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } } ], "responses": { @@ -4421,17 +4405,6 @@ "schema": { "type": "string" } - }, - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } } ], "responses": { @@ -4517,17 +4490,6 @@ "schema": { "type": "string" } - }, - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } } ], "responses": { @@ -4614,17 +4576,6 @@ "type": "string" } }, - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } - }, { "name": "period", "required": false, @@ -4809,17 +4760,6 @@ "schema": { "type": "string" } - }, - { - "name": "sync", - "required": false, - "in": "query", - "schema": { - "type": "string", - "enum": [ - "observe" - ] - } } ], "responses": { @@ -5120,91 +5060,6 @@ "tbank-connection" ] } - }, - "/api/v1/tbank-sync/{id}": { - "get": { - "operationId": "TbankSyncController_status", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TbankSyncStatusDto" - } - } - } - }, - "400": { - "description": "Invalid request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, - "401": { - "description": "Authentication required", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, - "404": { - "description": "Resource not found", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, - "500": { - "description": "Internal server error", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - }, - "503": { - "description": "Upstream service unavailable", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetailsDto" - } - } - } - } - }, - "security": [ - { - "bearer": [] - } - ], - "tags": [ - "tbank-sync" - ] - } } }, "info": { @@ -6734,6 +6589,298 @@ "meta" ] }, + "PerformancePeriodDto": { + "type": "object", + "properties": { + "from": { + "type": "string", + "format": "date" + }, + "to": { + "type": "string", + "format": "date" + }, + "days": { + "type": "number" + } + }, + "required": [ + "from", + "to", + "days" + ] + }, + "PerformanceUnavailableReason": { + "type": "string", + "enum": [ + "ACCOUNT_VALUATION_DATES_DIFFER", + "AMBIGUOUS_RETURN", + "HISTORY_START_UNKNOWN", + "INDEX_HISTORY_UNAVAILABLE", + "NON_RUB_ACTIVITY", + "NO_SOLUTION", + "OPERATIONS_HISTORY_INCOMPLETE", + "PORTFOLIO_VALUATION_INCOMPLETE", + "UNCONFIRMED_TRANSFER", + "UNVALUED_SECURITIES_TRANSFER" + ] + }, + "PerformanceReturnDto": { + "type": "object", + "properties": { + "value": { + "type": "string", + "nullable": true, + "description": "Decimal fraction: 0.151 = 15.1%" + }, + "basis": { + "type": "string", + "enum": [ + "annual", + "period" + ] + }, + "unavailableReason": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/PerformanceUnavailableReason" + } + ] + } + }, + "required": [ + "value", + "basis", + "unavailableReason" + ] + }, + "PerformanceIndexDto": { + "type": "object", + "properties": { + "code": { + "type": "string", + "enum": [ + "MCFTRR", + "IMOEX" + ] + }, + "value": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "return": { + "$ref": "#/components/schemas/PerformanceReturnDto" + }, + "asOf": { + "type": "string", + "format": "date", + "nullable": true + }, + "unavailableReason": { + "nullable": true, + "allOf": [ + { + "$ref": "#/components/schemas/PerformanceUnavailableReason" + } + ] + } + }, + "required": [ + "code", + "value", + "return", + "asOf", + "unavailableReason" + ] + }, + "PerformanceOutperformanceDto": { + "type": "object", + "properties": { + "percentagePoints": { + "type": "string", + "description": "Difference between rates as decimal fractions" + }, + "money": { + "$ref": "#/components/schemas/MoneyDto" + } + }, + "required": [ + "percentagePoints", + "money" + ] + }, + "PerformanceSeriesPointDto": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "netInvested": { + "type": "string" + }, + "benchmarkValue": { + "type": "string" + }, + "referenceValue": { + "type": "string", + "nullable": true + } + }, + "required": [ + "date", + "netInvested", + "benchmarkValue", + "referenceValue" + ] + }, + "PerformanceExcludedAccountDto": { + "type": "object", + "properties": { + "accountId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "reason": { + "allOf": [ + { + "$ref": "#/components/schemas/PerformanceUnavailableReason" + } + ] + } + }, + "required": [ + "accountId", + "name", + "reason" + ] + }, + "PerformanceDto": { + "type": "object", + "properties": { + "accountId": { + "type": "string" + }, + "period": { + "$ref": "#/components/schemas/PerformancePeriodDto" + }, + "netInvested": { + "$ref": "#/components/schemas/MoneyDto" + }, + "currentValue": { + "$ref": "#/components/schemas/MoneyDto" + }, + "profit": { + "$ref": "#/components/schemas/MoneyDto" + }, + "return": { + "$ref": "#/components/schemas/PerformanceReturnDto" + }, + "benchmark": { + "$ref": "#/components/schemas/PerformanceIndexDto" + }, + "reference": { + "$ref": "#/components/schemas/PerformanceIndexDto" + }, + "outperformance": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/PerformanceOutperformanceDto" + } + ] + }, + "series": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PerformanceSeriesPointDto" + } + }, + "completeness": { + "type": "string", + "enum": [ + "complete", + "incomplete" + ] + }, + "excludedAccounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PerformanceExcludedAccountDto" + } + }, + "calculatedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "accountId", + "period", + "netInvested", + "currentValue", + "profit", + "return", + "benchmark", + "reference", + "outperformance", + "series", + "completeness", + "excludedAccounts", + "calculatedAt" + ] + }, + "PerformanceMetaDto": { + "type": "object", + "properties": { + "dependencies": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InvestmentDependencyDto" + } + }, + "unavailable": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PerformanceExcludedAccountDto" + } + } + }, + "required": [ + "dependencies", + "unavailable" + ] + }, + "PerformanceResourceDto": { + "type": "object", + "properties": { + "data": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/PerformanceDto" + } + ] + }, + "meta": { + "$ref": "#/components/schemas/PerformanceMetaDto" + } + }, + "required": [ + "data", + "meta" + ] + }, "BrokerageOperationDto": { "type": "object", "properties": { @@ -9322,211 +9469,6 @@ "required": [ "token" ] - }, - "EventsInvestmentScreenDto": { - "type": "object", - "properties": { - "accountId": { - "type": "string" - }, - "screen": { - "type": "string", - "enum": [ - "events" - ] - }, - "from": { - "type": "string", - "format": "date", - "description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC." - }, - "to": { - "type": "string", - "format": "date", - "description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end." - } - }, - "required": [ - "accountId", - "screen" - ] - }, - "IncomeInvestmentScreenDto": { - "type": "object", - "properties": { - "accountId": { - "type": "string" - }, - "screen": { - "type": "string", - "enum": [ - "income" - ] - }, - "from": { - "type": "string", - "format": "date", - "description": "Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month." - }, - "to": { - "type": "string", - "format": "date", - "description": "Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now." - } - }, - "required": [ - "accountId", - "screen" - ] - }, - "SyncCandleRangeDto": { - "type": "object", - "properties": { - "from": { - "type": "string", - "format": "date-time" - }, - "to": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "from", - "to" - ] - }, - "TimestampInvestmentScreenDto": { - "type": "object", - "properties": { - "accountId": { - "type": "string" - }, - "instrumentUid": { - "type": "string", - "format": "uuid" - }, - "query": { - "type": "string" - }, - "candlePeriod": { - "type": "string", - "enum": [ - "month", - "halfYear", - "year", - "all" - ] - }, - "candleRange": { - "$ref": "#/components/schemas/SyncCandleRangeDto" - }, - "categories": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "trade", - "income", - "tax", - "fee", - "transfer", - "other" - ] - } - }, - "limit": { - "type": "number", - "minimum": 1, - "maximum": 1000 - }, - "screen": { - "type": "string", - "enum": [ - "accounts", - "account", - "operations", - "stocks", - "bonds", - "stock", - "bond" - ] - }, - "from": { - "type": "string", - "format": "date-time", - "description": "Start instant for operations." - }, - "to": { - "type": "string", - "format": "date-time", - "description": "End instant for operations." - } - }, - "required": [ - "screen" - ] - }, - "TbankSyncStatusDto": { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "scope": { - "oneOf": [ - { - "$ref": "#/components/schemas/IncomeInvestmentScreenDto" - }, - { - "$ref": "#/components/schemas/EventsInvestmentScreenDto" - }, - { - "$ref": "#/components/schemas/TimestampInvestmentScreenDto" - } - ], - "discriminator": { - "propertyName": "screen", - "mapping": { - "income": "#/components/schemas/IncomeInvestmentScreenDto", - "events": "#/components/schemas/EventsInvestmentScreenDto", - "accounts": "#/components/schemas/TimestampInvestmentScreenDto", - "account": "#/components/schemas/TimestampInvestmentScreenDto", - "operations": "#/components/schemas/TimestampInvestmentScreenDto", - "stocks": "#/components/schemas/TimestampInvestmentScreenDto", - "bonds": "#/components/schemas/TimestampInvestmentScreenDto", - "stock": "#/components/schemas/TimestampInvestmentScreenDto", - "bond": "#/components/schemas/TimestampInvestmentScreenDto" - } - }, - "nullable": true - }, - "now": { - "type": "string", - "format": "date-time" - }, - "state": { - "type": "string", - "enum": [ - "running", - "succeeded", - "failed", - "cancelled" - ] - }, - "errorCode": { - "type": "string", - "nullable": true - } - }, - "required": [ - "id", - "scope", - "now", - "state", - "errorCode" - ] } } } 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: "Портфель", diff --git a/src/shared/lib/display.test.ts b/src/shared/lib/display.test.ts index fae1dce..9658726 100644 --- a/src/shared/lib/display.test.ts +++ b/src/shared/lib/display.test.ts @@ -3,12 +3,22 @@ import { describe, expect, it } from "vitest"; import { formatDate, formatMoney, + formatPercentagePoints, + formatSignedPercent, formatSnapshotDay, formatSnapshotTime, instrumentLogoUrl, } from "./display"; describe("shared display helpers", () => { + it("formats signed rates as percentages and percentage points without negative zero", () => { + expect(formatSignedPercent("0.151")).toBe("+15,1 %"); + expect(formatSignedPercent("-0.15149")).toBe("−15,1 %"); + expect(formatSignedPercent("0")).toBe("0,0 %"); + expect(formatSignedPercent("-0.00049")).toBe("0,0 %"); + expect(formatPercentagePoints("0.043")).toBe("+4,3 п.п."); + expect(formatPercentagePoints("-0.0435")).toBe("−4,4 п.п."); + }); it("uses padded Russian dates and preserves the UTC date of timestamps", () => { expect(formatDate("2027-09-10")).toBe("10.09.2027"); expect(formatDate("2024-02-29")).toBe("29.02.2024"); diff --git a/src/shared/lib/display.ts b/src/shared/lib/display.ts index 48b6910..2fb8a0f 100644 --- a/src/shared/lib/display.ts +++ b/src/shared/lib/display.ts @@ -1,3 +1,5 @@ +import { decimalFromString } from "./decimal"; + const DECIMAL_PART_COUNT = 2; const DEFAULT_MINOR_UNITS = 2; @@ -99,6 +101,29 @@ function formatMoney( return value.startsWith("-") ? `−${formatted.slice(1)}` : `+${formatted}`; } +function formatPercentagePoints(rate: string): string { + return formatRate(rate, "п.п."); +} + +function formatRate(rate: string, suffix: string): string { + const { scale, value } = decimalFromString(rate); + const abs = value < 0n ? -value : value; + const radix = 10n; + const half = 2n; + const tenthsPerUnit = 1000n; + const denominator = radix ** BigInt(scale); + const tenths = (abs * tenthsPerUnit + denominator / half) / denominator; + let sign = ""; + if (tenths !== 0n) sign = value < 0n ? "−" : "+"; + const formatted = `${formatDecimal(String(tenths / radix))},${tenths % radix}`; + + return `${sign}${formatted} ${suffix}`; +} + +function formatSignedPercent(rate: string): string { + return formatRate(rate, "%"); +} + function isZeroDecimal(value: string) { const unsigned = value.startsWith("-") ? value.slice(1) : value; @@ -171,6 +196,8 @@ export { formatDate, formatDecimal, formatMoney, + formatPercentagePoints, + formatSignedPercent, formatSnapshotDay, formatSnapshotTime, instrumentLogoUrl, diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index f64e6b3..fb2f8b8 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -12,6 +12,8 @@ export { formatDate, formatDecimal, formatMoney, + formatPercentagePoints, + formatSignedPercent, formatSnapshotDay, formatSnapshotTime, instrumentLogoUrl,