diff --git a/apps/frontend/src/entities/broker-account/api/brokerAccountApi.ts b/apps/frontend/src/entities/broker-account/api/brokerAccountApi.ts new file mode 100644 index 0000000..6a9abe9 --- /dev/null +++ b/apps/frontend/src/entities/broker-account/api/brokerAccountApi.ts @@ -0,0 +1,28 @@ +import { request } from '../../../api/client'; +import type { ApiResponseMeta, BrokerAccount, BrokerPortfolio } from '../../../api/responses'; + +export type BrokerOperationQuery = { + from?: string; + to?: string; + cursor?: string; + limit?: number; + instrumentId?: string; + operationTypes?: string; + state?: string; +}; + +export function getBrokerAccounts(): Promise<{ + data: BrokerAccount[]; + meta: ApiResponseMeta; +}> { + return request('/api/v1/broker/accounts'); +} + +export function getBrokerPortfolio(accountId: string): Promise<{ + data: BrokerPortfolio; + meta: ApiResponseMeta; +}> { + return request( + `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`, + ); +} diff --git a/apps/frontend/src/entities/broker-account/index.ts b/apps/frontend/src/entities/broker-account/index.ts new file mode 100644 index 0000000..e0ef9ab --- /dev/null +++ b/apps/frontend/src/entities/broker-account/index.ts @@ -0,0 +1,9 @@ +export { useBrokerAccounts } from './model/useBrokerAccounts'; +export { useBrokerAccountPortfolios } from './model/useBrokerAccountPortfolios'; +export { useBrokerPortfolio } from './model/useBrokerPortfolio'; +export { aggregateBrokerAccounts } from './model/brokerAccountsOverview'; +export { + getBrokerAccounts, + getBrokerPortfolio, + type BrokerOperationQuery, +} from './api/brokerAccountApi'; diff --git a/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.test.ts b/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.test.ts new file mode 100644 index 0000000..b8af517 --- /dev/null +++ b/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import type { BrokerPortfolio } from '../../../api/responses'; +import { aggregateBrokerAccounts } from '../model/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 }, + ]); + }); +}); diff --git a/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.ts b/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.ts new file mode 100644 index 0000000..16a121d --- /dev/null +++ b/apps/frontend/src/entities/broker-account/model/brokerAccountsOverview.ts @@ -0,0 +1,126 @@ +import type { BrokerMoney, BrokerPortfolio } from '../../../api/responses'; + +export interface BrokerCurrencyAllocationSummary { + shares: number; + bonds: number; + etf: number; + cash: number; + other: number; +} + +export interface BrokerCurrencyPortfolioSummary { + currency: string; + total: number; + daily: number | null; + dailyPercent: number | null; + allocation: BrokerCurrencyAllocationSummary; +} + +export interface BrokerCurrencyCashSummary { + currency: string; + value: number; +} + +export interface BrokerAccountsAggregate { + portfolios: BrokerCurrencyPortfolioSummary[]; + cash: BrokerCurrencyCashSummary[]; +} + +interface MutableCurrencySummary { + currency: string; + total: number; + daily: number | null; + dailyComparable: boolean; + allocation: BrokerCurrencyAllocationSummary; +} + +function moneyValue(money: BrokerMoney | null | undefined): number { + return money?.value ?? 0; +} + +export function aggregateBrokerAccounts(portfolios: BrokerPortfolio[]): BrokerAccountsAggregate { + const portfolioSummaries = new Map(); + const cashSummaries = new Map(); + + for (const portfolio of portfolios) { + for (const cash of portfolio.cash) { + if (!cash.currency) { + continue; + } + + const existingCash = cashSummaries.get(cash.currency); + + if (existingCash) { + existingCash.value += cash.value; + } else { + cashSummaries.set(cash.currency, { currency: cash.currency, value: cash.value }); + } + } + + const totalMoney = portfolio.totals.portfolio; + const currency = totalMoney?.currency; + + if (!totalMoney || !currency) { + continue; + } + + const existingSummary = portfolioSummaries.get(currency); + const summary = + existingSummary ?? + ({ + currency, + total: 0, + daily: 0, + dailyComparable: true, + allocation: { shares: 0, bonds: 0, etf: 0, cash: 0, other: 0 }, + } satisfies MutableCurrencySummary); + + const total = totalMoney.value; + const shares = moneyValue(portfolio.totals.shares); + const bonds = moneyValue(portfolio.totals.bonds); + const etf = moneyValue(portfolio.totals.etf); + const cash = moneyValue(portfolio.totals.currencies); + const other = Math.max(0, total - shares - bonds - etf - cash); + + summary.total += total; + summary.allocation.shares += shares; + summary.allocation.bonds += bonds; + summary.allocation.etf += etf; + summary.allocation.cash += cash; + summary.allocation.other += other; + + const dailyMoney = portfolio.yields.daily; + const comparableDaily = dailyMoney && dailyMoney.currency === currency; + + if (!comparableDaily) { + summary.daily = null; + summary.dailyComparable = false; + } else if (summary.dailyComparable) { + summary.daily = (summary.daily ?? 0) + dailyMoney.value; + } + + if (!existingSummary) { + portfolioSummaries.set(currency, summary); + } + } + + return { + portfolios: Array.from(portfolioSummaries.values()).map((summary) => { + const daily = summary.dailyComparable ? summary.daily : null; + const startOfDay = daily === null ? null : summary.total - daily; + const dailyPercent = + daily === null || startOfDay === null || startOfDay <= 0 + ? null + : (daily / startOfDay) * 100; + + return { + currency: summary.currency, + total: summary.total, + daily, + dailyPercent, + allocation: summary.allocation, + }; + }), + cash: Array.from(cashSummaries.values()), + }; +} diff --git a/apps/frontend/src/entities/broker-account/model/useBrokerAccountPortfolios.ts b/apps/frontend/src/entities/broker-account/model/useBrokerAccountPortfolios.ts new file mode 100644 index 0000000..01e0447 --- /dev/null +++ b/apps/frontend/src/entities/broker-account/model/useBrokerAccountPortfolios.ts @@ -0,0 +1,17 @@ +import { useQueries } from '@tanstack/react-query'; +import type { BrokerAccount, BrokerPortfolio } from '../../../api/responses'; +import { getBrokerPortfolio } from '../api/brokerAccountApi'; + +export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) { + const queries = useQueries({ + queries: accounts.map((account) => ({ + queryKey: ['broker', 'portfolio', account.id], + queryFn: async (): Promise => (await getBrokerPortfolio(account.id)).data, + staleTime: 60_000, + retry: 2, + refetchOnWindowFocus: false, + })), + }); + + return accounts.map((account, index) => ({ account, query: queries[index] })); +} diff --git a/apps/frontend/src/entities/broker-account/model/useBrokerAccounts.ts b/apps/frontend/src/entities/broker-account/model/useBrokerAccounts.ts new file mode 100644 index 0000000..f8cb006 --- /dev/null +++ b/apps/frontend/src/entities/broker-account/model/useBrokerAccounts.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import type { BrokerAccount } from '../../../api/responses'; +import { getBrokerAccounts } from '../api/brokerAccountApi'; + +export function useBrokerAccounts() { + return useQuery({ + queryKey: ['broker', 'accounts'], + queryFn: async () => (await getBrokerAccounts()).data, + staleTime: 3_600_000, + retry: 2, + refetchOnWindowFocus: false, + }); +} diff --git a/apps/frontend/src/entities/broker-account/model/useBrokerPortfolio.ts b/apps/frontend/src/entities/broker-account/model/useBrokerPortfolio.ts new file mode 100644 index 0000000..688ad71 --- /dev/null +++ b/apps/frontend/src/entities/broker-account/model/useBrokerPortfolio.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; +import type { BrokerPortfolio } from '../../../api/responses'; +import { getBrokerPortfolio } from '../api/brokerAccountApi'; + +export function useBrokerPortfolio(accountId: string | undefined) { + return useQuery({ + queryKey: ['broker', 'portfolio', accountId], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerPortfolio(accountId!)).data, + staleTime: 60_000, + retry: 2, + refetchOnWindowFocus: false, + }); +} diff --git a/apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx b/apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx index 0db41a6..f24288b 100644 --- a/apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx +++ b/apps/frontend/src/hooks/useBrokerAccountPortfolios.test.tsx @@ -2,11 +2,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import { type ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { getBrokerPortfolio } from '../api/broker'; +import { getBrokerPortfolio } from '../entities/broker-account/api/brokerAccountApi'; import type { BrokerAccount, BrokerPortfolio } from '../api/responses'; import { useBrokerAccountPortfolios } from './useBrokerAccountPortfolios'; -vi.mock('../api/broker', () => ({ +vi.mock('../entities/broker-account/api/brokerAccountApi', () => ({ getBrokerPortfolio: vi.fn(), })); diff --git a/apps/frontend/src/hooks/useBrokerAccountPortfolios.ts b/apps/frontend/src/hooks/useBrokerAccountPortfolios.ts index 7feede3..1f6107a 100644 --- a/apps/frontend/src/hooks/useBrokerAccountPortfolios.ts +++ b/apps/frontend/src/hooks/useBrokerAccountPortfolios.ts @@ -1,17 +1 @@ -import { useQueries } from '@tanstack/react-query'; -import { getBrokerPortfolio } from '../api/broker'; -import type { BrokerAccount, BrokerPortfolio } from '../api/responses'; - -export function useBrokerAccountPortfolios(accounts: BrokerAccount[]) { - const queries = useQueries({ - queries: accounts.map((account) => ({ - queryKey: ['broker', 'portfolio', account.id], - queryFn: async (): Promise => (await getBrokerPortfolio(account.id)).data, - staleTime: 60_000, - retry: 2, - refetchOnWindowFocus: false, - })), - }); - - return accounts.map((account, index) => ({ account, query: queries[index] })); -} +export { useBrokerAccountPortfolios } from '../entities/broker-account'; diff --git a/apps/frontend/src/hooks/useBrokerAccounts.test.tsx b/apps/frontend/src/hooks/useBrokerAccounts.test.tsx index e3fe99c..8cfb766 100644 --- a/apps/frontend/src/hooks/useBrokerAccounts.test.tsx +++ b/apps/frontend/src/hooks/useBrokerAccounts.test.tsx @@ -2,10 +2,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import { type ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; -import { getBrokerAccounts } from '../api/broker'; +import { getBrokerAccounts } from '../entities/broker-account/api/brokerAccountApi'; import { useBrokerAccounts } from './useBrokerAccounts'; -vi.mock('../api/broker', () => ({ +vi.mock('../entities/broker-account/api/brokerAccountApi', () => ({ getBrokerAccounts: vi.fn(), })); diff --git a/apps/frontend/src/hooks/useBrokerAccounts.ts b/apps/frontend/src/hooks/useBrokerAccounts.ts index 70f880a..438e508 100644 --- a/apps/frontend/src/hooks/useBrokerAccounts.ts +++ b/apps/frontend/src/hooks/useBrokerAccounts.ts @@ -1,13 +1 @@ -import { useQuery } from '@tanstack/react-query'; -import { getBrokerAccounts } from '../api/broker'; -import type { BrokerAccount } from '../api/responses'; - -export function useBrokerAccounts() { - return useQuery({ - queryKey: ['broker', 'accounts'], - queryFn: async () => (await getBrokerAccounts()).data, - staleTime: 3_600_000, - retry: 2, - refetchOnWindowFocus: false, - }); -} +export { useBrokerAccounts } from '../entities/broker-account'; diff --git a/apps/frontend/src/hooks/useBrokerPortfolio.ts b/apps/frontend/src/hooks/useBrokerPortfolio.ts index 4b7db02..640ded0 100644 --- a/apps/frontend/src/hooks/useBrokerPortfolio.ts +++ b/apps/frontend/src/hooks/useBrokerPortfolio.ts @@ -1,14 +1 @@ -import { useQuery } from '@tanstack/react-query'; -import { getBrokerPortfolio } from '../api/broker'; -import type { BrokerPortfolio } from '../api/responses'; - -export function useBrokerPortfolio(accountId: string | undefined) { - return useQuery({ - queryKey: ['broker', 'portfolio', accountId], - enabled: Boolean(accountId), - queryFn: async () => (await getBrokerPortfolio(accountId!)).data, - staleTime: 60_000, - retry: 2, - refetchOnWindowFocus: false, - }); -} +export { useBrokerPortfolio } from '../entities/broker-account'; diff --git a/apps/frontend/src/pages/broker-accounts/ui/BrokerAccountsPage.tsx b/apps/frontend/src/pages/broker-accounts/ui/BrokerAccountsPage.tsx index 815e9f3..aaa0e05 100644 --- a/apps/frontend/src/pages/broker-accounts/ui/BrokerAccountsPage.tsx +++ b/apps/frontend/src/pages/broker-accounts/ui/BrokerAccountsPage.tsx @@ -1 +1,122 @@ -export { BrokerAccountsPage } from '../../broker/BrokerAccountsPage'; +import { + aggregateBrokerAccounts, + useBrokerAccounts, + useBrokerAccountPortfolios, +} from '../../../entities/broker-account'; +import { BrokerAccountCard } from '../../../widgets/broker-account-card'; +import { BrokerAccountsSummary } from '../../../widgets/broker-accounts-summary'; + +function BrokerAccountsPageSkeleton() { + return ( +
+
+
+

