import { formatShare, type Portfolio } from "@/entities/portfolio"; import { type Decimal, decimalFromString, decimalToString, formatDecimal, formatMoney, instrumentLogoUrl, sumDecimals, } from "@/shared/lib"; type AccountPagePosition = Portfolio["positions"][number] & { instrumentTo?: string; logoSrc: null | string; returnPercent: null | string; returnPercentText: null | string; returnText: null | string; returnTone?: AccountPageReturnTone; shareText: null | string; }; type AccountPagePositionGroup = { key: string; positions: [AccountPagePosition, ...AccountPagePosition[]]; }; type AccountPagePositionSection = { groups: AccountPagePositionGroup[]; kind: PositionSectionKind; }; type AccountPageReturnTone = "negative" | "positive"; type Money = NonNullable; type PositionSectionKind = "bonds" | "other" | "stocks"; const RADIX = 10n; const PERCENT_SCALE = 6; const DISPLAY_PERCENT_SCALE = 2; const PERCENT_FACTOR = 100n; const ROUNDING_FACTOR = 2n; function formatPositionReturnPercent(value: string): string { const decimal = decimalFromString(value); let rounded = decimal.value; if (decimal.scale > DISPLAY_PERCENT_SCALE) { const divisor = RADIX ** BigInt(decimal.scale - DISPLAY_PERCENT_SCALE); const remainder = rounded % divisor; const absoluteRemainder = remainder < 0n ? -remainder : remainder; rounded /= divisor; if (absoluteRemainder * ROUNDING_FACTOR >= divisor) { rounded += decimal.value < 0n ? -1n : 1n; } } else { rounded *= RADIX ** BigInt(DISPLAY_PERCENT_SCALE - decimal.scale); } const sign = rounded > 0n ? "+" : ""; return `${sign}${decimalToString({ scale: DISPLAY_PERCENT_SCALE, value: rounded, })}`; } function groupPositions( positions: AccountPagePosition[], ): AccountPagePositionGroup[] { const groups = new Map(); for (const [index, position] of positions.entries()) { const identity = positionIdentity(position); const rowKey = JSON.stringify(["row", position.account?.id, index]); const key = identity && position.account ? JSON.stringify([position.type, ...identity]) : rowKey; const group = groups.get(key); if ( group && group.positions.every( item => item.account?.id !== position.account?.id, ) ) { group.positions.push(position); } else { const uniqueKey = group ? rowKey : key; groups.set(uniqueKey, { key: uniqueKey, positions: [position] }); } } // eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline. return [...groups.values()]; } function positionIdentity(position: AccountPagePosition) { if (position.instrumentUid?.trim()) return ["uid", position.instrumentUid.trim()]; if (position.figi?.trim()) return ["figi", position.figi.trim()]; return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined; } function returnPercent( position: Portfolio["positions"][number], ): null | string { const result = position.absoluteReturn; const price = position.averagePurchasePrice; if (!result || !price || result.currency !== price.currency) return null; const quantity = decimalFromString(position.quantity); const purchasePrice = decimalFromString(price.value); const cost = { scale: quantity.scale + purchasePrice.scale, value: quantity.value * purchasePrice.value, }; if (quantity.value <= 0n || cost.value <= 0n) return null; const absoluteReturn = decimalFromString(result.value); const numerator = absoluteReturn.value * PERCENT_FACTOR * RADIX ** BigInt(cost.scale + PERCENT_SCALE); const denominator = cost.value * RADIX ** BigInt(absoluteReturn.scale); const remainder = numerator % denominator; const isRoundsUp = (remainder < 0n ? -remainder : remainder) * ROUNDING_FACTOR >= denominator; const rounding = isRoundsUp ? 1n : 0n; const signedRounding = numerator < 0n ? -rounding : rounding; const rounded = numerator / denominator + signedRounding; return decimalToString({ scale: PERCENT_SCALE, value: rounded }); } function toAccountPagePositionSections( portfolio: Portfolio, ): AccountPagePositionSection[] { const denominators = new Map(); for (const position of portfolio.positions) { if (position.type === "CURRENCY" || !position.currentValue) continue; const currency = position.currentValue.currency; denominators.set( currency, (denominators.get(currency) ?? 0) + Number(position.currentValue.value), ); } const stocks = toSection( "stocks", portfolio, denominators, ); const bonds = toSection("bonds", portfolio, denominators); const other = toSection("other", portfolio, denominators); return [stocks, bonds, other].filter(section => section.groups.length > 0); } function toPosition( position: Portfolio["positions"][number], denominators: Map, kind: PositionSectionKind, ): AccountPagePosition { const isExpanded = kind !== "other"; const returnValue = position.absoluteReturn ? Number(position.absoluteReturn.value) : null; let returnTone: AccountPageReturnTone | undefined; if (returnValue !== null && returnValue !== 0) returnTone = returnValue > 0 ? "positive" : "negative"; const returnPrefix = isExpanded && returnValue !== null && returnValue > 0 ? "+" : ""; const percent = returnPercent(position); return { ...position, instrumentTo: (kind === "stocks" || kind === "bonds") && position.instrumentUid ? `/${kind}/${encodeURIComponent(position.instrumentUid)}` : undefined, logoSrc: isExpanded ? instrumentLogoUrl(position.logoName) : null, returnPercent: percent, returnPercentText: percent ? `${formatDecimal(formatPositionReturnPercent(percent))} %` : null, returnText: position.absoluteReturn ? `${returnPrefix}${formatMoney(position.absoluteReturn)}` : null, returnTone, shareText: position.currentValue?.currency ? formatShare(position.currentValue, { currency: position.currentValue.currency, value: String(denominators.get(position.currentValue.currency) ?? 0), }) : null, }; } function toSection( kind: PositionSectionKind, portfolio: Portfolio, denominators: Map, ): AccountPagePositionSection { return { groups: groupPositions( portfolio.positions .filter((position) => { if (kind === "stocks") return position.type === "STOCK"; return kind === "bonds" ? position.type === "BOND" : position.type !== "STOCK" && position.type !== "BOND" && position.type !== "CURRENCY"; }) .map(position => toPosition( position, denominators, kind, ), ), ), kind, }; } function toUnrealizedReturns( positions: Portfolio["positions"], ): Money | null { const values: Decimal[] = []; for (const position of positions) { if (!position.absoluteReturn) { // A position without any valuation has nothing to contribute. if (!position.averagePurchasePrice && !position.currentValue) continue; return null; } values.push(decimalFromString(position.absoluteReturn.value)); } return { currency: "RUB", value: decimalToString(sumDecimals(values)) }; } export { type AccountPagePosition, type AccountPagePositionGroup, type AccountPagePositionSection, formatPositionReturnPercent, toAccountPagePositionSections, toUnrealizedReturns, };