279 lines
7.3 KiB
TypeScript
279 lines
7.3 KiB
TypeScript
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<Portfolio["positions"][number]["absoluteReturn"]>;
|
|
|
|
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<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 toAccountPagePositionSections(
|
|
portfolio: Portfolio,
|
|
): AccountPagePositionSection[] {
|
|
const denominators = new Map<string, number>();
|
|
|
|
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<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 | 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,
|
|
};
|