T-Bank broker overview

+

Брокерские счета

+
+
+ +
+ {['1', '2', '3'].map((id) => ( + undefined} + /> + ))} +
+
+ ); +} + +export function BrokerAccountsPage() { + const { data: accounts, isLoading, error } = useBrokerAccounts(); + const safeAccounts = accounts ?? []; + const accountQueries = useBrokerAccountPortfolios(safeAccounts); + + if (isLoading) { + return ; + } + + if (error) { + return

Не удалось загрузить счета

; + } + + if (safeAccounts.length === 0) { + return ( +
+
+
+

T-Bank broker overview

+

Брокерские счета

+
+
+
+

Пока нет подключённых счетов

+

+ После подключения T-Bank здесь появятся брокерские счета и ИИС со сводкой по капиталу. +

+
+
+ ); + } + + const successfulPortfolios = accountQueries + .map(({ query }) => query.data) + .filter((portfolio): portfolio is NonNullable => Boolean(portfolio)); + const loadingCount = accountQueries.filter( + ({ query }) => (query.isLoading || query.isPending || query.isFetching) && !query.data, + ).length; + const availableCount = successfulPortfolios.length; + const aggregate = aggregateBrokerAccounts(successfulPortfolios); + + return ( +
+
+
+

T-Bank broker overview

+

Брокерские счета

+
+

+ {safeAccounts.length} счетов под наблюдением +

+
+ + 0} + /> + +
+ {accountQueries.map(({ account, query }) => ( + { + void query.refetch(); + }} + /> + ))} +
+
+ ); +} diff --git a/apps/frontend/src/pages/broker/BrokerAccountCard.tsx b/apps/frontend/src/pages/broker/BrokerAccountCard.tsx index 4a4b82b..540f6ee 100644 --- a/apps/frontend/src/pages/broker/BrokerAccountCard.tsx +++ b/apps/frontend/src/pages/broker/BrokerAccountCard.tsx @@ -1,142 +1 @@ -import { Link } from 'react-router-dom'; -import { SkeletonBlock } from '../../components/SkeletonBlock'; -import type { BrokerAccount, BrokerPortfolio } from '../../api/responses'; -import { buildBrokerAllocation } from './brokerAllocation'; -import { BrokerAllocationBar } from './BrokerAllocationBar'; -import { - brokerAccountTypeLabel, - formatBrokerDate, - formatBrokerMoney, - formatBrokerSignedCurrencyValue, - formatBrokerSignedPercent, -} from './brokerAccountsOverview'; - -function BrokerAccountCardSkeleton({ name, typeLabel }: { name: string; typeLabel: string }) { - return ( -
-
-
-

{typeLabel}

-

{name}

-
-
-
- {[1, 2, 3, 4].map((item) => ( -
- - -
- ))} -
- - -
- ); -} - -function BrokerAccountCardError({ - account, - onRetry, -}: { - account: BrokerAccount; - onRetry: () => void; -}) { - return ( -
-
-
-

{brokerAccountTypeLabel(account.type)}

-

{account.name}

-
-
-
-

Не удалось загрузить данные счёта

- -
-
- ); -} - -function BrokerAccountCardSuccess({ - account, - portfolio, -}: { - account: BrokerAccount; - portfolio: BrokerPortfolio; -}) { - const typeLabel = brokerAccountTypeLabel(account.type); - const openedAt = formatBrokerDate(account.openedAt); - const allocation = buildBrokerAllocation(portfolio); - - return ( - -
-
-

{typeLabel}

-

{account.name}

-
- {openedAt ?

Открыт {openedAt}

: null} -
- -
-
- Стоимость - {formatBrokerMoney(portfolio.totals.portfolio)} -
-
- За день - - {formatBrokerSignedCurrencyValue( - portfolio.totals.portfolio?.currency ?? 'RUB', - portfolio.yields.daily?.value ?? null, - )} - -
-
- Дневная динамика - {formatBrokerSignedPercent(portfolio.yields.dailyPercent)} -
-
- Ожидаемая доходность - {formatBrokerSignedPercent(portfolio.yields.expectedPercent)} -
-
- - - - ); -} - -export function BrokerAccountCard({ - account, - portfolio, - isLoading, - error, - onRetry, -}: { - account: BrokerAccount; - portfolio?: BrokerPortfolio; - isLoading: boolean; - error: Error | null; - onRetry: () => void; -}) { - if (isLoading && !portfolio) { - return ( - - ); - } - - if (error || !portfolio) { - return ; - } - - return ; -} +export { BrokerAccountCard } from '../../widgets/broker-account-card'; diff --git a/apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx b/apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx index 3ef4508..0c6cb6d 100644 --- a/apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx +++ b/apps/frontend/src/pages/broker/BrokerAccountsPage.test.tsx @@ -5,8 +5,7 @@ import { type ReactElement } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { BrokerAccount, BrokerPortfolio } from '../../api/responses'; -import * as brokerAccountsHook from '../../hooks/useBrokerAccounts'; -import * as brokerAccountPortfoliosHook from '../../hooks/useBrokerAccountPortfolios'; +import * as brokerAccountEntity from '../../entities/broker-account'; import { BrokerAccountsPage } from './BrokerAccountsPage'; function renderPage(ui: ReactElement) { @@ -84,13 +83,13 @@ describe('BrokerAccountsPage', () => { const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' }); const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' }); - vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({ data: [broker, iis], isLoading: false, isFetching: false, error: null, } as any); - vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ + vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([ { account: broker, query: createQueryState({ data: createPortfolio(broker) }), @@ -140,13 +139,13 @@ describe('BrokerAccountsPage', () => { const broker = createAccount({ id: 'account one', name: 'Основной счёт' }); const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' }); - vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({ data: [broker, iis], isLoading: false, isFetching: false, error: null, } as any); - vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ + vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([ { account: broker, query: createQueryState({ data: createPortfolio(broker) }) }, { account: iis, query: createQueryState({ data: createPortfolio(iis) }) }, ] as any); @@ -165,7 +164,7 @@ describe('BrokerAccountsPage', () => { }); it('shows page skeleton while accounts are loading', () => { - vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({ data: undefined, isLoading: true, isFetching: true, @@ -178,13 +177,13 @@ describe('BrokerAccountsPage', () => { }); it('renders an empty state when there are no accounts', () => { - vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({ data: [], isLoading: false, isFetching: false, error: null, } as any); - vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([] as any); + vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([] as any); renderPage(); @@ -197,13 +196,13 @@ describe('BrokerAccountsPage', () => { const broker = createAccount({ id: 'acc-1', name: 'Основной счёт' }); const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' }); - vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({ data: [broker, iis], isLoading: false, isFetching: false, error: null, } as any); - vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ + vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([ { account: broker, query: createQueryState({ data: createPortfolio(broker) }) }, { account: iis, @@ -222,13 +221,13 @@ describe('BrokerAccountsPage', () => { const iis = createAccount({ id: 'acc-2', name: 'ИИС капитал', type: 'iis' }); const refetch = vi.fn(); - vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({ data: [broker, iis], isLoading: false, isFetching: false, error: null, } as any); - vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ + vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([ { account: broker, query: createQueryState({ data: createPortfolio(broker) }) }, { account: iis, @@ -250,13 +249,13 @@ describe('BrokerAccountsPage', () => { const broker = createAccount({ id: 'acc-1', name: 'Рублёвый счёт' }); const usd = createAccount({ id: 'acc-2', name: 'Долларовый счёт' }); - vi.spyOn(brokerAccountsHook, 'useBrokerAccounts').mockReturnValue({ + vi.spyOn(brokerAccountEntity, 'useBrokerAccounts').mockReturnValue({ data: [broker, usd], isLoading: false, isFetching: false, error: null, } as any); - vi.spyOn(brokerAccountPortfoliosHook, 'useBrokerAccountPortfolios').mockReturnValue([ + vi.spyOn(brokerAccountEntity, 'useBrokerAccountPortfolios').mockReturnValue([ { account: broker, query: createQueryState({ data: createPortfolio(broker) }) }, { account: usd, diff --git a/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx b/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx index ba1d963..2e85647 100644 --- a/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx +++ b/apps/frontend/src/pages/broker/BrokerAccountsPage.tsx @@ -1,120 +1 @@ -import { useBrokerAccountPortfolios } from '../../hooks/useBrokerAccountPortfolios'; -import { useBrokerAccounts } from '../../hooks/useBrokerAccounts'; -import { BrokerAccountCard } from './BrokerAccountCard'; -import { BrokerAccountsSummary } from './BrokerAccountsSummary'; -import { aggregateBrokerAccounts } from './brokerAccountsOverview'; - -function BrokerAccountsPageSkeleton() { - return ( -
-
-
-

T-Bank broker overview

-

Брокерские счета

-
-
- -
- {['1', '2', '3'].map((id) => ( - undefined} - /> - ))} -
-
- ); -} - -export function BrokerAccountsPage() { - const { data: accounts, isLoading, error } = useBrokerAccounts(); - const safeAccounts = accounts ?? []; - const accountQueries = useBrokerAccountPortfolios(safeAccounts); - - if (isLoading) { - return ; - } - - if (error) { - return

Не удалось загрузить счета

; - } - - if (safeAccounts.length === 0) { - return ( -
-
-
-

T-Bank broker overview

-

Брокерские счета

-
-
-
-

Пока нет подключённых счетов

-

- После подключения T-Bank здесь появятся брокерские счета и ИИС со сводкой по капиталу. -

-
-
- ); - } - - const successfulPortfolios = accountQueries - .map(({ query }) => query.data) - .filter((portfolio): portfolio is NonNullable => Boolean(portfolio)); - const loadingCount = accountQueries.filter( - ({ query }) => (query.isLoading || query.isPending || query.isFetching) && !query.data, - ).length; - const availableCount = successfulPortfolios.length; - const aggregate = aggregateBrokerAccounts(successfulPortfolios); - - return ( -
-
-
-

T-Bank broker overview

-

Брокерские счета

-
-

- {safeAccounts.length} счетов под наблюдением -

-
- - 0} - /> - -
- {accountQueries.map(({ account, query }) => ( - { - void query.refetch(); - }} - /> - ))} -
-
- ); -} +export { BrokerAccountsPage } from '../broker-accounts'; diff --git a/apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx b/apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx index 0329d4d..61efc23 100644 --- a/apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx +++ b/apps/frontend/src/pages/broker/BrokerAccountsSummary.tsx @@ -1,164 +1 @@ -import { SkeletonBlock } from '../../components/SkeletonBlock'; -import { buildBrokerAllocation } from './brokerAllocation'; -import { BrokerAllocationBar } from './BrokerAllocationBar'; -import type { BrokerAccountsAggregate } from './brokerAccountsOverview'; -import { - formatBrokerCurrencyValue, - formatBrokerSignedCurrencyValue, - formatBrokerSignedPercent, -} from './brokerAccountsOverview'; - -export function BrokerAccountsSummary({ - aggregate, - availableCount, - totalCount, - isLoading, -}: { - aggregate: BrokerAccountsAggregate; - availableCount: number; - totalCount: number; - isLoading: boolean; -}) { - if (isLoading) { - return ( -
-
- - - -
-
- {[1, 2, 3].map((item) => ( -
- - -
- ))} -
-
- ); - } - - return ( -
-
-
-

Финансовый обзор

-

Счета в одном кадре

-
-
- {totalCount} счетов - {availableCount !== totalCount ? ( - - Доступно по {availableCount} из {totalCount} счетов - - ) : null} -
-
- -
- {aggregate.portfolios.map((portfolioSummary) => { - const allocation = buildBrokerAllocation({ - account: { - id: 'aggregate', - type: 'brokerage', - name: 'aggregate', - status: 'ACCOUNT_STATUS_OPEN', - openedAt: null, - accessLevel: null, - }, - positionCounts: { shares: 0, bonds: 0, etf: 0, other: 0 }, - totals: { - shares: { - currency: portfolioSummary.currency, - units: '0', - nano: 0, - value: portfolioSummary.allocation.shares, - }, - bonds: { - currency: portfolioSummary.currency, - units: '0', - nano: 0, - value: portfolioSummary.allocation.bonds, - }, - etf: - portfolioSummary.allocation.etf > 0 - ? { - currency: portfolioSummary.currency, - units: '0', - nano: 0, - value: portfolioSummary.allocation.etf, - } - : null, - currencies: { - currency: portfolioSummary.currency, - units: '0', - nano: 0, - value: portfolioSummary.allocation.cash, - }, - futures: null, - options: null, - structuredProducts: null, - dfa: null, - portfolio: { - currency: portfolioSummary.currency, - units: '0', - nano: 0, - value: portfolioSummary.total, - }, - }, - yields: { expectedPercent: null, daily: null, dailyPercent: null }, - cash: [], - blockedCash: [], - asOf: '', - }); - - return ( -
-
- - {portfolioSummary.currency} - - - {formatBrokerCurrencyValue(portfolioSummary.currency, portfolioSummary.total)} - -
-
-
-
За день
-
- {formatBrokerSignedCurrencyValue( - portfolioSummary.currency, - portfolioSummary.daily, - )} -
-
-
-
Динамика
-
{formatBrokerSignedPercent(portfolioSummary.dailyPercent)}
-
-
-
Свободные деньги
-
- {formatBrokerCurrencyValue( - portfolioSummary.currency, - aggregate.cash.find((cash) => cash.currency === portfolioSummary.currency) - ?.value ?? 0, - )} -
-
-
- -
- ); - })} -
-
- ); -} +export { BrokerAccountsSummary } from '../../widgets/broker-accounts-summary'; diff --git a/apps/frontend/src/pages/broker/brokerAccountsOverview.ts b/apps/frontend/src/pages/broker/brokerAccountsOverview.ts index 8226ee2..e41724b 100644 --- a/apps/frontend/src/pages/broker/brokerAccountsOverview.ts +++ b/apps/frontend/src/pages/broker/brokerAccountsOverview.ts @@ -1,198 +1,14 @@ -import type { BrokerMoney, BrokerPortfolio } from '../../api/responses'; - -export interface BrokerCurrencyAllocationSummary { - shares: number; - bonds: number; - etf: number; - cash: number; - other: number; -} - -export interface BrokerCurrencyPortfolioSummary { - currency: string; - total: number; - daily: number | null; - dailyPercent: number | null; - allocation: BrokerCurrencyAllocationSummary; -} - -export interface BrokerCurrencyCashSummary { - currency: string; - value: number; -} - -export interface BrokerAccountsAggregate { - portfolios: BrokerCurrencyPortfolioSummary[]; - cash: BrokerCurrencyCashSummary[]; -} - -interface MutableCurrencySummary { - currency: string; - total: number; - daily: number | null; - dailyComparable: boolean; - allocation: BrokerCurrencyAllocationSummary; -} - -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(); - - for (const portfolio of portfolios) { - for (const cash of portfolio.cash) { - if (!cash.currency) { - continue; - } - - const existingCash = cashSummaries.get(cash.currency); - - if (existingCash) { - existingCash.value += cash.value; - } else { - cashSummaries.set(cash.currency, { currency: cash.currency, value: cash.value }); - } - } - - const totalMoney = portfolio.totals.portfolio; - const currency = totalMoney?.currency; - - if (!totalMoney || !currency) { - continue; - } - - const existingSummary = portfolioSummaries.get(currency); - const summary = - existingSummary ?? - ({ - currency, - total: 0, - daily: 0, - dailyComparable: true, - allocation: { shares: 0, bonds: 0, etf: 0, cash: 0, other: 0 }, - } satisfies MutableCurrencySummary); - - const total = totalMoney.value; - const shares = moneyValue(portfolio.totals.shares); - const bonds = moneyValue(portfolio.totals.bonds); - const etf = moneyValue(portfolio.totals.etf); - const cash = moneyValue(portfolio.totals.currencies); - const other = Math.max(0, total - shares - bonds - etf - cash); - - summary.total += total; - summary.allocation.shares += shares; - summary.allocation.bonds += bonds; - summary.allocation.etf += etf; - summary.allocation.cash += cash; - summary.allocation.other += other; - - const dailyMoney = portfolio.yields.daily; - const comparableDaily = dailyMoney && dailyMoney.currency === currency; - - if (!comparableDaily) { - summary.daily = null; - summary.dailyComparable = false; - } else if (summary.dailyComparable) { - summary.daily = (summary.daily ?? 0) + dailyMoney.value; - } - - if (!existingSummary) { - portfolioSummaries.set(currency, summary); - } - } - - return { - portfolios: Array.from(portfolioSummaries.values()).map((summary) => { - const daily = summary.dailyComparable ? summary.daily : null; - const startOfDay = daily === null ? null : summary.total - daily; - const dailyPercent = - daily === null || startOfDay === null || startOfDay <= 0 - ? null - : (daily / startOfDay) * 100; - - return { - currency: summary.currency, - total: summary.total, - daily, - dailyPercent, - allocation: summary.allocation, - }; - }), - cash: Array.from(cashSummaries.values()), - }; -} +export { + aggregateBrokerAccounts, + brokerAccountTypeLabel, + formatBrokerCurrencyValue, + formatBrokerDate, + formatBrokerMoney, + formatBrokerPercent, + formatBrokerSignedCurrencyValue, + formatBrokerSignedPercent, + type BrokerAccountsAggregate, + type BrokerCurrencyAllocationSummary, + type BrokerCurrencyCashSummary, + type BrokerCurrencyPortfolioSummary, +} from '../../entities/broker-account/model/brokerAccountsOverview'; diff --git a/apps/frontend/src/widgets/broker-account-card/index.ts b/apps/frontend/src/widgets/broker-account-card/index.ts new file mode 100644 index 0000000..10b8dd8 --- /dev/null +++ b/apps/frontend/src/widgets/broker-account-card/index.ts @@ -0,0 +1 @@ +export { BrokerAccountCard } from './ui/BrokerAccountCard'; diff --git a/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx b/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx new file mode 100644 index 0000000..f4b160c --- /dev/null +++ b/apps/frontend/src/widgets/broker-account-card/ui/BrokerAccountCard.tsx @@ -0,0 +1,201 @@ +import { Link } from 'react-router-dom'; +import { SkeletonBlock } from '../../../components/SkeletonBlock'; +import type { BrokerAccount, BrokerMoney, BrokerPortfolio } from '../../../api/responses'; +import { buildBrokerAllocation } from '../../../pages/broker/brokerAllocation'; +import { BrokerAllocationBar } from '../../../pages/broker/BrokerAllocationBar'; + +function formatBrokerCurrencyValue(currency: string, value: number): string { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: currency || 'RUB', + maximumFractionDigits: 2, + }).format(value); +} + +function formatBrokerMoney(value: BrokerMoney | null | undefined): string { + if (!value) { + return '—'; + } + + return formatBrokerCurrencyValue(value.currency, value.value); +} + +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; +} + +function formatBrokerSignedPercent(value: number | null): string { + if (value === null) { + return '—'; + } + + const formatted = `${new Intl.NumberFormat('ru-RU', { + maximumFractionDigits: 2, + }).format(Math.abs(value))}%`; + + if (value > 0) { + return `+${formatted}`; + } + + if (value < 0) { + return `−${formatted}`; + } + + return formatted; +} + +function formatBrokerDate(value: string | null | undefined): string | null { + if (!value) { + return null; + } + + return new Date(value).toLocaleDateString('ru-RU'); +} + +function brokerAccountTypeLabel(type: 'brokerage' | 'iis'): string { + return type === 'iis' ? 'ИИС' : 'Брокерский счёт'; +} + +function BrokerAccountCardSkeleton({ name, typeLabel }: { name: string; typeLabel: string }) { + return ( +
+
+
+

{typeLabel}

+

{name}

+
+
+
+ {[1, 2, 3, 4].map((item) => ( +
+ + +
+ ))} +
+ + +
+ ); +} + +function BrokerAccountCardError({ + account, + onRetry, +}: { + account: BrokerAccount; + onRetry: () => void; +}) { + return ( +
+
+
+

{brokerAccountTypeLabel(account.type)}

+

{account.name}

+
+
+
+

Не удалось загрузить данные счёта

+ +
+
+ ); +} + +function BrokerAccountCardSuccess({ + account, + portfolio, +}: { + account: BrokerAccount; + portfolio: BrokerPortfolio; +}) { + const typeLabel = brokerAccountTypeLabel(account.type); + const openedAt = formatBrokerDate(account.openedAt); + const allocation = buildBrokerAllocation(portfolio); + + return ( + +
+
+

{typeLabel}

+

{account.name}

+
+ {openedAt ?

Открыт {openedAt}

: null} +
+ +
+
+ Стоимость + {formatBrokerMoney(portfolio.totals.portfolio)} +
+
+ За день + + {formatBrokerSignedCurrencyValue( + portfolio.totals.portfolio?.currency ?? 'RUB', + portfolio.yields.daily?.value ?? null, + )} + +
+
+ Дневная динамика + {formatBrokerSignedPercent(portfolio.yields.dailyPercent)} +
+
+ Ожидаемая доходность + {formatBrokerSignedPercent(portfolio.yields.expectedPercent)} +
+
+ + + + ); +} + +export function BrokerAccountCard({ + account, + portfolio, + isLoading, + error, + onRetry, +}: { + account: BrokerAccount; + portfolio?: BrokerPortfolio; + isLoading: boolean; + error: Error | null; + onRetry: () => void; +}) { + if (isLoading && !portfolio) { + return ( + + ); + } + + if (error || !portfolio) { + return ; + } + + return ; +} diff --git a/apps/frontend/src/widgets/broker-accounts-summary/index.ts b/apps/frontend/src/widgets/broker-accounts-summary/index.ts new file mode 100644 index 0000000..5a4c1dc --- /dev/null +++ b/apps/frontend/src/widgets/broker-accounts-summary/index.ts @@ -0,0 +1 @@ +export { BrokerAccountsSummary } from './ui/BrokerAccountsSummary'; diff --git a/apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx b/apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx new file mode 100644 index 0000000..1482471 --- /dev/null +++ b/apps/frontend/src/widgets/broker-accounts-summary/ui/BrokerAccountsSummary.tsx @@ -0,0 +1,205 @@ +import { SkeletonBlock } from '../../../components/SkeletonBlock'; +import { buildBrokerAllocation } from '../../../pages/broker/brokerAllocation'; +import { BrokerAllocationBar } from '../../../pages/broker/BrokerAllocationBar'; +import type { BrokerAccountsAggregate } from '../../../pages/broker/brokerAccountsOverview'; + +function formatBrokerCurrencyValue(currency: string, value: number): string { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: currency || 'RUB', + maximumFractionDigits: 2, + }).format(value); +} + +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; +} + +function formatBrokerSignedPercent(value: number | null): string { + if (value === null) { + return '—'; + } + + const formatted = `${new Intl.NumberFormat('ru-RU', { + maximumFractionDigits: 2, + }).format(Math.abs(value))}%`; + + if (value > 0) { + return `+${formatted}`; + } + + if (value < 0) { + return `−${formatted}`; + } + + return formatted; +} + +export function BrokerAccountsSummary({ + aggregate, + availableCount, + totalCount, + isLoading, +}: { + aggregate: BrokerAccountsAggregate; + availableCount: number; + totalCount: number; + isLoading: boolean; +}) { + if (isLoading) { + return ( +
+
+ + + +
+
+ {[1, 2, 3].map((item) => ( +
+ + +
+ ))} +
+
+ ); + } + + return ( +
+
+
+

Финансовый обзор

+

Счета в одном кадре

+
+
+ {totalCount} счетов + {availableCount !== totalCount ? ( + + Доступно по {availableCount} из {totalCount} счетов + + ) : null} +
+
+ +
+ {aggregate.portfolios.map((portfolioSummary) => { + const allocation = buildBrokerAllocation({ + account: { + id: 'aggregate', + type: 'brokerage', + name: 'aggregate', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }, + positionCounts: { shares: 0, bonds: 0, etf: 0, other: 0 }, + totals: { + shares: { + currency: portfolioSummary.currency, + units: '0', + nano: 0, + value: portfolioSummary.allocation.shares, + }, + bonds: { + currency: portfolioSummary.currency, + units: '0', + nano: 0, + value: portfolioSummary.allocation.bonds, + }, + etf: + portfolioSummary.allocation.etf > 0 + ? { + currency: portfolioSummary.currency, + units: '0', + nano: 0, + value: portfolioSummary.allocation.etf, + } + : null, + currencies: { + currency: portfolioSummary.currency, + units: '0', + nano: 0, + value: portfolioSummary.allocation.cash, + }, + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: { + currency: portfolioSummary.currency, + units: '0', + nano: 0, + value: portfolioSummary.total, + }, + }, + yields: { expectedPercent: null, daily: null, dailyPercent: null }, + cash: [], + blockedCash: [], + asOf: '', + }); + + return ( +
+
+ + {portfolioSummary.currency} + + + {formatBrokerCurrencyValue(portfolioSummary.currency, portfolioSummary.total)} + +
+
+
+
За день
+
+ {formatBrokerSignedCurrencyValue( + portfolioSummary.currency, + portfolioSummary.daily, + )} +
+
+
+
Динамика
+
{formatBrokerSignedPercent(portfolioSummary.dailyPercent)}
+
+
+
Свободные деньги
+
+ {formatBrokerCurrencyValue( + portfolioSummary.currency, + aggregate.cash.find((cash) => cash.currency === portfolioSummary.currency) + ?.value ?? 0, + )} +
+
+
+ +
+ ); + })} +
+
+ ); +}