chore(release): 1.3.0 #130

Merged
codex-bot merged 15 commits from develop into main 2026-11-01 21:01:52 +03:00
15 changed files with 1503 additions and 22 deletions
Showing only changes of commit 70a2c335fd - Show all commits
@@ -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",
};
@@ -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]> = {},
): 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");
});
});
+300
View File
@@ -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<string, Group>,
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<string, Group>();
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,
};
@@ -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",
},
@@ -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 (
<button
onClick={() => navigate("/accounts/account-2")}
type="button"
>
Другой счёт
</button>
);
}
function configureTestAuth() {
configureAuthClient({
accessToken: () => "test-token",
@@ -50,6 +66,8 @@ function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
<QueryClientProvider client={queryClient}>
<MantineProvider>
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
<AccountSwitcher />
<Routes>
<Route element={<AccountPage />} path="/accounts/:accountId" />
</Routes>
@@ -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<Partial<Parameters<typeof AccountPageLoaded>[0]>, "onTabChange">,
) => {
const [tab, setTab] = useState(props.tab ?? "positions");
return (
<AccountPageLoaded
{...loadedProps()}
{...props}
onTabChange={value => 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(
<LoadedHarness
{...props}
accountId="a"
concentration={model}
currency="RUB"
tab="concentration"
/>,
);
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(
<LoadedHarness
{...loadedProps({ ...portfolio, positions })}
accountId="a"
concentration={toConcentration(
{ ...portfolio, positions },
"stocks",
"RUB",
concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
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(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
expect(screen.getByText("SBER")).toBeInTheDocument();
view.unmount();
renderPage(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "issuers", "RUB", concentrationLabels(),
)}
currency="RUB"
slice="issuers"
tab="concentration"
/>,
);
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(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
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(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "sectors", "RUB", concentrationLabels(),
)}
currency="RUB"
slice="sectors"
tab="concentration"
/>,
);
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(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
)}
currency="RUB"
tab="concentration"
/>,
);
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(
<LoadedHarness
{...loadedProps(data)}
accountId="a"
concentration={toConcentration(
data, "stocks", "RUB", concentrationLabels(),
)}
currency="RUB"
partial
tab="concentration"
/>,
);
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(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
cashBalances: [
@@ -490,7 +867,7 @@ describe("AccountPage states", () => {
});
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />);
renderPage(<LoadedHarness {...loadedProps()} />);
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(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [
@@ -571,7 +948,7 @@ describe("AccountPage states", () => {
"groups %s supporting metrics into quiet single lines",
(type) => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [position({
@@ -593,7 +970,7 @@ describe("AccountPage states", () => {
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [
@@ -618,7 +995,7 @@ describe("AccountPage states", () => {
it("keeps other-position cards compact with labeled return and no logo", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [position({
@@ -636,7 +1013,7 @@ describe("AccountPage states", () => {
it("uses the cached account title when available", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
account={{
@@ -655,7 +1032,7 @@ describe("AccountPage states", () => {
it("keeps the portfolio visible when recent operations fail", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
recentOperations={{
state: "error",
@@ -674,7 +1051,7 @@ describe("AccountPage states", () => {
it("uses the ledger row for a recent outgoing trade operation", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
recentOperations={{
@@ -724,7 +1101,7 @@ describe("AccountPage states", () => {
};
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
recentEvents={{
@@ -793,7 +1170,7 @@ describe("AccountPage states", () => {
"keeps the upcoming %s state and calendar action",
(state) => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
recentEvents={{ state, to: "/accounts/account-1/events" }}
/>,
@@ -830,7 +1207,7 @@ describe("AccountPage states", () => {
it("keeps the portfolio visible when upcoming events fail", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
recentEvents={{
state: "error",
@@ -995,7 +1372,7 @@ describe("AccountPage states", () => {
});
it("opens positions by default and switches between local tabs", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />);
renderPage(<LoadedHarness {...loadedProps()} />);
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(
<AccountPageLoaded
<LoadedHarness
{...loadedProps()}
incomeSummary={{
state: "empty",
@@ -1033,7 +1410,7 @@ describe("AccountPage states", () => {
});
it("filters positions by the kinds present in the portfolio", () => {
renderPage(<AccountPageLoaded {...loadedProps()} />);
renderPage(<LoadedHarness {...loadedProps()} />);
expect(
screen.getAllByRole<HTMLInputElement>("radio").map(radio => radio.value),
@@ -1047,7 +1424,7 @@ describe("AccountPage states", () => {
it("hides the position filter when only one kind exists", () => {
renderPage(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [portfolio.positions[0]],
@@ -1060,7 +1437,7 @@ describe("AccountPage states", () => {
});
it("hides unrealized returns for a partial portfolio", () => {
renderPage(<AccountPageLoaded {...loadedProps()} partial />);
renderPage(<LoadedHarness {...loadedProps()} partial />);
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(
<AccountPageLoaded
<LoadedHarness
{...loadedProps({
...portfolio,
positions: [
@@ -25,13 +25,20 @@ const AccountPage: FC = () => {
<AccountPageLoaded
account={viewModel.account}
accountId={viewModel.accountId}
concentration={viewModel.concentration}
currency={viewModel.currency}
incomeSummary={viewModel.incomeSummary}
onCurrencyChange={viewModel.onCurrencyChange}
onSliceChange={viewModel.onSliceChange}
onTabChange={viewModel.onTabChange}
partial={viewModel.partial}
portfolio={viewModel.portfolio}
positionSections={viewModel.positionSections}
recentEvents={viewModel.recentEvents}
recentOperations={viewModel.recentOperations}
slice={viewModel.slice}
source={viewModel.source}
tab={viewModel.tab}
unrealizedReturns={viewModel.unrealizedReturns}
/>
);
@@ -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<typeof selection>) =>
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),
};
}
@@ -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<AccountPageLoadedProps> = (props) => {
const { t } = useTranslation();
const [filter, setFilter] = useState<string>();
const tab = props.tab;
const onTabChange = props.onTabChange;
const activeSection = props.positionSections.find(
section => section.kind === filter,
@@ -75,7 +87,9 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
/>
{props.partial
? <Alert role="status">{t("investment.partial")}</Alert>
? tab !== "concentration" && (
<Alert role="status">{t("investment.partial")}</Alert>
)
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
{!props.partial && props.unrealizedReturns.length > 0 && (
@@ -107,14 +121,21 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
<Tabs
classNames={{ list: classes.tabsList }}
defaultValue="positions"
keepMounted={false}
onChange={onTabChange}
value={tab}
>
<Tabs.List aria-label={t("portfolio.sections")} mb="md">
<Tabs.Tab value="positions">
{t("portfolio.tabs.positions")}
</Tabs.Tab>
{props.accountId !== "all" && (
<Tabs.Tab value="concentration">
{t("portfolio.concentration.title")}
</Tabs.Tab>
)}
<Tabs.Tab value="cash">{t("portfolio.tabs.cash")}</Tabs.Tab>
<Tabs.Tab value="operations">
@@ -143,6 +164,22 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
</Stack>
</Tabs.Panel>
{props.accountId !== "all" && (
<Tabs.Panel value="concentration">
{props.concentration && (
<Concentration
currency={props.currency}
key={`${props.slice}-${props.currency}`}
model={props.concentration}
onCurrencyChange={props.onCurrencyChange}
onSliceChange={props.onSliceChange}
partial={!!props.partial}
slice={props.slice}
/>
)}
</Tabs.Panel>
)}
<Tabs.Panel value="cash">
<CurrencyCards amounts={props.portfolio.cashBalances} />
</Tabs.Panel>
@@ -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; }
}
@@ -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<RowProps> = props => (
<li className={classes.row}>
<span
aria-hidden="true"
className={classes.rank}
style={{ backgroundColor: props.color }}
>
{props.index}
</span>
{props.logo && <PositionLogo label={props.name} src={props.logo.src} />}
<span className={classes.identity}>
<span>{props.name}</span>
{props.logo?.subtitle && (
<Text c="dimmed" size="xs">{props.logo.subtitle}</Text>
)}
<span aria-hidden="true" className={classes.bar}>
<span style={{
backgroundColor: props.color,
width: `${props.item.chartValue * PERCENT}%`,
}}
/>
</span>
</span>
<span className={classes.value}>
<strong>{amountFor(props.item, props.currency)}</strong>
<Text c="dimmed" size="sm">{props.item.share}</Text>
</span>
</li>
);
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<TooltipProps> = (props) => {
const row = props.rows[Number(props.payload?.[0]?.name)];
if (!row || !props.active) return;
return (
<Paper p="sm" radius="md" shadow="sm">
<Text fw={600} size="sm">{row.label}</Text>
<Text size="sm">{row.amount} · {row.share}</Text>
</Paper>
);
};
const Concentration: FC<Props> = (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: <ChartTooltip rows={tooltipRows} /> };
const totalText = props.model.total
? formatMoney({ currency, value: props.model.total })
: "";
return (
<Stack gap="lg">
<Group align="end" justify="space-between">
<SegmentedControl
aria-label={t("portfolio.concentration.slice")}
className={classes.slices}
data={slices}
onChange={selectSlice}
value={props.slice}
/>
{props.model.currencies.length > 1 && (
<Select
allowDeselect={false}
aria-label={t("portfolio.concentration.currency")}
data={props.model.currencies}
onChange={props.onCurrencyChange}
value={props.currency}
w={130}
/>
)}
</Group>
{props.partial && (
<Alert role="status">{t("portfolio.concentration.incomplete")}</Alert>
)}
{props.model.empty
? (
<Stack gap="xs">
<Text fw={600}>{t(sliceLabels[props.slice])}
{props.currency && ` · ${props.currency}`}
</Text>
<Text>{t("portfolio.concentration.empty")}</Text>
</Stack>
)
: (
<Paper className={classes.layout} p="lg" radius="md" withBorder>
<Box className={classes.chart}>
<DonutChart
aria-describedby={chartId}
aria-label={t("portfolio.concentration.chart")}
cellProps={cellProps}
data={chartData}
pieProps={pieProps}
role="img"
size={260}
strokeWidth={24}
tooltipDataSource="segment"
tooltipProps={tooltipProps}
/>
<div aria-hidden="true" className={classes.center}>
{props.model.top5Share && (
<Text fw={700} size="sm">{t("portfolio.concentration.top5", {
share: props.model.top5Share,
})}
</Text>
)}
<Text fw={700}>{totalText}</Text>
</div>
<Text
className={classes.srOnly}
id={chartId}
>
{chartText}
</Text>
</Box>
<Stack gap="xs">
<Title order={3} size="md">{t("portfolio.concentration.title")}</Title>
<ol className={classes.list}>
{props.model.items.map((item, index) => (
<ConcentrationRow
color={colors[index % colors.length] ?? "blue"}
currency={currency}
index={index + 1}
item={item}
key={item.key}
logo={item.logo}
name={item.label}
/>
))}
</ol>
{props.model.other && (
<>
<button
aria-controls={otherId}
aria-expanded={expanded}
className={classes.other}
onClick={toggleOther}
type="button"
>
<span>{t("portfolio.concentration.other")} · {t(
"portfolio.concentration.otherCount",
{ count: props.model.other.count },
)}
</span>
<span>{amountFor(props.model.other, currency)} · {
props.model.other.share
}
</span>
</button>
<ol
className={classes.list}
hidden={!expanded}
id={otherId}
start={OTHER_START}
>
{props.model.other.entries.map((item, index) => (
<ConcentrationRow
color={colors[(index + OTHER_START) % colors.length]
?? "blue"}
currency={currency}
index={index + OTHER_START}
item={item}
key={item.key}
logo={item.logo}
name={item.label}
/>
))}
</ol>
</>
)}
</Stack>
</Paper>
)}
{props.model.unaccounted > 0 && (
<Text c="dimmed" size="sm">{t(
"portfolio.concentration.unaccounted",
{ count: props.model.unaccounted },
)}
</Text>
)}
</Stack>
);
};
export { Concentration };
@@ -12,6 +12,7 @@ const syntheticPosition
displayName: "Учебные технологии",
figi: null,
isin: null,
issuer: null,
logoName: null,
logoSrc: null,
quantity: "100",
@@ -19,6 +20,7 @@ const syntheticPosition
returnPercentText: "+25,00 %",
returnText: "+5 000 ₽",
returnTone: "positive",
sector: null,
shareText: "20%",
ticker: "DEMO",
type: "STOCK",
@@ -37,12 +37,14 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
displayName: `Инструмент ${index}`,
figi: null,
isin: null,
issuer: null,
logoName: null,
logoSrc: null,
quantity: "10",
returnPercent: null,
returnPercentText: null,
returnText: null,
sector: null,
shareText: null,
ticker: null,
type: kind === "stocks" ? "STOCK" : otherType,
+8
View File
@@ -1056,6 +1056,10 @@ export interface components {
id: string;
name: string;
};
PortfolioIssuerDto: {
id: string;
name: string;
};
PortfolioPositionDto: {
instrumentUid?: string | null;
account?: components["schemas"]["AccountReferenceDto"];
@@ -1070,6 +1074,8 @@ export interface components {
isin: string | null;
ticker: string | null;
logoName: string | null;
sector: string | null;
issuer: components["schemas"]["PortfolioIssuerDto"] | null;
};
PortfolioDto: {
/** Format: date-time */
@@ -1383,6 +1389,8 @@ export interface components {
isin: string | null;
ticker: string | null;
logoName: string | null;
sector: string | null;
issuer: components["schemas"]["PortfolioIssuerDto"] | null;
returnPercent: string | null;
};
StockPositionDto: {
+46 -1
View File
@@ -6423,6 +6423,21 @@
"name"
]
},
"PortfolioIssuerDto": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"name"
]
},
"PortfolioPositionDto": {
"type": "object",
"properties": {
@@ -6495,6 +6510,19 @@
"logoName": {
"type": "string",
"nullable": true
},
"sector": {
"type": "string",
"nullable": true
},
"issuer": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/PortfolioIssuerDto"
}
]
}
},
"required": [
@@ -6507,7 +6535,9 @@
"absoluteReturn",
"isin",
"ticker",
"logoName"
"logoName",
"sector",
"issuer"
]
},
"PortfolioDto": {
@@ -7910,6 +7940,19 @@
"type": "string",
"nullable": true
},
"sector": {
"type": "string",
"nullable": true
},
"issuer": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/PortfolioIssuerDto"
}
]
},
"returnPercent": {
"type": "string",
"nullable": true
@@ -7926,6 +7969,8 @@
"isin",
"ticker",
"logoName",
"sector",
"issuer",
"returnPercent"
]
},
+21
View File
@@ -509,6 +509,26 @@ const ru = {
bonds: "Облигации",
byAccount: "Позиции по счетам",
cashBalances: "Денежные остатки",
concentration: {
chart: "Распределение позиций",
currency: "Валюта концентрации",
empty: "В этом разрезе нет позиций с положительной оценкой.",
incomplete: "Данные неполные",
issuers: "Эмитенты",
missingIssuer: "Эмитент не указан",
missingSector: "Сектор не указан",
other: "Прочее",
otherCount: "{{count}} элементов",
otherCount_few: "{{count}} элемента",
otherCount_many: "{{count}} элементов",
otherCount_one: "{{count}} элемент",
otherCount_other: "{{count}} элемента",
sectors: "Секторы",
slice: "Разрез концентрации",
title: "Концентрация",
top5: "Топ-5 · {{share}}",
unaccounted: "Не учтено позиций: {{count}}",
},
connectionErrorText: "Настройте подключение, чтобы посмотреть портфель.",
connectionErrorTitle: "Проверьте подключение T-Bank",
currencies: {
@@ -760,6 +780,7 @@ const ru = {
consumer: "Потребительский сектор",
energy: "Энергетика",
financial: "Финансы",
government: "Государственный сектор",
health_care: "Здравоохранение",
industrials: "Промышленность",
it: "Информационные технологии",