feat(account): add concentration tab with top-5 slices and chart (WealthLedger/main#48) (#127)
This commit was merged in pull request #127.
This commit is contained in:
@@ -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: "Акция",
|
||||
figi: "FIGI-STOCK",
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: "akciya.png",
|
||||
quantity: "2",
|
||||
sector: null,
|
||||
ticker: null,
|
||||
type: "STOCK",
|
||||
},
|
||||
@@ -25,8 +27,10 @@ const portfolio
|
||||
displayName: "Облигация",
|
||||
figi: "FIGI-BOND",
|
||||
isin: "RU000A0JX0J2",
|
||||
issuer: null,
|
||||
logoName: null,
|
||||
quantity: "1",
|
||||
sector: null,
|
||||
ticker: null,
|
||||
type: "BOND",
|
||||
},
|
||||
@@ -37,8 +41,10 @@ const portfolio
|
||||
displayName: "Фонд",
|
||||
figi: "FIGI-FUND",
|
||||
isin: null,
|
||||
issuer: null,
|
||||
logoName: "fond.png",
|
||||
quantity: "3",
|
||||
sector: null,
|
||||
ticker: null,
|
||||
type: "FUND",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user