diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx index a189416..0363249 100644 --- a/src/app/routes/AppRoutes/AllAccounts.test.tsx +++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx @@ -46,8 +46,10 @@ const position: Portfolio["positions"][number] displayName: "Сбербанк", figi: "SBER", isin: null, + issuer: null, logoName: null, quantity: "2", + sector: null, ticker: "SBER", type: "STOCK", }; diff --git a/src/pages/account/model/concentration.test.ts b/src/pages/account/model/concentration.test.ts new file mode 100644 index 0000000..80fd3f0 --- /dev/null +++ b/src/pages/account/model/concentration.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from "vitest"; + +import type { Portfolio } from "@/entities/portfolio"; + +import { toConcentration } from "./concentration"; +import { portfolio, position } from "./positions.fixtures"; + +function snapshot(positions: Portfolio["positions"]): Portfolio { + return { ...portfolio, positions }; +} + +function stock( + name: string, + value: null | string, + currency = "RUB", + overrides: Partial = {}, +): Portfolio["positions"][number] { + return { + ...position, + currentValue: value === null ? null : { currency, value }, + displayName: name, + ...overrides, + }; +} + +const labels = { + missingIssuer: "Эмитент не указан", + missingSector: "Сектор не указан", + other: "Прочее", + sector: (code: string) => code, +}; + +describe("account concentration", () => { + it("uses exact positive values and excludes cash, negatives and nulls", () => { + const model = toConcentration(snapshot([ + stock("A", "1"), stock("B", "15"), + stock("C", "0"), stock("D", "-50"), stock("E", null), + stock("F", null, "USD"), stock("G", "-3", "USD"), + ]), "stocks", "RUB", labels); + + expect(model.currencies).toEqual(["RUB", "USD"]); + expect(model.total).toBe("16"); + expect(model.items.map(item => item.share)).toEqual(["93,8 %", "6,3 %"]); + expect(model.unaccounted).toBe(3); + const negative = snapshot([stock("A", "-3", "USD")]); + expect(toConcentration(negative, "stocks", "USD", labels)) + .toMatchObject({ empty: true, unaccounted: 1 }); + }); + + it("ranks seven positions with exact other and top-five totals", () => { + const model = toConcentration(snapshot([ + stock("A", "400"), stock("B", "250"), stock("C", "150"), + stock("D", "100"), stock("E", "50"), stock("F", "30"), + stock("G", "20"), + ]), "stocks", "RUB", labels); + + expect(model.items.map(item => item.amount)).toEqual([ + "400", "250", "150", "100", "50", + ]); + expect(model.other).toMatchObject({ + amount: "50", count: 2, share: "5 %", + }); + expect(model.top5Share).toBe("95 %"); + expect(model.other?.entries.map(item => item.share)).toEqual([ + "3 %", "2 %", + ]); + }); + + it("keeps sixth row, tiny share and stable alphabetical ties", () => { + const model = toConcentration(snapshot([ + stock("Сбер", "100"), stock("X5", "100"), + stock("VK", "100"), stock("D", "100"), + stock("E", "100"), stock("F", "100"), + ]), "stocks", "RUB", labels); + + expect(model.items).toHaveLength(6); + expect(model.other).toBeNull(); + expect(model.items.slice(0, 2).map(item => item.name)).toEqual([ + "D", "E", + ]); + + const tiny = snapshot([ + stock("Large", "100000000"), stock("Small", "1"), + ]); + + expect(toConcentration(tiny, "stocks", "RUB", labels).items[1]?.share) + .toBe("< 0,1 %"); + }); + + it("groups sectors and issuers by codes, not labels", () => { + const positions = [ + stock("A", "300", "RUB", { + issuer: { id: "one", name: "Сбербанк" }, sector: "energy", + }), + stock("B", "200", "RUB", { + issuer: { id: "one", name: "Сбер" }, sector: "energy", + type: "BOND", + }), + stock("C", "100", "RUB", { + issuer: { id: "two", name: "Сбер" }, sector: "financial", + type: "FUND", + }), + stock("D", "50", "RUB", { issuer: null, sector: null }), + ]; + + expect(toConcentration(snapshot(positions), "sectors", "RUB", labels) + .items.map(item => item.amount)).toEqual(["500", "100", "50"]); + expect(toConcentration(snapshot(positions), "issuers", "RUB", labels) + .items.map(item => [item.name, item.amount])).toEqual([ + ["Сбербанк", "500"], ["", "50"], + ]); + }); + + it("orders equal sums by displayed name", () => { + const issuers = snapshot([ + stock("A", "300", "RUB", { + issuer: { id: "g", name: "Газпром" }, sector: "energy", + }), + stock("B", "300", "RUB", { + issuer: { id: "s", name: "Сбер" }, sector: "energy", + }), + stock("C", "200", "RUB", { issuer: null, sector: "energy" }), + stock("D", "100", "RUB", { issuer: null, sector: "energy" }), + ]); + + expect(toConcentration(issuers, "issuers", "RUB", labels) + .items.map(item => [item.label, item.amount])).toEqual([ + ["Газпром", "300"], ["Сбер", "300"], ["Эмитент не указан", "300"], + ]); + + const sectors = snapshot([ + stock("A", "100", "RUB", { sector: "it" }), + stock("B", "100", "RUB", { sector: "energy" }), + ]); + + const sectorLabels = { + ...labels, + sector: (code: string) => code === "it" + ? "Информационные технологии" + : "Энергетика", + }; + + expect(toConcentration(sectors, "sectors", "RUB", sectorLabels) + .items.map(item => item.label)) + .toEqual(["Информационные технологии", "Энергетика"]); + }); + + it("chooses issuer name from the largest position, then alphabetically", () => { + const data = snapshot([ + stock("A", "100", "RUB", { + issuer: { id: "brand", name: "Ястреб" }, + }), + stock("B", "300", "RUB", { + issuer: { id: "brand", name: "Альфа" }, + }), + stock("C", "400", "RUB", { + issuer: { id: "other", name: "Бета" }, + }), + ]); + + expect(toConcentration(data, "issuers", "RUB", labels).items + .map(item => [item.name, item.label, item.amount])).toEqual([ + ["Альфа", "Альфа", "400"], ["Бета", "Бета", "400"], + ]); + }); + + it("uses ISIN before ticker for bonds", () => { + const data = snapshot([stock("Облигация", "100", "RUB", { + isin: "RU000A0JX0J2", ticker: "BOND", type: "BOND", + })]); + + expect(toConcentration(data, "bonds", "RUB", labels) + .items[0]?.logo?.subtitle).toBe("RU000A0JX0J2"); + }); +}); diff --git a/src/pages/account/model/concentration.ts b/src/pages/account/model/concentration.ts new file mode 100644 index 0000000..e9eb18a --- /dev/null +++ b/src/pages/account/model/concentration.ts @@ -0,0 +1,300 @@ +import type { Portfolio } from "@/entities/portfolio"; + +import { + type Decimal, + decimalFromString, + decimalToString, + instrumentLogoUrl, + sumDecimals, +} from "@/shared/lib"; + +type Concentration = { + currencies: string[]; + empty: boolean; + items: ConcentrationItem[]; + other: ConcentrationOther | null; + top5Share: null | string; + total: null | string; + unaccounted: number; +}; + +type ConcentrationItem = { + amount: string; + chartValue: number; + key: string; + label: string; + logo: ConcentrationLogo | null; + name: string; + share: string; + unknown: boolean; +}; + +type ConcentrationLabels = { + missingIssuer: string; + missingSector: string; + other: string; + sector: (code: string) => string; +}; + +type ConcentrationLogo = { + src: null | string; + subtitle: null | string; +}; + +type ConcentrationOther = ConcentrationItem & { + count: number; + entries: ConcentrationItem[]; +}; + +type Group = { + best?: { name: string; value: Decimal }; + first: number; + key: string; + label: string; + logo: ConcentrationLogo | null; + name: string; + unknown: boolean; + values: Decimal[]; +}; + +type Position = Portfolio["positions"][number]; + +type Slice = "bonds" + | "issuers" + | "sectors" + | "stocks"; + +const eligibleTypes = new Set(["BOND", "FUND", "STOCK"]); +const RADIX = 10n; +const CHART_PRECISION = 1_000_000n; +const SHARE_PRECISION = 1000n; +const HALF = 2n; +const TOP = 5; +const FIRST_OTHER = 7; + +const compareNames = (a: string, b: string) => + a.toLowerCase().localeCompare(b.toLowerCase(), "en", { + numeric: false, + }); + +function addPosition( + groups: Map, + position: Position, + index: number, + slice: Slice, + labels: ConcentrationLabels, + selected: null | string, +): number { + if (!isIncluded(slice, position.type)) return 0; + if (!position.currentValue) return 1; + if (position.currentValue.currency !== selected) return 0; + const value = decimalFromString(position.currentValue.value); + if (value.value < 0n) return 1; + if (value.value === 0n) return 0; + const identity = groupIdentity(position, slice, index, labels); + + const group = groups.get(identity.key) ?? { + ...identity, first: index, values: [], + }; + + group.values.push(value); + + if (slice === "issuers" && !identity.unknown) { + const best = group.best; + + if (!best || compareDecimal(value, best.value) > 0 + || (compareDecimal(value, best.value) === 0 + && compareNames(identity.name, best.name) < 0)) { + group.best = { name: identity.name, value }; + group.name = identity.name; + group.label = identity.label; + } + } + + groups.set(identity.key, group); + + return 0; +} + +function chartValue(part: Decimal, total: Decimal): number { + const scale = Math.max(part.scale, total.scale); + const numerator = part.value * RADIX ** BigInt(scale - part.scale); + const denominator = total.value * RADIX ** BigInt(scale - total.scale); + + return Number(numerator * CHART_PRECISION / denominator) + / Number(CHART_PRECISION); +} + +function compareDecimal(a: Decimal, b: Decimal): number { + const scale = Math.max(a.scale, b.scale); + const left = a.value * RADIX ** BigInt(scale - a.scale); + const right = b.value * RADIX ** BigInt(scale - b.scale); + + if (left > right) return 1; + + return left < right ? -1 : 0; +} + +function concentrationCurrencies(portfolio: Portfolio): string[] { + return [...new Set(portfolio.positions + .filter(position => eligibleTypes.has(position.type)) + .filter(position => position.currentValue + && decimalFromString(position.currentValue.value).value !== 0n) + .map(position => position.currentValue?.currency) + .filter((value): value is string => !!value))] + .toSorted((a, b) => a.localeCompare(b)); +} + +function groupIdentity( + position: Position, + slice: Slice, + index: number, + labels: ConcentrationLabels, +) { + if (slice === "sectors") { + const sector = position.sector; + const isUnknown = !sector; + + return { + key: sector ?? "", + label: isUnknown + ? labels.missingSector + : labels.sector(sector), + logo: null, + name: sector ?? "", + unknown: isUnknown, + }; + } + + if (slice === "issuers") { + const isUnknown = !position.issuer; + + return { + key: position.issuer?.id ?? "", + label: position.issuer ? position.issuer.name : labels.missingIssuer, + logo: null, + name: position.issuer?.name ?? "", + unknown: isUnknown, + }; + } + + return { + key: String(index), + label: position.displayName, + logo: { + src: instrumentLogoUrl(position.logoName), + subtitle: slice === "bonds" + ? position.isin ?? position.ticker ?? position.figi + : position.ticker ?? position.isin ?? position.figi, + }, + name: position.displayName, + unknown: false, + }; +} + +function isIncluded(slice: Slice, type: string): boolean { + if (slice === "stocks") return type === "STOCK"; + if (slice === "bonds") return type === "BOND"; + + return slice === "issuers" ? type === "STOCK" || type === "BOND" : eligibleTypes.has(type); +} + +function share(part: Decimal, total: Decimal): string { + const scale = Math.max(part.scale, total.scale); + const numerator = part.value * RADIX ** BigInt(scale - part.scale); + const denominator = total.value * RADIX ** BigInt(scale - total.scale); + if (numerator * SHARE_PRECISION < denominator) return "< 0,1 %"; + + const tenths = (numerator * SHARE_PRECISION * HALF + denominator) + / (HALF * denominator); + + return tenths % RADIX === 0n ? `${tenths / RADIX} %` : `${tenths / RADIX},${tenths % RADIX} %`; +} + +function toConcentration( + portfolio: Portfolio, + slice: Slice, + currency: null | string, + labels: ConcentrationLabels, +): Concentration { + const currencies = concentrationCurrencies(portfolio); + + const selected = currency && currencies.includes(currency) + ? currency + : currencies[0] ?? null; + + const groups = new Map(); + let unaccounted = 0; + + for (const [index, position] of portfolio.positions.entries()) { + unaccounted += addPosition( + groups, position, index, slice, labels, selected, + ); + } + + const ranked = Array.from(groups.values(), group => ({ + ...group, sum: sumDecimals(group.values), + })).toSorted((a, b) => compareDecimal(b.sum, a.sum) + || compareNames(a.label, b.label) || a.first - b.first); + + const total = ranked.length > 0 + ? sumDecimals(ranked.map(item => item.sum)) + : null; + + const entries = total + ? ranked.map(group => ({ + amount: decimalToString(group.sum), + chartValue: chartValue(group.sum, total), + key: group.key, + label: group.label, + logo: group.logo, + name: group.name, + share: share(group.sum, total), + unknown: group.unknown, + })) + : []; + + const rest = entries.length >= FIRST_OTHER ? entries.slice(TOP) : []; + + const otherSum = rest.length > 0 + ? sumDecimals(ranked.slice(TOP).map(item => item.sum)) + : null; + + const other = otherSum && total + ? { + amount: decimalToString(otherSum), + chartValue: chartValue(otherSum, total), + count: rest.length, + entries: rest, + key: "__other", + label: labels.other, + logo: null, + name: "", share: share(otherSum, total), unknown: false, + } + : null; + + const top5 = ranked.length > TOP + ? sumDecimals(ranked.slice(0, TOP).map(item => item.sum)) + : null; + + return { + currencies, + empty: !total, + items: other ? entries.slice(0, TOP) : entries, + other, + top5Share: top5 && total ? share(top5, total) : null, + total: total && decimalToString(total), + unaccounted, + }; +} + +export { + concentrationCurrencies, + toConcentration, +}; +export type { + Concentration, + ConcentrationItem, + ConcentrationLabels, + Slice, +}; diff --git a/src/pages/account/model/positions.fixtures.ts b/src/pages/account/model/positions.fixtures.ts index 54508f6..ff4718e 100644 --- a/src/pages/account/model/positions.fixtures.ts +++ b/src/pages/account/model/positions.fixtures.ts @@ -13,8 +13,10 @@ const portfolio displayName: "Акция", figi: "FIGI-STOCK", isin: null, + issuer: null, logoName: "akciya.png", quantity: "2", + sector: null, ticker: null, type: "STOCK", }, @@ -25,8 +27,10 @@ const portfolio displayName: "Облигация", figi: "FIGI-BOND", isin: "RU000A0JX0J2", + issuer: null, logoName: null, quantity: "1", + sector: null, ticker: null, type: "BOND", }, @@ -37,8 +41,10 @@ const portfolio displayName: "Фонд", figi: "FIGI-FUND", isin: null, + issuer: null, logoName: "fond.png", quantity: "3", + sector: null, ticker: null, type: "FUND", }, diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 5c4e610..296a08a 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -8,7 +8,9 @@ import { within, } from "@testing-library/react"; import { http, HttpResponse } from "msw"; -import { MemoryRouter, Route, Routes } from "react-router"; +import { useState } from "react"; +import { getI18n } from "react-i18next"; +import { MemoryRouter, Route, Routes, useNavigate } from "react-router"; import { describe, expect, it, vi } from "vitest"; import type { BrokerageOperation } from "@/entities/broker-operation"; @@ -25,6 +27,7 @@ import type { AccountPageRecentOperations, } from "../../model/previews"; +import { toConcentration } from "../../model/concentration"; import { toAccountPagePositionSections, toUnrealizedReturns, @@ -35,6 +38,19 @@ import { AccountPageError } from "./components/AccountPageError.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view"; +function AccountSwitcher() { + const navigate = useNavigate(); + + return ( + + ); +} + function configureTestAuth() { configureAuthClient({ accessToken: () => "test-token", @@ -50,6 +66,8 @@ function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") { + + } path="/accounts/:accountId" /> @@ -84,8 +102,10 @@ const portfolio displayName: "Вторая", figi: "FIGI-2", isin: null, + issuer: null, logoName: "vtoraya.png", quantity: "2", + sector: null, ticker: null, type: "STOCK", }, @@ -96,8 +116,10 @@ const portfolio displayName: "Первая", figi: "FIGI-1", isin: "RU000A0JX0J2", + issuer: null, logoName: null, quantity: "1", + sector: null, ticker: null, type: "BOND", }, @@ -116,8 +138,10 @@ function position( displayName: "Инструмент", figi: null, isin: null, + issuer: null, logoName: null, quantity: "1", + sector: null, ticker: null, type: "STOCK", ...overrides, @@ -134,6 +158,19 @@ const emptyRecentEvents: AccountPageRecentEvents = { to: "/accounts/account-1/events", }; +function concentrationLabels() { + const i18n = getI18n(); + + return { + missingIssuer: i18n.t("portfolio.concentration.missingIssuer"), + missingSector: i18n.t("portfolio.concentration.missingSector"), + other: i18n.t("portfolio.concentration.other"), + sector: (code: string) => i18n.t(`stock.sectors.${code}`, { + defaultValue: code, + }), + }; +} + function emptyProps() { return { recentEvents: emptyRecentEvents, @@ -141,17 +178,357 @@ function emptyProps() { }; } +const LoadedHarness = ( + props: Omit[0]>, "onTabChange">, +) => { + const [tab, setTab] = useState(props.tab ?? "positions"); + + return ( + setTab(value ?? "positions")} + tab={tab} + /> + ); +}; + function loadedProps(nextPortfolio: Portfolio = portfolio) { return { + concentration: null, + currency: null, + onCurrencyChange: vi.fn(), + onSliceChange: vi.fn(), portfolio: nextPortfolio, positionSections: toAccountPagePositionSections(nextPortfolio), recentEvents: emptyRecentEvents, recentOperations: emptyRecentOperations, + slice: "stocks" as const, unrealizedReturns: toUnrealizedReturns(nextPortfolio.positions), }; } describe("AccountPage states", () => { + it("shows concentration only for a loaded individual account", async () => { + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json(resource({ + ...portfolio, + positions: [position({ + currentValue: { currency: "RUB", value: "100" }, + displayName: "Акция", + })], + })), + ), + ); + const client = createTestQueryClient(); + renderAccountRoute(client); + const tab = await screen.findByRole("tab", { name: "Концентрация" }); + expect(tab).toHaveAttribute("aria-selected", "false"); + fireEvent.click(tab); + expect(screen.getAllByText("100 ₽")).toHaveLength(2); + expect(screen.getByRole("img", { name: "Распределение позиций" })) + .toHaveAccessibleDescription(/Акция: 100 ₽, 100 %/); + }); + + it("expands and resets other when switching slice", () => { + const positions = [400, 250, 150, 100, 50, 30, 20].map( + (value, index) => position({ + currentValue: { currency: "RUB", value: String(value) }, + displayName: `Акция ${index + 1}`, + }), + ); + + const data = { ...portfolio, positions }; + + const model = toConcentration( + data, "stocks", "RUB", concentrationLabels(), + ); + + const props = loadedProps(data); + renderPage( + , + ); + const other = screen.getByRole("button", { name: /Прочее/ }); + expect(other).toHaveAttribute("aria-expanded", "false"); + fireEvent.click(other); + expect(other).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("Акция 7")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("radio", { name: "Секторы" })); + expect(props.onSliceChange).toHaveBeenCalledWith("sectors"); + }); + + it.each([ + [2, "2 элемента"], + [5, "5 элементов"], + [21, "21 элемент"], + ])("pluralizes the other count %d as %s", (count, expected) => { + const values = [400, 250, 150, 100, 50]; + + const rest = Array.from( + { length: count }, + (_, index) => 20 + index, + ); + + const positions = [...values, ...rest].map( + (value, index) => position({ + currentValue: { currency: "RUB", value: String(value) }, + displayName: `Акция ${index + 1}`, + }), + ); + + renderPage( + , + ); + + const other = screen.getByRole("button", { name: /Прочее/ }); + expect(other).toHaveTextContent(expected); + }); + + it("shows ticker subtitles and stocks rows with logos, issuers without", () => { + const positions = [ + position({ + currentValue: { currency: "RUB", value: "300" }, + displayName: "Сбербанк", + ticker: "SBER", + }), + ]; + + const data = { ...portfolio, positions }; + + const view = renderPage( + , + ); + + expect(screen.getByText("SBER")).toBeInTheDocument(); + + view.unmount(); + + renderPage( + , + ); + + expect(screen.queryByText("SBER")).not.toBeInTheDocument(); + }); + + it("hides the currency select when only one currency is available", () => { + const data = { ...portfolio, positions: [position({ + currentValue: { currency: "RUB", value: "100" }, + displayName: "Акция", + })] }; + + renderPage( + , + ); + + expect(screen.queryByRole("combobox", { name: "Валюта концентрации" })) + .not.toBeInTheDocument(); + }); + + it("shows sector labels including government and unknown codes", () => { + const positions = [ + position({ + currentValue: { currency: "RUB", value: "300" }, + displayName: "А", + sector: "government", + }), + position({ + currentValue: { currency: "RUB", value: "100" }, + displayName: "Б", + sector: "unknown_code", + }), + ]; + + const data = { ...portfolio, positions }; + + renderPage( + , + ); + + expect(screen.getByText("Государственный сектор")).toBeInTheDocument(); + expect(screen.getByText("unknown_code")).toBeInTheDocument(); + }); + + it("describes ranked segments with amounts and shares", () => { + const data = { ...portfolio, positions: [ + position({ + currentValue: { currency: "RUB", value: "300" }, + displayName: "Первая акция", + }), + position({ + currentValue: { currency: "RUB", value: "100" }, + displayName: "Вторая акция", + }), + ...[50, 40, 30, 20, 10].map((value, index) => position({ + currentValue: { currency: "RUB", value: String(value) }, + displayName: `Остаток ${index + 1}`, + })), + ] }; + + renderPage( + , + ); + + const chart = screen.getByRole("img", { name: "Распределение позиций" }); + + expect(chart).toHaveAccessibleDescription( + /Первая акция: 300 ₽, 54,5 %; Вторая акция: 100 ₽, 18,2 %; Остаток 1: 50 ₽, 9,1 %; Остаток 2: 40 ₽, 7,3 %; Остаток 3: 30 ₽, 5,5 %; Прочее: 30 ₽, 5,5 %/, + ); + expect(screen.getByRole("button", { name: /Прочее/ })) + .toBeInTheDocument(); + }); + + it("does not duplicate the partial warning on the concentration tab", () => { + const data = { ...portfolio, positions: [position({ + currentValue: { currency: "RUB", value: "100" }, + displayName: "Акция", + })] }; + + renderPage( + , + ); + + expect(screen.getAllByRole("status")) + .toHaveLength(1); + }); + + it("keeps slice and currency while switching page tabs and resets on account change", async () => { + const multiCurrency = { ...portfolio, positions: + ["RUB", "USD"].flatMap(currency => + [400, 250, 150, 100, 50, 30, 20].map((value, index) => + position({ + currentValue: { currency, value: String(value) }, + displayName: `${currency} акция ${index + 1}`, + sector: "energy", type: "STOCK", + }), + ), + ) }; + + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json(resource(multiCurrency)), + ), + ); + const client = createTestQueryClient(); + renderAccountRoute(client); + + fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" })); + fireEvent.click(screen.getByRole("button", { name: /Прочее/ })); + expect(screen.getByRole("button", { name: /Прочее/ })) + .toHaveAttribute("aria-expanded", "true"); + fireEvent.click(screen.getByRole("combobox", { name: "Валюта концентрации" })); + fireEvent.click(screen.getByRole("option", { name: "USD" })); + expect(screen.getByRole("button", { name: /Прочее/ })) + .toHaveAttribute("aria-expanded", "false"); + fireEvent.click(screen.getByRole("button", { name: /Прочее/ })); + fireEvent.click(screen.getByRole("radio", { name: "Секторы" })); + fireEvent.click(screen.getByRole("radio", { name: "Акции" })); + expect(screen.getByRole("button", { name: /Прочее/ })) + .toHaveAttribute("aria-expanded", "false"); + fireEvent.click(screen.getByRole("radio", { name: "Секторы" })); + expect(screen.getByRole("combobox", { name: "Валюта концентрации" })) + .toHaveValue("USD"); + + fireEvent.click(screen.getByRole("tab", { name: "Позиции" })); + fireEvent.click(screen.getByRole("tab", { name: "Концентрация" })); + + expect(screen.getByRole("radio", { name: "Секторы" })) + .toBeChecked(); + expect(screen.getByRole("combobox", { name: "Валюта концентрации" })) + .toHaveValue("USD"); + fireEvent.click(screen.getByRole("button", { name: "Другой счёт" })); + fireEvent.click(await screen.findByRole("tab", { name: "Концентрация" })); + expect(screen.getByRole("radio", { name: "Акции" })).toBeChecked(); + expect(screen.getByRole("combobox", { name: "Валюта концентрации" })) + .toHaveValue("RUB"); + }); + + it("falls back to the first currency when the selected one disappears", () => { + const data = { ...portfolio, positions: [ + position({ + currentValue: { currency: "USD", value: "200" }, + }), + position({ + currentValue: { currency: "RUB", value: "100" }, + }), + ] }; + + const model = toConcentration( + data, "stocks", "EUR", concentrationLabels(), + ); + + expect(model.currencies).toEqual(["RUB", "USD"]); + expect(model.total).toBe("100"); + expect(model.items.map(item => item.amount)).toEqual(["100"]); + }); it("shows the loaded portfolio source sync status", async () => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => @@ -306,7 +683,7 @@ describe("AccountPage states", () => { it("shows supplied cash separately without adding currency instruments or recalculating totals", () => { renderPage( - { }); it("renders a stale portfolio in upstream order with nullable values hidden", () => { - renderPage(); + renderPage(); expect( screen.getByRole("heading", { level: 1, name: "Портфель" }), @@ -528,7 +905,7 @@ describe("AccountPage states", () => { "renders %s logos, fallbacks and signed returns including zero", (type) => { renderPage( - { "groups %s supporting metrics into quiet single lines", (type) => { renderPage( - { it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => { renderPage( - { it("keeps other-position cards compact with labeled return and no logo", () => { renderPage( - { it("uses the cached account title when available", () => { renderPage( - { it("keeps the portfolio visible when recent operations fail", () => { renderPage( - { it("uses the ledger row for a recent outgoing trade operation", () => { renderPage( - { }; renderPage( - { "keeps the upcoming %s state and calendar action", (state) => { renderPage( - , @@ -830,7 +1207,7 @@ describe("AccountPage states", () => { it("keeps the portfolio visible when upcoming events fail", () => { renderPage( - { }); it("opens positions by default and switches between local tabs", () => { - renderPage(); + renderPage(); expect(screen.getByRole("tab", { name: "Позиции" })) .toHaveAttribute("aria-selected", "true"); @@ -1012,7 +1389,7 @@ describe("AccountPage states", () => { it("links operation, event and income previews to their detail pages", () => { renderPage( - { }); it("filters positions by the kinds present in the portfolio", () => { - renderPage(); + renderPage(); expect( screen.getAllByRole("radio").map(radio => radio.value), @@ -1047,7 +1424,7 @@ describe("AccountPage states", () => { it("hides the position filter when only one kind exists", () => { renderPage( - { }); it("hides unrealized returns for a partial portfolio", () => { - renderPage(); + renderPage(); expect(screen.queryByRole("region", { name: "Нереализованный результат" })) .not.toBeInTheDocument(); @@ -1068,7 +1445,7 @@ describe("AccountPage states", () => { it("omits an unrealized currency total when any return is incomplete", () => { renderPage( - { ); diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index a68c0db..5ecf856 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -1,3 +1,5 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { useParams } from "react-router"; import type { Portfolio } from "@/entities/portfolio"; @@ -17,6 +19,13 @@ import { useScreenSync } from "@/features/tbank-connection"; import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api"; import { useInitialAvailability } from "@/shared/lib"; +import { + type Concentration, + concentrationCurrencies, + type ConcentrationLabels, + type Slice, + toConcentration, +} from "../../model/concentration"; import { type AccountPagePositionSection, toAccountPagePositionSections, @@ -45,10 +54,17 @@ type AccountPageEmptyViewModel = AccountPageContent & { }; type AccountPageLoadedViewModel = AccountPageContent & { + concentration: Concentration | null; + currency: null | string; + onCurrencyChange: (currency: null | string) => void; + onSliceChange: (slice: Slice) => void; + onTabChange: (tab: null | string) => void; portfolio: Portfolio; positionSections: AccountPagePositionSection[]; + slice: Slice; source?: InvestmentResourceMeta; state: "loaded"; + tab: string; unrealizedReturns: Portfolio["totalsByCurrency"]; }; @@ -66,6 +82,24 @@ function isPortfolioAccessFailure(code?: string) { function useAccountPageViewModel(): AccountPageViewModel { const { accountId } = useParams(); + const { t } = useTranslation(); + + const [selection, setSelection] = useState<{ + accountId?: string; + currency: null | string; + slice: Slice; + tab: string; + }>({ accountId, currency: null, slice: "stocks", tab: "positions" }); + + const current = selection.accountId === accountId + ? selection + : { + accountId, currency: null, slice: "stocks" as const, tab: "positions", + }; + + const updateSelection = (change: Partial) => + setSelection({ ...current, ...change }); + const portfolio = usePortfolio(accountId); const operations = useBrokerageOperations(accountId, { @@ -122,18 +156,42 @@ function useAccountPageViewModel(): AccountPageViewModel { }; const isPartial = portfolio.meta?.completeness === "incomplete"; + const currencies = concentrationCurrencies(portfolio.data); + + const currency = current.currency && currencies.includes(current.currency) + ? current.currency + : currencies[0] ?? null; + + const labels: ConcentrationLabels = { + missingIssuer: t("portfolio.concentration.missingIssuer"), + missingSector: t("portfolio.concentration.missingSector"), + other: t("portfolio.concentration.other"), + sector: code => t(`stock.sectors.${code}`, { defaultValue: code }), + }; + + const concentration = accountId !== "all" + && current.tab === "concentration" + ? toConcentration(portfolio.data, current.slice, currency, labels) + : null; return { account, accountId, + concentration, + currency, incomeSummary, + onCurrencyChange: value => updateSelection({ currency: value }), + onSliceChange: slice => updateSelection({ slice }), + onTabChange: tab => updateSelection({ tab: tab ?? "positions" }), partial: isPartial, portfolio: portfolio.data, positionSections: toAccountPagePositionSections(portfolio.data), recentEvents, recentOperations, + slice: current.slice, source: portfolio.meta, state: "loaded" as const, + tab: current.tab, unrealizedReturns: toUnrealizedReturns(portfolio.data.positions), }; } diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx index b8c9a99..8348501 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -18,6 +18,7 @@ import { formatMoney } from "@/shared/lib"; import { SourceSyncStatus } from "@/shared/ui"; import { PortfolioStructure } from "@/widgets/portfolio-structure"; +import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration"; import type { AccountPagePositionSection } from "../../../model/positions"; import type { AccountPageIncomeSummary, @@ -27,6 +28,7 @@ import type { import classes from "../AccountPage.module.css"; import { AccountHeading } from "./AccountHeading.view"; +import { Concentration } from "./Concentration/Concentration.view"; import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view"; import { IncomeSummary } from "./IncomeSummary.view"; import { PositionSection } from "./PositionSection.view"; @@ -35,19 +37,29 @@ import { RecentOperations } from "./RecentOperations.view"; type AccountPageLoadedProps = { account?: BrokerageAccount; accountId?: string; + concentration: ConcentrationModel | null; + currency: null | string; incomeSummary?: AccountPageIncomeSummary; + onCurrencyChange: (currency: null | string) => void; + onSliceChange: (slice: Slice) => void; + onTabChange: (tab: null | string) => void; partial?: boolean; portfolio: Portfolio; positionSections: AccountPagePositionSection[]; recentEvents: AccountPageRecentEvents; recentOperations: AccountPageRecentOperations; + slice: Slice; source?: InvestmentResourceMeta; + tab: string; unrealizedReturns: Portfolio["totalsByCurrency"]; }; const AccountPageLoaded: FC = (props) => { const { t } = useTranslation(); const [filter, setFilter] = useState(); + const tab = props.tab; + + const onTabChange = props.onTabChange; const activeSection = props.positionSections.find( section => section.kind === filter, @@ -75,7 +87,9 @@ const AccountPageLoaded: FC = (props) => { /> {props.partial - ? {t("investment.partial")} + ? tab !== "concentration" && ( + {t("investment.partial")} + ) : } {!props.partial && props.unrealizedReturns.length > 0 && ( @@ -107,14 +121,21 @@ const AccountPageLoaded: FC = (props) => { {t("portfolio.tabs.positions")} + {props.accountId !== "all" && ( + + {t("portfolio.concentration.title")} + + )} + {t("portfolio.tabs.cash")} @@ -143,6 +164,22 @@ const AccountPageLoaded: FC = (props) => { + {props.accountId !== "all" && ( + + {props.concentration && ( + + )} + + )} + diff --git a/src/pages/account/ui/AccountPage/components/Concentration/Concentration.module.css b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.module.css new file mode 100644 index 0000000..5a204c1 --- /dev/null +++ b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.module.css @@ -0,0 +1,111 @@ +.layout { + display: grid; + grid-template-columns: minmax(260px, 1fr) minmax(0, 1.4fr); + gap: var(--mantine-spacing-xl); + align-items: center; +} + +.chart { + position: relative; + justify-self: center; +} + +.pattern { + position: absolute; + width: 0; + height: 0; +} + +.center { + position: absolute; + inset: 32%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + font-size: clamp(12px, 1.3vw, 17px); + overflow-wrap: anywhere; + pointer-events: none; +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +.list { + list-style: none; + margin: 0; + padding: 0; +} + +.row { + display: flex; + align-items: center; + gap: var(--mantine-spacing-sm); + min-height: 58px; + border-bottom: 1px solid var(--mantine-color-gray-2); +} + +.rank { + display: grid; + place-items: center; + width: 28px; + height: 28px; + flex: none; + border-radius: 50%; + color: white; +} + +.identity { + flex: 1; + min-width: 0; + overflow-wrap: anywhere; +} + +.bar, +.bar span { + display: block; + height: 4px; + border-radius: 4px; +} + +.bar { + background: var(--mantine-color-gray-2); +} + +.value { + text-align: right; + white-space: nowrap; +} + +.other { + display: flex; + justify-content: space-between; + gap: var(--mantine-spacing-sm); + width: 100%; + min-height: 44px; + align-items: center; + border: 0; + background: var(--mantine-color-gray-0); + color: inherit; + cursor: pointer; + text-align: left; +} + +.other:focus-visible { + outline: 2px solid var(--mantine-color-blue-6); + outline-offset: 2px; +} + +@media (max-width: 700px) { + .layout { grid-template-columns: 1fr; } + .slices { max-width: 100%; overflow-x: auto; } + .other { flex-wrap: wrap; } +} diff --git a/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx new file mode 100644 index 0000000..9f9783a --- /dev/null +++ b/src/pages/account/ui/AccountPage/components/Concentration/Concentration.view.tsx @@ -0,0 +1,330 @@ +import { DonutChart } from "@mantine/charts"; +import { + Alert, Box, Group, Paper, SegmentedControl, Select, Stack, + Text, Title, +} from "@mantine/core"; +import { type FC, useId, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { formatMoney } from "@/shared/lib"; + +import type { + ConcentrationItem, + Concentration as ConcentrationModel, + Slice, +} from "../../../../model/concentration"; + +import { PositionLogo } from "../PositionLogo.view"; +import classes from "./Concentration.module.css"; + +type Props = { + currency: null | string; + model: ConcentrationModel; + onCurrencyChange: (currency: null | string) => void; + onSliceChange: (slice: Slice) => void; + partial: boolean; + slice: Slice; +}; + +const sliceLabels = { + bonds: "portfolio.bonds", + issuers: "portfolio.concentration.issuers", + sectors: "portfolio.concentration.sectors", + stocks: "portfolio.stocks", +} as const; + +type RowProps = { + color: string; + currency: string; + index: number; + item: ConcentrationItem; + logo?: ConcentrationItem["logo"]; + name: string; +}; + +const colors = [ + "#2563eb", "#18a999", "#f59e0b", "#8b5cf6", "#ef6c5b", + "#64748b", +]; + +const PERCENT = 100; +const OTHER_START = 6; + +function amountFor(item: ConcentrationItem, currency: string): string { + return formatMoney({ currency, value: item.amount }); +} + +const ConcentrationRow: FC = props => ( +
  • + + + {props.logo && } + + + {props.name} + + {props.logo?.subtitle && ( + {props.logo.subtitle} + )} + + + + + {amountFor(props.item, props.currency)} + + {props.item.share} + +
  • +); + +type TooltipProps = { + active?: boolean; + payload?: { name?: number | string }[]; + rows: { amount: string; label: string; share: string }[]; +}; + +function chartCellProps(series: { name: string }, rows: ConcentrationItem[]) { + const index = Number(series.name); + const item = rows.at(index); + + return { + stroke: "white", + strokeWidth: 2, + ...((item?.unknown || (index === rows.length - 1 + && item?.key === "__other")) + && { strokeDasharray: "3 2" }), + }; +} + +function rankLabel({ index }: { index: number; percent?: number }) { + return String(index + 1); +} + +const ChartTooltip: FC = (props) => { + const row = props.rows[Number(props.payload?.[0]?.name)]; + if (!row || !props.active) return; + + return ( + + {row.label} + + {row.amount} · {row.share} + + ); +}; + +const Concentration: FC = (props) => { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const otherId = useId(); + const chartId = useId(); + const currency = props.currency ?? ""; + + const rows = props.model.other + ? [...props.model.items, props.model.other] + : props.model.items; + + const chartData = rows.map((item, index) => ({ + color: colors[index % colors.length] ?? colors[0] ?? "blue", + name: String(index), + value: item.chartValue, + })); + + const chartText = [ + props.model.top5Share && t("portfolio.concentration.top5", { + share: props.model.top5Share, + }), + props.model.total && formatMoney({ + currency, value: props.model.total, + }), + ...rows.map(item => `${item.label}: ${amountFor(item, currency)}, ${item.share}`), + ].filter(Boolean).join("; "); + + const slices = [ + { label: t("portfolio.stocks"), value: "stocks" }, + { label: t("portfolio.bonds"), value: "bonds" }, + { label: t("portfolio.concentration.sectors"), value: "sectors" }, + { label: t("portfolio.concentration.issuers"), value: "issuers" }, + ]; + + // eslint-disable-next-line frontend/readability -- Mantine callback closes over page action. + const selectSlice = (value: string) => props.onSliceChange(value as Slice); + // eslint-disable-next-line frontend/readability -- Disclosure state belongs to this view. + const toggleOther = () => setExpanded(value => !value); + + // eslint-disable-next-line frontend/readability -- Chart callback closes over visible rows. + const cellProps = (series: { name: string }) => chartCellProps(series, rows); + const pieProps = { isAnimationActive: false, label: rankLabel }; + + const tooltipRows = rows.map(item => ({ + amount: amountFor(item, currency), + label: item.label, + share: item.share, + })); + + const tooltipProps = { content: }; + + const totalText = props.model.total + ? formatMoney({ currency, value: props.model.total }) + : ""; + + return ( + + + + + {props.model.currencies.length > 1 && ( +