feat: replace account page accordion with tabs and show open position result (#112)
Frontend verify / commits (push) Skipped
Frontend verify / security (push) Successful in 20s
Frontend verify / verify (push) Failing after 6m36s

This commit was merged in pull request #113.
This commit is contained in:
2026-10-26 20:30:35 +03:00
parent 1a92f023c1
commit 3075081df1
18 changed files with 630 additions and 131 deletions
+156 -1
View File
@@ -1,9 +1,11 @@
import { formatShare, type Portfolio } from "@/entities/portfolio";
import { formatMoney, instrumentLogoUrl } from "@/shared/lib";
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;
@@ -21,10 +23,73 @@ type AccountPagePositionSection = {
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[] {
@@ -67,6 +132,53 @@ function positionIdentity(position: AccountPagePosition) {
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[] {
@@ -111,6 +223,8 @@ function toPosition(
const returnPrefix
= isExpanded && returnValue !== null && returnValue > 0 ? "+" : "";
const percent = returnPercent(position);
return {
...position,
instrumentTo:
@@ -120,6 +234,10 @@ function toPosition(
logoSrc: isExpanded
? instrumentLogoUrl(position.logoName)
: null,
returnPercent: percent,
returnPercentText: percent
? `${formatDecimal(formatPositionReturnPercent(percent))} %`
: null,
returnText: position.absoluteReturn
? `${returnPrefix}${formatMoney(position.absoluteReturn)}`
: null,
@@ -162,9 +280,46 @@ function toSection(
};
}
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,
};