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,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,
|
||||
};
|
||||
Reference in New Issue
Block a user