326 lines
8.6 KiB
TypeScript
326 lines
8.6 KiB
TypeScript
import { formatShare, type Portfolio } from "@/entities/portfolio";
|
|
import { formatDecimal, formatMoney, instrumentLogoUrl } 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 Decimal = { scale: number; value: bigint };
|
|
|
|
type Money = NonNullable<Portfolio["positions"][number]["absoluteReturn"]>;
|
|
|
|
type PositionSectionKind = "bonds"
|
|
| "other"
|
|
| "stocks";
|
|
|
|
const RADIX = 10n;
|
|
const DECIMAL_PARTS = 2;
|
|
const PERCENT_SCALE = 6;
|
|
const DISPLAY_PERCENT_SCALE = 2;
|
|
const PERCENT_FACTOR = 100n;
|
|
const ROUNDING_FACTOR = 2n;
|
|
|
|
function decimalFromString(value: string): Decimal {
|
|
const [whole = "0", fraction = ""] = value.split(".", DECIMAL_PARTS);
|
|
|
|
return { scale: fraction.length, value: BigInt(`${whole}${fraction}`) };
|
|
}
|
|
|
|
function decimalToString(decimal: Decimal): string {
|
|
const sign = decimal.value < 0n ? "-" : "";
|
|
|
|
const digits = (decimal.value < 0n ? -decimal.value : decimal.value)
|
|
.toString()
|
|
.padStart(decimal.scale + 1, "0");
|
|
|
|
const whole = decimal.scale
|
|
? digits.slice(0, -decimal.scale)
|
|
: digits;
|
|
|
|
let fraction = decimal.scale ? digits.slice(-decimal.scale) : "";
|
|
|
|
while (fraction.endsWith("0")) fraction = fraction.slice(0, -1);
|
|
|
|
const suffix = fraction ? `.${fraction}` : "";
|
|
|
|
return `${sign}${whole}${suffix}`;
|
|
}
|
|
|
|
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<string, AccountPagePositionGroup>();
|
|
|
|
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 sumDecimals(values: Decimal[]): Decimal {
|
|
const scale = Math.max(...values.map(value => value.scale));
|
|
|
|
const sum = values.reduce(
|
|
(total, value) => total
|
|
+ value.value * RADIX ** BigInt(scale - value.scale),
|
|
0n,
|
|
);
|
|
|
|
return { scale, value: sum };
|
|
}
|
|
|
|
function toAccountPagePositionSections(
|
|
portfolio: Portfolio,
|
|
): AccountPagePositionSection[] {
|
|
const denominators = new Map<string, number>();
|
|
|
|
for (const position of portfolio.positions) {
|
|
if (!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<string, number>,
|
|
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<string, number>,
|
|
): 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[] {
|
|
const totals = new Map<string, {
|
|
complete: boolean;
|
|
values: Decimal[];
|
|
}>();
|
|
|
|
for (const position of positions) {
|
|
const currencies = position.absoluteReturn
|
|
? [position.absoluteReturn.currency]
|
|
: [...new Set([
|
|
position.averagePurchasePrice?.currency,
|
|
position.currentValue?.currency,
|
|
].filter((currency): currency is string => !!currency))];
|
|
|
|
for (const currency of currencies) {
|
|
const total = totals.get(currency) ?? { complete: true, values: [] };
|
|
|
|
if (position.absoluteReturn) {
|
|
total.values.push(decimalFromString(position.absoluteReturn.value));
|
|
}
|
|
else {
|
|
total.complete = false;
|
|
}
|
|
|
|
totals.set(currency, total);
|
|
}
|
|
}
|
|
|
|
return [...totals].flatMap(([currency, total]) => total.complete
|
|
? [{ currency, value: decimalToString(sumDecimals(total.values)) }]
|
|
: []);
|
|
}
|
|
|
|
export {
|
|
type AccountPagePosition,
|
|
type AccountPagePositionGroup,
|
|
type AccountPagePositionSection,
|
|
formatPositionReturnPercent,
|
|
toAccountPagePositionSections,
|
|
toUnrealizedReturns,
|
|
};
|