238 lines
6.0 KiB
TypeScript
238 lines
6.0 KiB
TypeScript
import type { Portfolio } from "@/entities/portfolio";
|
|
|
|
import {
|
|
type Decimal,
|
|
decimalFromString,
|
|
decimalToString,
|
|
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;
|
|
name: string;
|
|
share: string;
|
|
unknown: boolean;
|
|
};
|
|
|
|
type ConcentrationOther = ConcentrationItem & {
|
|
count: number;
|
|
entries: ConcentrationItem[];
|
|
};
|
|
|
|
type Group = {
|
|
best?: { name: string; value: Decimal };
|
|
first: number;
|
|
key: string;
|
|
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,
|
|
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);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 groupIdentity(position: Position, slice: Slice, index: number) {
|
|
if (slice === "sectors") {
|
|
const sector = position.sector;
|
|
|
|
return { key: sector ?? "", name: sector ?? "", unknown: !sector };
|
|
}
|
|
|
|
if (slice === "issuers") {
|
|
return {
|
|
key: position.issuer?.id ?? "",
|
|
name: position.issuer?.name ?? "",
|
|
unknown: !position.issuer,
|
|
};
|
|
}
|
|
|
|
return {
|
|
key: String(index),
|
|
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,
|
|
): Concentration {
|
|
const currencies = [...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));
|
|
|
|
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, selected);
|
|
}
|
|
|
|
const ranked = Array.from(groups.values(), group => ({
|
|
...group, sum: sumDecimals(group.values),
|
|
})).toSorted((a, b) => compareDecimal(b.sum, a.sum)
|
|
|| compareNames(a.name, b.name) || 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,
|
|
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",
|
|
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 { toConcentration };
|
|
export type { Concentration, ConcentrationItem, Slice };
|