diff --git a/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.ts b/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.ts index 16a121d..87352b8 100644 --- a/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.ts +++ b/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.ts @@ -38,6 +38,78 @@ function moneyValue(money: BrokerMoney | null | undefined): number { return money?.value ?? 0; } +export function formatBrokerMoney(value: BrokerMoney | null | undefined): string { + if (!value) { + return '—'; + } + + return formatBrokerCurrencyValue(value.currency, value.value); +} + +export function formatBrokerCurrencyValue(currency: string, value: number): string { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: currency || 'RUB', + maximumFractionDigits: 2, + }).format(value); +} + +export function formatBrokerSignedCurrencyValue(currency: string, value: number | null): string { + if (value === null) { + return '—'; + } + + const formatted = formatBrokerCurrencyValue(currency, Math.abs(value)); + + if (value > 0) { + return `+${formatted}`; + } + + if (value < 0) { + return `−${formatted}`; + } + + return formatted; +} + +export function formatBrokerPercent(value: number | null): string { + if (value === null) { + return '—'; + } + + return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`; +} + +export function formatBrokerSignedPercent(value: number | null): string { + if (value === null) { + return '—'; + } + + const formatted = formatBrokerPercent(Math.abs(value)); + + if (value > 0) { + return `+${formatted}`; + } + + if (value < 0) { + return `−${formatted}`; + } + + return formatted; +} + +export function formatBrokerDate(value: string | null | undefined): string | null { + if (!value) { + return null; + } + + return new Date(value).toLocaleDateString('ru-RU'); +} + +export function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string { + return type === 'iis' ? 'ИИС' : 'Брокерский счёт'; +} + export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate { const portfolioSummaries = new Map(); const cashSummaries = new Map(); diff --git a/apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts b/apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts deleted file mode 100644 index 5e15834..0000000 --- a/apps/frontend/src/pages/broker/brokerAccountsOverview.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { BrokerPortfolio } from '../../api/responses'; -import { aggregateBrokerAccounts } from './brokerAccountsOverview'; - -function portfolio( - id: string, - currency: string, - total: number, - daily: number | null, - cash: number, -): BrokerPortfolio { - return { - account: { - id, - type: 'brokerage', - name: id, - status: 'ACCOUNT_STATUS_OPEN', - openedAt: '2022-06-16T00:00:00.000Z', - accessLevel: null, - }, - positionCounts: { shares: 1, bonds: 1, etf: 0, other: 0 }, - totals: { - shares: { currency, units: '0', nano: 0, value: total * 0.5 }, - bonds: { currency, units: '0', nano: 0, value: total * 0.3 }, - etf: null, - currencies: { currency, units: '0', nano: 0, value: total * 0.2 }, - futures: null, - options: null, - structuredProducts: null, - dfa: null, - portfolio: { currency, units: '0', nano: 0, value: total }, - }, - yields: { - expectedPercent: 10, - daily: daily === null ? null : { currency, units: '0', nano: 0, value: daily }, - dailyPercent: null, - }, - cash: [{ currency, units: '0', nano: 0, value: cash }], - blockedCash: [], - asOf: '2026-06-19T10:00:00.000Z', - }; -} - -describe('aggregateBrokerAccounts', () => { - it('sums comparable portfolios and uses the specified daily percent formula', () => { - const result = aggregateBrokerAccounts([ - portfolio('a', 'RUB', 1_100, 100, 200), - portfolio('b', 'RUB', 2_200, 200, 300), - ]); - - expect(result.portfolios).toEqual([ - expect.objectContaining({ - currency: 'RUB', - total: 3_300, - daily: 300, - dailyPercent: 10, - allocation: { shares: 1_650, bonds: 990, etf: 0, cash: 660, other: 0 }, - }), - ]); - expect(result.cash).toEqual([{ currency: 'RUB', value: 500 }]); - }); - - it('keeps different currencies separate', () => { - const result = aggregateBrokerAccounts([ - portfolio('rub', 'RUB', 1_100, 100, 200), - portfolio('usd', 'USD', 550, 50, 25), - ]); - - expect(result.portfolios.map(({ currency, total }) => ({ currency, total }))).toEqual([ - { currency: 'RUB', total: 1_100 }, - { currency: 'USD', total: 550 }, - ]); - }); - - it('does not expose a daily percent when one account lacks daily data', () => { - const result = aggregateBrokerAccounts([ - portfolio('a', 'RUB', 1_100, 100, 200), - portfolio('b', 'RUB', 2_000, null, 300), - ]); - - expect(result.portfolios[0]).toMatchObject({ daily: null, dailyPercent: null }); - }); - - it('does not expose a daily percent when start of day is non-positive', () => { - const result = aggregateBrokerAccounts([portfolio('a', 'RUB', 100, 100, 20)]); - - expect(result.portfolios[0]).toMatchObject({ daily: 100, dailyPercent: null }); - }); - - it('clamps negative residual other allocation to zero', () => { - const overAllocated = portfolio('a', 'RUB', 1_000, 50, 100); - overAllocated.totals.shares!.value = 700; - overAllocated.totals.bonds!.value = 400; - overAllocated.totals.currencies!.value = 100; - - const result = aggregateBrokerAccounts([overAllocated]); - - expect(result.portfolios[0].allocation).toEqual({ - shares: 700, - bonds: 400, - etf: 0, - cash: 100, - other: 0, - }); - }); - - it('returns empty summaries for empty or unsupported portfolios', () => { - const missingTotal = portfolio('a', 'RUB', 1_000, 50, 100); - missingTotal.totals.portfolio = null; - - expect(aggregateBrokerAccounts([])).toEqual({ portfolios: [], cash: [] }); - expect(aggregateBrokerAccounts([missingTotal])).toEqual({ - portfolios: [], - cash: [{ currency: 'RUB', value: 100 }], - }); - }); - - it('groups cash separately by currency', () => { - const mixedCash = portfolio('a', 'RUB', 1_000, 50, 100); - mixedCash.cash.push({ currency: 'USD', units: '0', nano: 0, value: 25 }); - - expect(aggregateBrokerAccounts([mixedCash]).cash).toEqual([ - { currency: 'RUB', value: 100 }, - { currency: 'USD', value: 25 }, - ]); - }); -});