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, 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(); 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 };