feat(account): add concentration tab with top-5 slices and chart (#48) #127
@@ -46,8 +46,10 @@ const position: Portfolio["positions"][number]
|
|||||||
displayName: "Сбербанк",
|
displayName: "Сбербанк",
|
||||||
figi: "SBER",
|
figi: "SBER",
|
||||||
isin: null,
|
isin: null,
|
||||||
|
issuer: null,
|
||||||
logoName: null,
|
logoName: null,
|
||||||
quantity: "2",
|
quantity: "2",
|
||||||
|
sector: null,
|
||||||
ticker: "SBER",
|
ticker: "SBER",
|
||||||
type: "STOCK",
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: "Акция",
|
displayName: "Акция",
|
||||||
figi: "FIGI-STOCK",
|
figi: "FIGI-STOCK",
|
||||||
isin: null,
|
isin: null,
|
||||||
|
issuer: null,
|
||||||
logoName: "akciya.png",
|
logoName: "akciya.png",
|
||||||
quantity: "2",
|
quantity: "2",
|
||||||
|
sector: null,
|
||||||
ticker: null,
|
ticker: null,
|
||||||
type: "STOCK",
|
type: "STOCK",
|
||||||
},
|
},
|
||||||
@@ -25,8 +27,10 @@ const portfolio
|
|||||||
displayName: "Облигация",
|
displayName: "Облигация",
|
||||||
figi: "FIGI-BOND",
|
figi: "FIGI-BOND",
|
||||||
isin: "RU000A0JX0J2",
|
isin: "RU000A0JX0J2",
|
||||||
|
issuer: null,
|
||||||
logoName: null,
|
logoName: null,
|
||||||
quantity: "1",
|
quantity: "1",
|
||||||
|
sector: null,
|
||||||
ticker: null,
|
ticker: null,
|
||||||
type: "BOND",
|
type: "BOND",
|
||||||
},
|
},
|
||||||
@@ -37,8 +41,10 @@ const portfolio
|
|||||||
displayName: "Фонд",
|
displayName: "Фонд",
|
||||||
figi: "FIGI-FUND",
|
figi: "FIGI-FUND",
|
||||||
isin: null,
|
isin: null,
|
||||||
|
issuer: null,
|
||||||
logoName: "fond.png",
|
logoName: "fond.png",
|
||||||
quantity: "3",
|
quantity: "3",
|
||||||
|
sector: null,
|
||||||
ticker: null,
|
ticker: null,
|
||||||
type: "FUND",
|
type: "FUND",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
within,
|
within,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
import { http, HttpResponse } from "msw";
|
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 { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { BrokerageOperation } from "@/entities/broker-operation";
|
import type { BrokerageOperation } from "@/entities/broker-operation";
|
||||||
@@ -25,6 +27,7 @@ import type {
|
|||||||
AccountPageRecentOperations,
|
AccountPageRecentOperations,
|
||||||
} from "../../model/previews";
|
} from "../../model/previews";
|
||||||
|
|
||||||
|
import { toConcentration } from "../../model/concentration";
|
||||||
import {
|
import {
|
||||||
toAccountPagePositionSections,
|
toAccountPagePositionSections,
|
||||||
toUnrealizedReturns,
|
toUnrealizedReturns,
|
||||||
@@ -35,6 +38,19 @@ import { AccountPageError } from "./components/AccountPageError.view";
|
|||||||
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
import { AccountPageLoaded } from "./components/AccountPageLoaded.view";
|
||||||
import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.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() {
|
function configureTestAuth() {
|
||||||
configureAuthClient({
|
configureAuthClient({
|
||||||
accessToken: () => "test-token",
|
accessToken: () => "test-token",
|
||||||
@@ -50,6 +66,8 @@ function renderAccountRoute(queryClient: QueryClient, accountId = "account-1") {
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
|
<MemoryRouter initialEntries={[`/accounts/${accountId}`]}>
|
||||||
|
<AccountSwitcher />
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<AccountPage />} path="/accounts/:accountId" />
|
<Route element={<AccountPage />} path="/accounts/:accountId" />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -84,8 +102,10 @@ const portfolio
|
|||||||
displayName: "Вторая",
|
displayName: "Вторая",
|
||||||
figi: "FIGI-2",
|
figi: "FIGI-2",
|
||||||
isin: null,
|
isin: null,
|
||||||
|
issuer: null,
|
||||||
logoName: "vtoraya.png",
|
logoName: "vtoraya.png",
|
||||||
quantity: "2",
|
quantity: "2",
|
||||||
|
sector: null,
|
||||||
ticker: null,
|
ticker: null,
|
||||||
type: "STOCK",
|
type: "STOCK",
|
||||||
},
|
},
|
||||||
@@ -96,8 +116,10 @@ const portfolio
|
|||||||
displayName: "Первая",
|
displayName: "Первая",
|
||||||
figi: "FIGI-1",
|
figi: "FIGI-1",
|
||||||
isin: "RU000A0JX0J2",
|
isin: "RU000A0JX0J2",
|
||||||
|
issuer: null,
|
||||||
logoName: null,
|
logoName: null,
|
||||||
quantity: "1",
|
quantity: "1",
|
||||||
|
sector: null,
|
||||||
ticker: null,
|
ticker: null,
|
||||||
type: "BOND",
|
type: "BOND",
|
||||||
},
|
},
|
||||||
@@ -116,8 +138,10 @@ function position(
|
|||||||
displayName: "Инструмент",
|
displayName: "Инструмент",
|
||||||
figi: null,
|
figi: null,
|
||||||
isin: null,
|
isin: null,
|
||||||
|
issuer: null,
|
||||||
logoName: null,
|
logoName: null,
|
||||||
quantity: "1",
|
quantity: "1",
|
||||||
|
sector: null,
|
||||||
ticker: null,
|
ticker: null,
|
||||||
type: "STOCK",
|
type: "STOCK",
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -134,6 +158,19 @@ const emptyRecentEvents: AccountPageRecentEvents = {
|
|||||||
to: "/accounts/account-1/events",
|
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() {
|
function emptyProps() {
|
||||||
return {
|
return {
|
||||||
recentEvents: emptyRecentEvents,
|
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) {
|
function loadedProps(nextPortfolio: Portfolio = portfolio) {
|
||||||
return {
|
return {
|
||||||
|
concentration: null,
|
||||||
|
currency: null,
|
||||||
|
onCurrencyChange: vi.fn(),
|
||||||
|
onSliceChange: vi.fn(),
|
||||||
portfolio: nextPortfolio,
|
portfolio: nextPortfolio,
|
||||||
positionSections: toAccountPagePositionSections(nextPortfolio),
|
positionSections: toAccountPagePositionSections(nextPortfolio),
|
||||||
recentEvents: emptyRecentEvents,
|
recentEvents: emptyRecentEvents,
|
||||||
recentOperations: emptyRecentOperations,
|
recentOperations: emptyRecentOperations,
|
||||||
|
slice: "stocks" as const,
|
||||||
unrealizedReturns: toUnrealizedReturns(nextPortfolio.positions),
|
unrealizedReturns: toUnrealizedReturns(nextPortfolio.positions),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("AccountPage states", () => {
|
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 () => {
|
it("shows the loaded portfolio source sync status", async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () =>
|
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", () => {
|
it("shows supplied cash separately without adding currency instruments or recalculating totals", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps({
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
cashBalances: [
|
cashBalances: [
|
||||||
@@ -490,7 +867,7 @@ describe("AccountPage states", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
|
it("renders a stale portfolio in upstream order with nullable values hidden", () => {
|
||||||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
renderPage(<LoadedHarness {...loadedProps()} />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("heading", { level: 1, name: "Портфель" }),
|
screen.getByRole("heading", { level: 1, name: "Портфель" }),
|
||||||
@@ -528,7 +905,7 @@ describe("AccountPage states", () => {
|
|||||||
"renders %s logos, fallbacks and signed returns including zero",
|
"renders %s logos, fallbacks and signed returns including zero",
|
||||||
(type) => {
|
(type) => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps({
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [
|
positions: [
|
||||||
@@ -571,7 +948,7 @@ describe("AccountPage states", () => {
|
|||||||
"groups %s supporting metrics into quiet single lines",
|
"groups %s supporting metrics into quiet single lines",
|
||||||
(type) => {
|
(type) => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps({
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [position({
|
positions: [position({
|
||||||
@@ -593,7 +970,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
|
it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps({
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [
|
positions: [
|
||||||
@@ -618,7 +995,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("keeps other-position cards compact with labeled return and no logo", () => {
|
it("keeps other-position cards compact with labeled return and no logo", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps({
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [position({
|
positions: [position({
|
||||||
@@ -636,7 +1013,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("uses the cached account title when available", () => {
|
it("uses the cached account title when available", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps()}
|
{...loadedProps()}
|
||||||
|
|
||||||
account={{
|
account={{
|
||||||
@@ -655,7 +1032,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("keeps the portfolio visible when recent operations fail", () => {
|
it("keeps the portfolio visible when recent operations fail", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps()}
|
{...loadedProps()}
|
||||||
recentOperations={{
|
recentOperations={{
|
||||||
state: "error",
|
state: "error",
|
||||||
@@ -674,7 +1051,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("uses the ledger row for a recent outgoing trade operation", () => {
|
it("uses the ledger row for a recent outgoing trade operation", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps()}
|
{...loadedProps()}
|
||||||
|
|
||||||
recentOperations={{
|
recentOperations={{
|
||||||
@@ -724,7 +1101,7 @@ describe("AccountPage states", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps()}
|
{...loadedProps()}
|
||||||
|
|
||||||
recentEvents={{
|
recentEvents={{
|
||||||
@@ -793,7 +1170,7 @@ describe("AccountPage states", () => {
|
|||||||
"keeps the upcoming %s state and calendar action",
|
"keeps the upcoming %s state and calendar action",
|
||||||
(state) => {
|
(state) => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps()}
|
{...loadedProps()}
|
||||||
recentEvents={{ state, to: "/accounts/account-1/events" }}
|
recentEvents={{ state, to: "/accounts/account-1/events" }}
|
||||||
/>,
|
/>,
|
||||||
@@ -830,7 +1207,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("keeps the portfolio visible when upcoming events fail", () => {
|
it("keeps the portfolio visible when upcoming events fail", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps()}
|
{...loadedProps()}
|
||||||
recentEvents={{
|
recentEvents={{
|
||||||
state: "error",
|
state: "error",
|
||||||
@@ -995,7 +1372,7 @@ describe("AccountPage states", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("opens positions by default and switches between local tabs", () => {
|
it("opens positions by default and switches between local tabs", () => {
|
||||||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
renderPage(<LoadedHarness {...loadedProps()} />);
|
||||||
|
|
||||||
expect(screen.getByRole("tab", { name: "Позиции" }))
|
expect(screen.getByRole("tab", { name: "Позиции" }))
|
||||||
.toHaveAttribute("aria-selected", "true");
|
.toHaveAttribute("aria-selected", "true");
|
||||||
@@ -1012,7 +1389,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("links operation, event and income previews to their detail pages", () => {
|
it("links operation, event and income previews to their detail pages", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps()}
|
{...loadedProps()}
|
||||||
incomeSummary={{
|
incomeSummary={{
|
||||||
state: "empty",
|
state: "empty",
|
||||||
@@ -1033,7 +1410,7 @@ describe("AccountPage states", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("filters positions by the kinds present in the portfolio", () => {
|
it("filters positions by the kinds present in the portfolio", () => {
|
||||||
renderPage(<AccountPageLoaded {...loadedProps()} />);
|
renderPage(<LoadedHarness {...loadedProps()} />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getAllByRole<HTMLInputElement>("radio").map(radio => radio.value),
|
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", () => {
|
it("hides the position filter when only one kind exists", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps({
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [portfolio.positions[0]],
|
positions: [portfolio.positions[0]],
|
||||||
@@ -1060,7 +1437,7 @@ describe("AccountPage states", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("hides unrealized returns for a partial portfolio", () => {
|
it("hides unrealized returns for a partial portfolio", () => {
|
||||||
renderPage(<AccountPageLoaded {...loadedProps()} partial />);
|
renderPage(<LoadedHarness {...loadedProps()} partial />);
|
||||||
|
|
||||||
expect(screen.queryByRole("region", { name: "Нереализованный результат" }))
|
expect(screen.queryByRole("region", { name: "Нереализованный результат" }))
|
||||||
.not.toBeInTheDocument();
|
.not.toBeInTheDocument();
|
||||||
@@ -1068,7 +1445,7 @@ describe("AccountPage states", () => {
|
|||||||
|
|
||||||
it("omits an unrealized currency total when any return is incomplete", () => {
|
it("omits an unrealized currency total when any return is incomplete", () => {
|
||||||
renderPage(
|
renderPage(
|
||||||
<AccountPageLoaded
|
<LoadedHarness
|
||||||
{...loadedProps({
|
{...loadedProps({
|
||||||
...portfolio,
|
...portfolio,
|
||||||
positions: [
|
positions: [
|
||||||
|
|||||||
@@ -25,13 +25,20 @@ const AccountPage: FC = () => {
|
|||||||
<AccountPageLoaded
|
<AccountPageLoaded
|
||||||
account={viewModel.account}
|
account={viewModel.account}
|
||||||
accountId={viewModel.accountId}
|
accountId={viewModel.accountId}
|
||||||
|
concentration={viewModel.concentration}
|
||||||
|
currency={viewModel.currency}
|
||||||
incomeSummary={viewModel.incomeSummary}
|
incomeSummary={viewModel.incomeSummary}
|
||||||
|
onCurrencyChange={viewModel.onCurrencyChange}
|
||||||
|
onSliceChange={viewModel.onSliceChange}
|
||||||
|
onTabChange={viewModel.onTabChange}
|
||||||
partial={viewModel.partial}
|
partial={viewModel.partial}
|
||||||
portfolio={viewModel.portfolio}
|
portfolio={viewModel.portfolio}
|
||||||
positionSections={viewModel.positionSections}
|
positionSections={viewModel.positionSections}
|
||||||
recentEvents={viewModel.recentEvents}
|
recentEvents={viewModel.recentEvents}
|
||||||
recentOperations={viewModel.recentOperations}
|
recentOperations={viewModel.recentOperations}
|
||||||
|
slice={viewModel.slice}
|
||||||
source={viewModel.source}
|
source={viewModel.source}
|
||||||
|
tab={viewModel.tab}
|
||||||
unrealizedReturns={viewModel.unrealizedReturns}
|
unrealizedReturns={viewModel.unrealizedReturns}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
|
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
@@ -17,6 +19,13 @@ import { useScreenSync } from "@/features/tbank-connection";
|
|||||||
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
import { ApiErrorCode, apiErrorCode, isApiErrorCode } from "@/shared/api";
|
||||||
import { useInitialAvailability } from "@/shared/lib";
|
import { useInitialAvailability } from "@/shared/lib";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type Concentration,
|
||||||
|
concentrationCurrencies,
|
||||||
|
type ConcentrationLabels,
|
||||||
|
type Slice,
|
||||||
|
toConcentration,
|
||||||
|
} from "../../model/concentration";
|
||||||
import {
|
import {
|
||||||
type AccountPagePositionSection,
|
type AccountPagePositionSection,
|
||||||
toAccountPagePositionSections,
|
toAccountPagePositionSections,
|
||||||
@@ -45,10 +54,17 @@ type AccountPageEmptyViewModel = AccountPageContent & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AccountPageLoadedViewModel = 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;
|
portfolio: Portfolio;
|
||||||
positionSections: AccountPagePositionSection[];
|
positionSections: AccountPagePositionSection[];
|
||||||
|
slice: Slice;
|
||||||
source?: InvestmentResourceMeta;
|
source?: InvestmentResourceMeta;
|
||||||
state: "loaded";
|
state: "loaded";
|
||||||
|
tab: string;
|
||||||
unrealizedReturns: Portfolio["totalsByCurrency"];
|
unrealizedReturns: Portfolio["totalsByCurrency"];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,6 +82,24 @@ function isPortfolioAccessFailure(code?: string) {
|
|||||||
|
|
||||||
function useAccountPageViewModel(): AccountPageViewModel {
|
function useAccountPageViewModel(): AccountPageViewModel {
|
||||||
const { accountId } = useParams();
|
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 portfolio = usePortfolio(accountId);
|
||||||
|
|
||||||
const operations = useBrokerageOperations(accountId, {
|
const operations = useBrokerageOperations(accountId, {
|
||||||
@@ -122,18 +156,42 @@ function useAccountPageViewModel(): AccountPageViewModel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isPartial = portfolio.meta?.completeness === "incomplete";
|
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 {
|
return {
|
||||||
account,
|
account,
|
||||||
accountId,
|
accountId,
|
||||||
|
concentration,
|
||||||
|
currency,
|
||||||
incomeSummary,
|
incomeSummary,
|
||||||
|
onCurrencyChange: value => updateSelection({ currency: value }),
|
||||||
|
onSliceChange: slice => updateSelection({ slice }),
|
||||||
|
onTabChange: tab => updateSelection({ tab: tab ?? "positions" }),
|
||||||
partial: isPartial,
|
partial: isPartial,
|
||||||
portfolio: portfolio.data,
|
portfolio: portfolio.data,
|
||||||
positionSections: toAccountPagePositionSections(portfolio.data),
|
positionSections: toAccountPagePositionSections(portfolio.data),
|
||||||
recentEvents,
|
recentEvents,
|
||||||
recentOperations,
|
recentOperations,
|
||||||
|
slice: current.slice,
|
||||||
source: portfolio.meta,
|
source: portfolio.meta,
|
||||||
state: "loaded" as const,
|
state: "loaded" as const,
|
||||||
|
tab: current.tab,
|
||||||
unrealizedReturns: toUnrealizedReturns(portfolio.data.positions),
|
unrealizedReturns: toUnrealizedReturns(portfolio.data.positions),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { formatMoney } from "@/shared/lib";
|
|||||||
import { SourceSyncStatus } from "@/shared/ui";
|
import { SourceSyncStatus } from "@/shared/ui";
|
||||||
import { PortfolioStructure } from "@/widgets/portfolio-structure";
|
import { PortfolioStructure } from "@/widgets/portfolio-structure";
|
||||||
|
|
||||||
|
import type { Concentration as ConcentrationModel, Slice } from "../../../model/concentration";
|
||||||
import type { AccountPagePositionSection } from "../../../model/positions";
|
import type { AccountPagePositionSection } from "../../../model/positions";
|
||||||
import type {
|
import type {
|
||||||
AccountPageIncomeSummary,
|
AccountPageIncomeSummary,
|
||||||
@@ -27,6 +28,7 @@ import type {
|
|||||||
|
|
||||||
import classes from "../AccountPage.module.css";
|
import classes from "../AccountPage.module.css";
|
||||||
import { AccountHeading } from "./AccountHeading.view";
|
import { AccountHeading } from "./AccountHeading.view";
|
||||||
|
import { Concentration } from "./Concentration/Concentration.view";
|
||||||
import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
|
import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view";
|
||||||
import { IncomeSummary } from "./IncomeSummary.view";
|
import { IncomeSummary } from "./IncomeSummary.view";
|
||||||
import { PositionSection } from "./PositionSection.view";
|
import { PositionSection } from "./PositionSection.view";
|
||||||
@@ -35,19 +37,29 @@ import { RecentOperations } from "./RecentOperations.view";
|
|||||||
type AccountPageLoadedProps = {
|
type AccountPageLoadedProps = {
|
||||||
account?: BrokerageAccount;
|
account?: BrokerageAccount;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
concentration: ConcentrationModel | null;
|
||||||
|
currency: null | string;
|
||||||
incomeSummary?: AccountPageIncomeSummary;
|
incomeSummary?: AccountPageIncomeSummary;
|
||||||
|
onCurrencyChange: (currency: null | string) => void;
|
||||||
|
onSliceChange: (slice: Slice) => void;
|
||||||
|
onTabChange: (tab: null | string) => void;
|
||||||
partial?: boolean;
|
partial?: boolean;
|
||||||
portfolio: Portfolio;
|
portfolio: Portfolio;
|
||||||
positionSections: AccountPagePositionSection[];
|
positionSections: AccountPagePositionSection[];
|
||||||
recentEvents: AccountPageRecentEvents;
|
recentEvents: AccountPageRecentEvents;
|
||||||
recentOperations: AccountPageRecentOperations;
|
recentOperations: AccountPageRecentOperations;
|
||||||
|
slice: Slice;
|
||||||
source?: InvestmentResourceMeta;
|
source?: InvestmentResourceMeta;
|
||||||
|
tab: string;
|
||||||
unrealizedReturns: Portfolio["totalsByCurrency"];
|
unrealizedReturns: Portfolio["totalsByCurrency"];
|
||||||
};
|
};
|
||||||
|
|
||||||
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [filter, setFilter] = useState<string>();
|
const [filter, setFilter] = useState<string>();
|
||||||
|
const tab = props.tab;
|
||||||
|
|
||||||
|
const onTabChange = props.onTabChange;
|
||||||
|
|
||||||
const activeSection = props.positionSections.find(
|
const activeSection = props.positionSections.find(
|
||||||
section => section.kind === filter,
|
section => section.kind === filter,
|
||||||
@@ -75,7 +87,9 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{props.partial
|
{props.partial
|
||||||
? <Alert role="status">{t("investment.partial")}</Alert>
|
? tab !== "concentration" && (
|
||||||
|
<Alert role="status">{t("investment.partial")}</Alert>
|
||||||
|
)
|
||||||
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
|
: <CurrencyCards amounts={props.portfolio.totalsByCurrency} />}
|
||||||
|
|
||||||
{!props.partial && props.unrealizedReturns.length > 0 && (
|
{!props.partial && props.unrealizedReturns.length > 0 && (
|
||||||
@@ -107,14 +121,21 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
|||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
classNames={{ list: classes.tabsList }}
|
classNames={{ list: classes.tabsList }}
|
||||||
defaultValue="positions"
|
|
||||||
keepMounted={false}
|
keepMounted={false}
|
||||||
|
onChange={onTabChange}
|
||||||
|
value={tab}
|
||||||
>
|
>
|
||||||
<Tabs.List aria-label={t("portfolio.sections")} mb="md">
|
<Tabs.List aria-label={t("portfolio.sections")} mb="md">
|
||||||
<Tabs.Tab value="positions">
|
<Tabs.Tab value="positions">
|
||||||
{t("portfolio.tabs.positions")}
|
{t("portfolio.tabs.positions")}
|
||||||
</Tabs.Tab>
|
</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="cash">{t("portfolio.tabs.cash")}</Tabs.Tab>
|
||||||
|
|
||||||
<Tabs.Tab value="operations">
|
<Tabs.Tab value="operations">
|
||||||
@@ -143,6 +164,22 @@ const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Tabs.Panel>
|
</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">
|
<Tabs.Panel value="cash">
|
||||||
<CurrencyCards amounts={props.portfolio.cashBalances} />
|
<CurrencyCards amounts={props.portfolio.cashBalances} />
|
||||||
</Tabs.Panel>
|
</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: "Учебные технологии",
|
displayName: "Учебные технологии",
|
||||||
figi: null,
|
figi: null,
|
||||||
isin: null,
|
isin: null,
|
||||||
|
issuer: null,
|
||||||
logoName: null,
|
logoName: null,
|
||||||
logoSrc: null,
|
logoSrc: null,
|
||||||
quantity: "100",
|
quantity: "100",
|
||||||
@@ -19,6 +20,7 @@ const syntheticPosition
|
|||||||
returnPercentText: "+25,00 %",
|
returnPercentText: "+25,00 %",
|
||||||
returnText: "+5 000 ₽",
|
returnText: "+5 000 ₽",
|
||||||
returnTone: "positive",
|
returnTone: "positive",
|
||||||
|
sector: null,
|
||||||
shareText: "20%",
|
shareText: "20%",
|
||||||
ticker: "DEMO",
|
ticker: "DEMO",
|
||||||
type: "STOCK",
|
type: "STOCK",
|
||||||
|
|||||||
@@ -37,12 +37,14 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor
|
|||||||
displayName: `Инструмент ${index}`,
|
displayName: `Инструмент ${index}`,
|
||||||
figi: null,
|
figi: null,
|
||||||
isin: null,
|
isin: null,
|
||||||
|
issuer: null,
|
||||||
logoName: null,
|
logoName: null,
|
||||||
logoSrc: null,
|
logoSrc: null,
|
||||||
quantity: "10",
|
quantity: "10",
|
||||||
returnPercent: null,
|
returnPercent: null,
|
||||||
returnPercentText: null,
|
returnPercentText: null,
|
||||||
returnText: null,
|
returnText: null,
|
||||||
|
sector: null,
|
||||||
shareText: null,
|
shareText: null,
|
||||||
ticker: null,
|
ticker: null,
|
||||||
type: kind === "stocks" ? "STOCK" : otherType,
|
type: kind === "stocks" ? "STOCK" : otherType,
|
||||||
|
|||||||
Vendored
+8
@@ -1056,6 +1056,10 @@ export interface components {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
PortfolioIssuerDto: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
PortfolioPositionDto: {
|
PortfolioPositionDto: {
|
||||||
instrumentUid?: string | null;
|
instrumentUid?: string | null;
|
||||||
account?: components["schemas"]["AccountReferenceDto"];
|
account?: components["schemas"]["AccountReferenceDto"];
|
||||||
@@ -1070,6 +1074,8 @@ export interface components {
|
|||||||
isin: string | null;
|
isin: string | null;
|
||||||
ticker: string | null;
|
ticker: string | null;
|
||||||
logoName: string | null;
|
logoName: string | null;
|
||||||
|
sector: string | null;
|
||||||
|
issuer: components["schemas"]["PortfolioIssuerDto"] | null;
|
||||||
};
|
};
|
||||||
PortfolioDto: {
|
PortfolioDto: {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -1383,6 +1389,8 @@ export interface components {
|
|||||||
isin: string | null;
|
isin: string | null;
|
||||||
ticker: string | null;
|
ticker: string | null;
|
||||||
logoName: string | null;
|
logoName: string | null;
|
||||||
|
sector: string | null;
|
||||||
|
issuer: components["schemas"]["PortfolioIssuerDto"] | null;
|
||||||
returnPercent: string | null;
|
returnPercent: string | null;
|
||||||
};
|
};
|
||||||
StockPositionDto: {
|
StockPositionDto: {
|
||||||
|
|||||||
@@ -6423,6 +6423,21 @@
|
|||||||
"name"
|
"name"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"PortfolioIssuerDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
]
|
||||||
|
},
|
||||||
"PortfolioPositionDto": {
|
"PortfolioPositionDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -6495,6 +6510,19 @@
|
|||||||
"logoName": {
|
"logoName": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
},
|
||||||
|
"sector": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"issuer": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/PortfolioIssuerDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -6507,7 +6535,9 @@
|
|||||||
"absoluteReturn",
|
"absoluteReturn",
|
||||||
"isin",
|
"isin",
|
||||||
"ticker",
|
"ticker",
|
||||||
"logoName"
|
"logoName",
|
||||||
|
"sector",
|
||||||
|
"issuer"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"PortfolioDto": {
|
"PortfolioDto": {
|
||||||
@@ -7910,6 +7940,19 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"sector": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"issuer": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/PortfolioIssuerDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"returnPercent": {
|
"returnPercent": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
@@ -7926,6 +7969,8 @@
|
|||||||
"isin",
|
"isin",
|
||||||
"ticker",
|
"ticker",
|
||||||
"logoName",
|
"logoName",
|
||||||
|
"sector",
|
||||||
|
"issuer",
|
||||||
"returnPercent"
|
"returnPercent"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -509,6 +509,26 @@ const ru = {
|
|||||||
bonds: "Облигации",
|
bonds: "Облигации",
|
||||||
byAccount: "Позиции по счетам",
|
byAccount: "Позиции по счетам",
|
||||||
cashBalances: "Денежные остатки",
|
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: "Настройте подключение, чтобы посмотреть портфель.",
|
connectionErrorText: "Настройте подключение, чтобы посмотреть портфель.",
|
||||||
connectionErrorTitle: "Проверьте подключение T-Bank",
|
connectionErrorTitle: "Проверьте подключение T-Bank",
|
||||||
currencies: {
|
currencies: {
|
||||||
@@ -760,6 +780,7 @@ const ru = {
|
|||||||
consumer: "Потребительский сектор",
|
consumer: "Потребительский сектор",
|
||||||
energy: "Энергетика",
|
energy: "Энергетика",
|
||||||
financial: "Финансы",
|
financial: "Финансы",
|
||||||
|
government: "Государственный сектор",
|
||||||
health_care: "Здравоохранение",
|
health_care: "Здравоохранение",
|
||||||
industrials: "Промышленность",
|
industrials: "Промышленность",
|
||||||
it: "Информационные технологии",
|
it: "Информационные технологии",
|
||||||
|
|||||||
Reference in New Issue
Block a user