feat(portfolio): visualize asset allocation

This commit is contained in:
2026-09-09 21:30:12 +03:00
parent daf1dca666
commit 0306526d33
19 changed files with 1142 additions and 12 deletions
+8 -1
View File
@@ -39,6 +39,7 @@ const portfolio: Portfolio = {
lastSuccessfulAt: "2026-08-15T12:00:00Z",
isStale: false,
totalsByCurrency: [{ currency: "RUB", value: "1200" }],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [],
positions: accounts.map((account) => ({ ...position, account })),
};
@@ -188,7 +189,13 @@ describe("All accounts routes", () => {
])("opens %s directly", async (path, title) => {
renderPage(path);
expect(
await screen.findByRole("heading", { level: 1, name: title }),
// The lazy route also imports charts; parallel coverage instrumentation
// can exceed RTL's one-second default before the route is mounted.
await screen.findByRole(
"heading",
{ level: 1, name: title },
{ timeout: 5000 },
),
).toBeInTheDocument();
expect(screen.getByRole("combobox", { name: "Счёт" })).toHaveValue(
"Все счета",
@@ -38,6 +38,7 @@ describe("usePortfolio", () => {
isStale: false,
totalsByCurrency: [],
positions: [],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [],
})
: HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 });
+41 -1
View File
@@ -90,6 +90,7 @@ const portfolio: Portfolio = {
logoName: null,
},
],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [{ currency: "USD", value: "0.5" }],
};
@@ -120,6 +121,21 @@ function emptyProps() {
}
describe("AccountPage states", () => {
it("keeps the account and positions visible for an older HTTP response without structure", async () => {
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
HttpResponse.json({ ...portfolio, structure: undefined }),
),
);
renderAccountRoute(createTestQueryClient());
expect(await screen.findByText("Вторая")).toBeInTheDocument();
expect(
screen.getByRole("region", { name: "Структура портфеля" }),
).toHaveTextContent("Данные о структуре портфеля пока недоступны.");
expect(
screen.getAllByRole("definition").map((node) => node.textContent),
).toEqual(["1 234,5 ₽", "0,5 $"]);
});
it("keeps initial placeholders decorative without amounts or focus targets", () => {
const { container } = renderPage(<AccountPageLoading />);
expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true");
@@ -167,6 +183,7 @@ describe("AccountPage states", () => {
currentValue: { currency: "RUB", value: "1000" },
},
],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [
{ currency: "RUB", value: "234.500000000000000001" },
{ currency: "USD", value: "-0.5" },
@@ -226,6 +243,7 @@ describe("AccountPage states", () => {
currentValue: { currency: "RUB", value: "100" },
},
],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [],
};
server.use(
@@ -762,12 +780,26 @@ describe("AccountPage states", () => {
it.each([503, 404])(
"preserves totals and UTC snapshot through refresh error %i, retry and success",
async (status) => {
const initialPortfolio: Portfolio = {
...portfolio,
structure: {
unvaluedCategories: [{ type: "STOCK", count: 1 }],
currencies: [
{
currency: "RUB",
totalValue: "10",
hasNegativeValues: false,
categories: [{ type: "BOND", value: "10", sharePercent: null }],
},
],
},
};
let requestCount = 0;
let resolveRetry: ((response: Response) => void) | undefined;
server.use(
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => {
requestCount += 1;
if (requestCount === 1) return HttpResponse.json(portfolio);
if (requestCount === 1) return HttpResponse.json(initialPortfolio);
if (requestCount === 3)
return new Promise<Response>((resolve) => {
resolveRetry = resolve;
@@ -782,6 +814,10 @@ describe("AccountPage states", () => {
renderAccountRoute(queryClient);
await screen.findByText("Вторая");
const structure = screen.getByRole("region", {
name: "Структура портфеля",
});
expect(structure).toHaveTextContent("10 ₽");
const snapshotText = screen.getByText(/Данные получены/).textContent;
expect(snapshotText).toContain("10:00");
void queryClient.invalidateQueries({
@@ -807,6 +843,7 @@ describe("AccountPage states", () => {
);
fireEvent.click(screen.getByRole("button", { name: "Повторить" }));
await waitFor(() => expect(requestCount).toBe(3));
expect(structure).toHaveTextContent("10 ₽");
expect(
screen.getAllByRole("definition").map((node) => node.textContent),
).toEqual(["1 234,5 ₽", "0,5 $"]);
@@ -819,6 +856,7 @@ describe("AccountPage states", () => {
isStale: false,
lastSuccessfulAt: "2026-08-14T11:30:00Z",
totalsByCurrency: [{ currency: "USD", value: "-0.5" }],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [{ currency: "EUR", value: "2.50" }],
}),
);
@@ -828,6 +866,8 @@ describe("AccountPage states", () => {
).toEqual(["-0,5 $", "2,50 €"]),
);
expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30");
expect(structure).not.toHaveTextContent("10 ₽");
expect(structure).toHaveTextContent("Нет положительной стоимости");
expect(
screen.queryByText(/Не удалось обновить портфель/),
).not.toBeInTheDocument();
@@ -55,6 +55,7 @@ const portfolio: Portfolio = {
logoName: "fond.png",
},
],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [{ currency: "USD", value: "1" }],
};
@@ -145,6 +146,7 @@ describe("useAccountPageViewModel", () => {
const snapshot: Portfolio = {
...portfolio,
totalsByCurrency: [{ currency: "RUB", value: "1250" }],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [{ currency: "RUB", value: "1000" }],
positions: [
...portfolio.positions,
@@ -12,6 +12,7 @@ import type {
import { AccountHeading } from "./AccountHeading.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
import { PortfolioStructure } from "./PortfolioStructure";
import { RecentOperations } from "./RecentOperations.view";
type AccountPageEmptyProps = {
@@ -43,6 +44,9 @@ const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
: "portfolio.emptyText",
)}
/>
<PortfolioStructure
structure={{ currencies: [], unvaluedCategories: [] }}
/>
<RecentOperations operations={props.recentOperations} />
<BrokerageEventsPreview events={props.recentEvents} />
{props.incomeSummary ? <IncomeSummary {...props.incomeSummary} /> : null}
@@ -16,6 +16,7 @@ import { AccountHeading } from "./AccountHeading.view";
import { CurrencyCards } from "./CurrencyCards.view";
import { IncomeSummary } from "./IncomeSummary.view";
import { PortfolioRefreshState } from "./PortfolioRefreshState.view";
import { PortfolioStructure } from "./PortfolioStructure";
import { PositionSection } from "./PositionSection.view";
import { RecentOperations } from "./RecentOperations.view";
@@ -56,6 +57,10 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
)}
</Text>
<CurrencyCards amounts={props.portfolio.totalsByCurrency} />
<PortfolioStructure
key={props.accountId}
structure={props.portfolio.structure}
/>
<Accordion
multiple
defaultValue={[
@@ -26,6 +26,7 @@ const AccountPageLoading: FC = () => {
</Paper>
))}
</SimpleGrid>
<Skeleton height={260} radius="sm" />
{[0, 1, 2].map((index) => (
<Skeleton key={index} height={52} radius="sm" />
))}
@@ -0,0 +1,72 @@
.section {
padding-block: var(--mantine-spacing-sm);
}
.content {
animation: currency-change 220ms ease-out;
}
@keyframes currency-change {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.content {
animation: none;
}
}
.layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
align-items: center;
gap: var(--mantine-spacing-xl);
}
.list {
list-style: none;
padding: 0;
margin: 0;
min-width: 0;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-md);
padding-block: var(--mantine-spacing-sm);
}
.marker {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.values {
min-width: 0;
text-align: right;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
@media (max-width: 48em) {
.layout {
grid-template-columns: minmax(0, 1fr);
gap: var(--mantine-spacing-sm);
}
.layout > :first-child {
justify-self: center;
}
}
@@ -0,0 +1,114 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Portfolio } from "@/entities/portfolio";
import { PortfolioStructure } from "./PortfolioStructure";
const rub: Portfolio["structure"]["currencies"][number] = {
currency: "RUB",
totalValue: "1250000",
hasNegativeValues: false,
categories: [
{ type: "STOCK", value: "650000", sharePercent: "52" },
{ type: "BOND", value: "375000", sharePercent: "30" },
{ type: "OTHER", value: "125000", sharePercent: "10" },
{ type: "CASH", value: "100000", sharePercent: "8" },
],
};
const meta = {
title: "Product/PortfolioStructure",
component: PortfolioStructure,
tags: ["autodocs"],
args: { structure: { currencies: [rub], unvaluedCategories: [] } },
parameters: {
docs: {
description: {
component:
"Структура по типам активов, включая доступные деньги. Синтетические серверные суммы и доли, без запросов API и финансовых расчётов на клиенте. При неполной или отрицательной оценке остаётся текстовый список.",
},
},
},
} satisfies Meta<typeof PortfolioStructure>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Regular: Story = {};
export const MultipleCurrencies: Story = {
args: {
structure: {
currencies: [
rub,
{
currency: "USD",
totalValue: "500",
hasNegativeValues: false,
categories: [{ type: "CASH", value: "500", sharePercent: "100" }],
},
],
unvaluedCategories: [],
},
},
};
export const CashOnly: Story = {
args: {
structure: {
currencies: [
{
currency: "RUB",
totalValue: "100000",
hasNegativeValues: false,
categories: rub.categories.map((row) => ({
...row,
value: row.type === "CASH" ? "100000" : "0",
sharePercent: row.type === "CASH" ? "100" : "0",
})),
},
],
unvaluedCategories: [],
},
},
};
export const Empty: Story = {
args: { structure: { currencies: [], unvaluedCategories: [] } },
};
export const Incomplete: Story = {
args: {
structure: {
currencies: [
{
...rub,
categories: rub.categories.map((row) => ({
...row,
sharePercent: null,
})),
},
],
unvaluedCategories: [{ type: "STOCK", count: 2 }],
},
},
};
export const UnknownOnly: Story = {
args: {
structure: {
currencies: [],
unvaluedCategories: [{ type: "BOND", count: 1 }],
},
},
};
export const Negative: Story = {
args: {
structure: {
currencies: [
{
...rub,
hasNegativeValues: true,
totalValue: "1150000",
categories: rub.categories.map((row) => ({
...row,
value: row.type === "OTHER" ? "25000" : row.value,
sharePercent: null,
})),
},
],
unvaluedCategories: [],
},
},
};
@@ -0,0 +1,148 @@
import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import type { Portfolio } from "@/entities/portfolio";
import { PortfolioStructure } from "./PortfolioStructure";
const rub: Portfolio["structure"]["currencies"][number] = {
currency: "RUB",
totalValue: "100",
hasNegativeValues: false,
categories: [
{ type: "STOCK", value: "60", sharePercent: "60" },
{ type: "BOND", value: "20", sharePercent: "20" },
{ type: "OTHER", value: "0", sharePercent: "0" },
{ type: "CASH", value: "20", sharePercent: "20" },
],
};
const ui = (structure: Portfolio["structure"] | null | undefined) => (
<MantineProvider>
<PortfolioStructure structure={structure} />
</MantineProvider>
);
describe("portfolio structure", () => {
it.each([undefined, null])(
"keeps an older snapshot without structure available (%s) and recovers on refresh",
(structure) => {
const view = render(ui(structure));
expect(
screen.getByRole("region", { name: "Структура портфеля" }),
).toHaveTextContent("Данные о структуре портфеля пока недоступны.");
expect(
screen.queryByText(/Нет положительной стоимости/),
).not.toBeInTheDocument();
expect(screen.queryByRole("list")).not.toBeInTheDocument();
view.rerender(ui({ currencies: [rub], unvaluedCategories: [] }));
expect(screen.getByRole("list")).toHaveTextContent("Акции60 ₽60 %");
expect(
screen.queryByText("Данные о структуре портфеля пока недоступны."),
).not.toBeInTheDocument();
},
);
it("shows exact server amounts and cash-inclusive shares without hovering", () => {
render(ui({ currencies: [rub], unvaluedCategories: [] }));
const list = screen.getByRole("list", { name: "Категории активов" });
expect(list).toHaveTextContent("Акции60 ₽60 %");
expect(list).toHaveTextContent("Свободные деньги20 ₽20 %");
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
});
it("switches currencies and falls back when a refresh removes the selection", () => {
const usd = { ...rub, currency: "USD" };
const view = render(ui({ currencies: [rub, usd], unvaluedCategories: [] }));
fireEvent.click(
screen.getByRole("combobox", { name: "Валюта структуры портфеля" }),
);
fireEvent.click(screen.getByRole("option", { name: "USD" }));
expect(screen.getByRole("list")).toHaveTextContent("60 USD");
expect(screen.getByRole("list")).not.toHaveTextContent("₽");
view.rerender(ui({ currencies: [rub], unvaluedCategories: [] }));
expect(screen.getByRole("list")).toHaveTextContent("60 ₽");
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
});
it("retains known amounts and names unvalued categories without inventing a zero", () => {
render(
ui({
currencies: [
{
...rub,
categories: rub.categories.map((row) => ({
...row,
sharePercent: null,
})),
},
],
unvaluedCategories: [
{ type: "OTHER", count: 2 },
{ type: "STOCK", count: 1 },
],
}),
);
expect(screen.getByText(/Оценка неполная/)).toBeInTheDocument();
expect(screen.getByRole("list")).toHaveTextContent("Известно: 60 ₽");
expect(screen.getByRole("list")).toHaveTextContent(
"Другие активыНет оценки",
);
expect(
screen.queryByLabelText(/Структура по типам/),
).not.toBeInTheDocument();
});
it("keeps negative amounts visible without a donut or invented positive shares", () => {
render(
ui({
currencies: [
{
...rub,
hasNegativeValues: true,
categories: rub.categories.map((row) => ({
...row,
value: row.type === "STOCK" ? "-60" : row.value,
sharePercent: null,
})),
},
],
unvaluedCategories: [],
}),
);
expect(screen.getByText(/Есть отрицательные/)).toBeInTheDocument();
expect(screen.getByRole("list")).toHaveTextContent("-60 ₽");
expect(
within(screen.getByRole("list")).getAllByText("Доля недоступна"),
).toHaveLength(4);
expect(
screen.queryByLabelText(/Структура по типам/),
).not.toBeInTheDocument();
});
it("handles unknown-only and empty compositions", () => {
const view = render(
ui({ currencies: [], unvaluedCategories: [{ type: "BOND", count: 1 }] }),
);
expect(screen.getByText("Облигации: без оценки — 1")).toBeInTheDocument();
expect(screen.queryByRole("list")).not.toBeInTheDocument();
view.rerender(ui({ currencies: [], unvaluedCategories: [] }));
expect(screen.getByText(/Нет положительной стоимости/)).toBeInTheDocument();
});
it("keeps full decimal amounts and describes tiny shares", () => {
render(
ui({
currencies: [
{
...rub,
categories: [
{
type: "CASH",
value: "9007199254740993.000000001",
sharePercent: "0.000001",
},
],
},
],
unvaluedCategories: [],
}),
);
expect(screen.getByRole("list")).toHaveTextContent(
"9 007 199 254 740 993,000000001 ₽",
);
expect(screen.getByRole("list")).toHaveTextContent("< 0,01 %");
});
});
@@ -0,0 +1,78 @@
import { type FC, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney } from "@/shared/lib";
import { PortfolioStructureView } from "./PortfolioStructure.view";
// A cached snapshot or an older running server can predate this response field.
type PortfolioStructureProps = { structure?: Portfolio["structure"] | null };
const categoryPresentation = {
STOCK: { label: "portfolio.stocks", color: "blue.6" },
BOND: { label: "portfolio.bonds", color: "grape.6" },
OTHER: { label: "portfolio.structure.other", color: "orange.6" },
CASH: { label: "portfolio.structure.cash", color: "green.6" },
} as const;
const PortfolioStructure: FC<PortfolioStructureProps> = (props) => {
const { structure } = props;
const { t } = useTranslation();
const [currency, setCurrency] = useState<string | null>(null);
const selected =
structure?.currencies.find((item) => item.currency === currency) ??
structure?.currencies[0];
const incomplete = (structure?.unvaluedCategories.length ?? 0) > 0;
const rows = selected
? selected.categories.map((category) => {
const presentation = categoryPresentation[category.type];
const percentage =
category.sharePercent === null ? null : Number(category.sharePercent);
const unknown = structure?.unvaluedCategories.some(
(item) => item.type === category.type,
);
const amount = formatMoney({
currency: selected.currency,
value: category.value,
});
return {
name: t(presentation.label),
color: presentation.color,
amount: unknown
? category.value === "0"
? t("portfolio.structure.unvalued")
: t("portfolio.structure.knownAmount", { amount })
: amount,
share:
percentage === null
? t("portfolio.structure.shareUnavailable")
: percentage > 0 && percentage < 0.01
? t("portfolio.structure.smallShare")
: `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`,
value: percentage ?? 0,
};
})
: [];
const chartData = rows.filter((row) => row.value > 0);
return (
<PortfolioStructureView
unavailable={!structure}
currencies={structure?.currencies.map((item) => item.currency) ?? []}
currency={selected?.currency ?? null}
onCurrencyChange={setCurrency}
rows={rows}
chartData={chartData}
incomplete={incomplete}
negative={selected?.hasNegativeValues ?? false}
unvalued={
structure?.unvaluedCategories.map((category) =>
t("portfolio.structure.unvaluedCategory", {
category: t(categoryPresentation[category.type].label),
count: category.count,
}),
) ?? []
}
/>
);
};
export { PortfolioStructure };
@@ -0,0 +1,148 @@
import "@mantine/charts/styles.css";
import { DonutChart } from "@mantine/charts";
import { Box, Group, Paper, Select, Stack, Text, Title } from "@mantine/core";
import { type FC, useId } from "react";
import { useTranslation } from "react-i18next";
import classes from "./PortfolioStructure.module.css";
type StructureRow = {
name: string;
color: string;
amount: string;
share: string;
value: number;
};
type PortfolioStructureViewProps = {
unavailable: boolean;
currencies: string[];
currency: string | null;
onCurrencyChange: (currency: string | null) => void;
rows: StructureRow[];
chartData: StructureRow[];
incomplete: boolean;
negative: boolean;
unvalued: string[];
};
const PortfolioStructureView: FC<PortfolioStructureViewProps> = (props) => {
const { t } = useTranslation();
const titleId = useId();
return (
<Stack
component="section"
aria-labelledby={titleId}
gap="md"
className={classes.section}
>
<Group justify="space-between" align="center">
<Title order={2} size="h4" id={titleId}>
{t("portfolio.structure.title")}
</Title>
{props.currencies.length > 1 ? (
<Select
aria-label={t("portfolio.structure.currency")}
data={props.currencies}
value={props.currency}
onChange={props.onCurrencyChange}
allowDeselect={false}
w={112}
/>
) : null}
</Group>
<Text size="sm" c="dimmed">
{t(
props.unavailable
? "portfolio.structure.unavailable"
: "portfolio.structure.basis",
)}
</Text>
{props.incomplete ? (
<Stack gap={4}>
<Text size="sm">{t("portfolio.structure.incomplete")}</Text>
{props.unvalued.map((message) => (
<Text size="sm" c="dimmed" key={message}>
{message}
</Text>
))}
</Stack>
) : null}
{props.negative ? (
<Text size="sm">{t("portfolio.structure.negative")}</Text>
) : null}
{!props.unavailable &&
!props.chartData.length &&
!props.incomplete &&
!props.negative ? (
<Text size="sm" c="dimmed">
{t("portfolio.structure.empty")}
</Text>
) : null}
<div
key={props.currency}
className={`${classes.content} ${props.chartData.length ? classes.layout : ""}`}
>
{props.chartData.length ? (
<DonutChart
data={props.chartData}
size={220}
thickness={32}
strokeWidth={2}
startAngle={90}
endAngle={-270}
chartLabel={props.currency ?? undefined}
tooltipDataSource="segment"
pieProps={{ isAnimationActive: false }}
pieChartProps={{ title: t("portfolio.structure.chartLabel") }}
aria-label={t("portfolio.structure.chartLabel")}
tooltipProps={{
content: ({ active, payload }) => {
const row = props.chartData.find(
(item) => item.name === payload?.[0]?.name,
);
return active && row ? (
<Paper shadow="sm" p="sm" radius="md">
<Text size="sm" fw={600}>
{row.name}
</Text>
<Text size="sm">
{row.amount} · {row.share}
</Text>
</Paper>
) : null;
},
}}
/>
) : null}
{props.rows.length ? (
<ul
className={classes.list}
aria-label={t("portfolio.structure.categories")}
>
{props.rows.map((row) => (
<li key={row.name} className={classes.row}>
<Group gap="xs" wrap="nowrap">
<Box
aria-hidden="true"
bg={row.color}
className={classes.marker}
/>
<Text size="sm">{row.name}</Text>
</Group>
<div className={classes.values}>
<Text size="sm" fw={600}>
{row.amount}
</Text>
<Text size="sm" c="dimmed">
{row.share}
</Text>
</div>
</li>
))}
</ul>
) : null}
</div>
</Stack>
);
};
export { PortfolioStructureView };
@@ -32,6 +32,7 @@ function sections(positions: Portfolio["positions"]) {
lastSuccessfulAt: "2026-09-09T12:00:00Z",
isStale: false,
totalsByCurrency: [],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [],
positions,
});
+26
View File
@@ -379,6 +379,31 @@ export interface components {
ticker: string | null;
logoName: string | null;
};
PortfolioStructureCategoryDto: {
/** @enum {string} */
type: "STOCK" | "BOND" | "OTHER" | "CASH";
/** @description Exact known value as a decimal string. */
value: string;
/** @description Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions. */
sharePercent: string | null;
};
PortfolioStructureCurrencyDto: {
currency: string;
totalValue: string;
/** @description Any negative constituent, even if netted positive. */
hasNegativeValues: boolean;
categories: components["schemas"]["PortfolioStructureCategoryDto"][];
};
PortfolioUnvaluedCategoryDto: {
/** @enum {string} */
type: "STOCK" | "BOND" | "OTHER";
count: number;
};
PortfolioStructureDto: {
currencies: components["schemas"]["PortfolioStructureCurrencyDto"][];
/** @description Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty. */
unvaluedCategories: components["schemas"]["PortfolioUnvaluedCategoryDto"][];
};
PortfolioDto: {
/** Format: date-time */
lastSuccessfulAt: string;
@@ -386,6 +411,7 @@ export interface components {
totalsByCurrency: components["schemas"]["MoneyDto"][];
positions: components["schemas"]["PortfolioPositionDto"][];
cashBalances: components["schemas"]["MoneyDto"][];
structure: components["schemas"]["PortfolioStructureDto"];
};
UpcomingEventDto: {
account?: components["schemas"]["AccountReferenceDto"];
+103 -1
View File
@@ -2062,6 +2062,104 @@
"logoName"
]
},
"PortfolioStructureCategoryDto": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"STOCK",
"BOND",
"OTHER",
"CASH"
]
},
"value": {
"type": "string",
"description": "Exact known value as a decimal string."
},
"sharePercent": {
"type": "string",
"nullable": true,
"description": "Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions."
}
},
"required": [
"type",
"value",
"sharePercent"
]
},
"PortfolioStructureCurrencyDto": {
"type": "object",
"properties": {
"currency": {
"type": "string"
},
"totalValue": {
"type": "string"
},
"hasNegativeValues": {
"type": "boolean",
"description": "Any negative constituent, even if netted positive."
},
"categories": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PortfolioStructureCategoryDto"
}
}
},
"required": [
"currency",
"totalValue",
"hasNegativeValues",
"categories"
]
},
"PortfolioUnvaluedCategoryDto": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"STOCK",
"BOND",
"OTHER"
]
},
"count": {
"type": "number",
"minimum": 1
}
},
"required": [
"type",
"count"
]
},
"PortfolioStructureDto": {
"type": "object",
"properties": {
"currencies": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PortfolioStructureCurrencyDto"
}
},
"unvaluedCategories": {
"description": "Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.",
"type": "array",
"items": {
"$ref": "#/components/schemas/PortfolioUnvaluedCategoryDto"
}
}
},
"required": [
"currencies",
"unvaluedCategories"
]
},
"PortfolioDto": {
"type": "object",
"properties": {
@@ -2089,6 +2187,9 @@
"items": {
"$ref": "#/components/schemas/MoneyDto"
}
},
"structure": {
"$ref": "#/components/schemas/PortfolioStructureDto"
}
},
"required": [
@@ -2096,7 +2197,8 @@
"isStale",
"totalsByCurrency",
"positions",
"cashBalances"
"cashBalances",
"structure"
]
},
"UpcomingEventDto": {
+21
View File
@@ -93,6 +93,27 @@ const ru = {
bonds: "Облигации",
otherPositions: "Другие позиции",
cashBalances: "Денежные остатки",
structure: {
title: "Структура портфеля",
other: "Другие активы",
cash: "Свободные деньги",
currency: "Валюта структуры портфеля",
categories: "Категории активов",
chartLabel:
"Структура по типам активов. Суммы и доли — в списке рядом.",
basis: "Доли учитывают свободные деньги и округлены до сотых процента.",
incomplete:
"Оценка неполная: ниже показаны только известные суммы. Валюта оценки части позиций неизвестна, поэтому доли и диаграмма недоступны.",
unvaluedCategory: "{{category}}: без оценки — {{count}}",
negative:
"Есть отрицательные стоимости. Кольцевая диаграмма и доли недоступны; в списке сохранены суммы со знаком.",
empty: "Нет положительной стоимости для кольцевой диаграммы.",
unavailable: "Данные о структуре портфеля пока недоступны.",
shareUnavailable: "Доля недоступна",
unvalued: "Нет оценки",
knownAmount: "Известно: {{amount}}",
smallShare: "< 0,01 %",
},
quantity: "Количество: {{value}}",
return: "Доход: {{value}}",
avgPrice: "Средняя цена: {{value}}",
+1
View File
@@ -33,6 +33,7 @@ const handlers = [
isStale: false,
totalsByCurrency: [],
positions: [],
structure: { currencies: [], unvaluedCategories: [] },
cashBalances: [],
}),
),