refactor(frontend): move broker account slice to fsd

This commit is contained in:
Sergey Krylov 2026-06-20 12:29:30 +03:00
parent a03e3c0240
commit 55cc479abb
22 changed files with 902 additions and 688 deletions

View File

@ -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<BrokerAccount[]>('/api/v1/broker/accounts');
}
export function getBrokerPortfolio(accountId: string): Promise<{
data: BrokerPortfolio;
meta: ApiResponseMeta;
}> {
return request<BrokerPortfolio>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`,
);
}

View File

@ -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';

View File

@ -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 },
]);
});
});

View File

@ -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<string, MutableCurrencySummary>();
const cashSummaries = new Map<string, BrokerCurrencyCashSummary>();
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()),
};
}

View File

@ -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<BrokerPortfolio> => (await getBrokerPortfolio(account.id)).data,
staleTime: 60_000,
retry: 2,
refetchOnWindowFocus: false,
})),
});
return accounts.map((account, index) => ({ account, query: queries[index] }));
}

View File

@ -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<BrokerAccount[]>({
queryKey: ['broker', 'accounts'],
queryFn: async () => (await getBrokerAccounts()).data,
staleTime: 3_600_000,
retry: 2,
refetchOnWindowFocus: false,
});
}

View File

@ -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<BrokerPortfolio>({
queryKey: ['broker', 'portfolio', accountId],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerPortfolio(accountId!)).data,
staleTime: 60_000,
retry: 2,
refetchOnWindowFocus: false,
});
}

View File

@ -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(),
}));

View File

@ -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<BrokerPortfolio> => (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';

View File

@ -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(),
}));

View File

@ -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<BrokerAccount[]>({
queryKey: ['broker', 'accounts'],
queryFn: async () => (await getBrokerAccounts()).data,
staleTime: 3_600_000,
retry: 2,
refetchOnWindowFocus: false,
});
}
export { useBrokerAccounts } from '../entities/broker-account';

View File

@ -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<BrokerPortfolio>({
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';

View File

@ -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 (
<div className="broker-accounts-page">
<header className="broker-accounts-page__header">
<div>
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
</div>
</header>
<BrokerAccountsSummary
aggregate={{ portfolios: [], cash: [] }}
availableCount={0}
totalCount={0}
isLoading
/>
<div className="broker-accounts-page__cards">
{['1', '2', '3'].map((id) => (
<BrokerAccountCard
key={id}
account={{
id,
name: 'Загрузка счёта',
type: 'brokerage',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
}}
isLoading
error={null}
onRetry={() => undefined}
/>
))}
</div>
</div>
);
}
export function BrokerAccountsPage() {
const { data: accounts, isLoading, error } = useBrokerAccounts();
const safeAccounts = accounts ?? [];
const accountQueries = useBrokerAccountPortfolios(safeAccounts);
if (isLoading) {
return <BrokerAccountsPageSkeleton />;
}
if (error) {
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>;
}
if (safeAccounts.length === 0) {
return (
<div className="broker-accounts-page">
<header className="broker-accounts-page__header">
<div>
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
</div>
</header>
<section className="broker-accounts-empty">
<h2>Пока нет подключённых счетов</h2>
<p>
После подключения T-Bank здесь появятся брокерские счета и ИИС со сводкой по капиталу.
</p>
</section>
</div>
);
}
const successfulPortfolios = accountQueries
.map(({ query }) => query.data)
.filter((portfolio): portfolio is NonNullable<typeof portfolio> => 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 (
<div className="broker-accounts-page">
<header className="broker-accounts-page__header">
<div>
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
</div>
<p className="broker-accounts-page__caption">
{safeAccounts.length} счетов под наблюдением
</p>
</header>
<BrokerAccountsSummary
aggregate={aggregate}
availableCount={availableCount}
totalCount={safeAccounts.length}
isLoading={availableCount === 0 && loadingCount > 0}
/>
<div className="broker-accounts-page__cards">
{accountQueries.map(({ account, query }) => (
<BrokerAccountCard
key={account.id}
account={account}
portfolio={query.data}
isLoading={(query.isLoading || query.isPending || query.isFetching) && !query.data}
error={(query.error as Error | null) ?? null}
onRetry={() => {
void query.refetch();
}}
/>
))}
</div>
</div>
);
}

View File

@ -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 (
<article className="broker-account-card broker-account-card--loading" aria-busy="true">
<div className="broker-account-card__header">
<div>
<p className="broker-account-card__eyebrow">{typeLabel}</p>
<h2 className="broker-account-card__title">{name}</h2>
</div>
</div>
<div className="broker-account-card__grid">
{[1, 2, 3, 4].map((item) => (
<div className="broker-account-card__stat" key={item}>
<SkeletonBlock height={12} width="50%" />
<SkeletonBlock height={24} width="75%" />
</div>
))}
</div>
<SkeletonBlock height={16} width="100%" borderRadius={999} />
<SkeletonBlock height={16} width="65%" />
</article>
);
}
function BrokerAccountCardError({
account,
onRetry,
}: {
account: BrokerAccount;
onRetry: () => void;
}) {
return (
<article className="broker-account-card broker-account-card--error">
<div className="broker-account-card__header">
<div>
<p className="broker-account-card__eyebrow">{brokerAccountTypeLabel(account.type)}</p>
<h2 className="broker-account-card__title">{account.name}</h2>
</div>
</div>
<div className="broker-account-card__alert" role="alert">
<p>Не удалось загрузить данные счёта</p>
<button className="broker-account-card__retry" type="button" onClick={onRetry}>
Повторить
</button>
</div>
</article>
);
}
function BrokerAccountCardSuccess({
account,
portfolio,
}: {
account: BrokerAccount;
portfolio: BrokerPortfolio;
}) {
const typeLabel = brokerAccountTypeLabel(account.type);
const openedAt = formatBrokerDate(account.openedAt);
const allocation = buildBrokerAllocation(portfolio);
return (
<Link
className="broker-account-card broker-account-card--link"
to={`/broker/${encodeURIComponent(account.id)}`}
>
<div className="broker-account-card__header">
<div>
<p className="broker-account-card__eyebrow">{typeLabel}</p>
<h2 className="broker-account-card__title">{account.name}</h2>
</div>
{openedAt ? <p className="broker-account-card__opened">Открыт {openedAt}</p> : null}
</div>
<div className="broker-account-card__grid">
<div className="broker-account-card__stat broker-account-card__stat--wide">
<span>Стоимость</span>
<strong>{formatBrokerMoney(portfolio.totals.portfolio)}</strong>
</div>
<div className="broker-account-card__stat">
<span>За день</span>
<strong>
{formatBrokerSignedCurrencyValue(
portfolio.totals.portfolio?.currency ?? 'RUB',
portfolio.yields.daily?.value ?? null,
)}
</strong>
</div>
<div className="broker-account-card__stat">
<span>Дневная динамика</span>
<strong>{formatBrokerSignedPercent(portfolio.yields.dailyPercent)}</strong>
</div>
<div className="broker-account-card__stat">
<span>Ожидаемая доходность</span>
<strong>{formatBrokerSignedPercent(portfolio.yields.expectedPercent)}</strong>
</div>
</div>
<BrokerAllocationBar title={`Структура счёта ${account.name}`} items={allocation.sectors} />
</Link>
);
}
export function BrokerAccountCard({
account,
portfolio,
isLoading,
error,
onRetry,
}: {
account: BrokerAccount;
portfolio?: BrokerPortfolio;
isLoading: boolean;
error: Error | null;
onRetry: () => void;
}) {
if (isLoading && !portfolio) {
return (
<BrokerAccountCardSkeleton
name={account.name}
typeLabel={brokerAccountTypeLabel(account.type)}
/>
);
}
if (error || !portfolio) {
return <BrokerAccountCardError account={account} onRetry={onRetry} />;
}
return <BrokerAccountCardSuccess account={account} portfolio={portfolio} />;
}
export { BrokerAccountCard } from '../../widgets/broker-account-card';

View File

@ -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(<BrokerAccountsPage />);
@ -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,

View File

@ -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 (
<div className="broker-accounts-page">
<header className="broker-accounts-page__header">
<div>
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
</div>
</header>
<BrokerAccountsSummary
aggregate={{ portfolios: [], cash: [] }}
availableCount={0}
totalCount={0}
isLoading
/>
<div className="broker-accounts-page__cards">
{['1', '2', '3'].map((id) => (
<BrokerAccountCard
key={id}
account={{
id,
name: 'Загрузка счёта',
type: 'brokerage',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
}}
isLoading
error={null}
onRetry={() => undefined}
/>
))}
</div>
</div>
);
}
export function BrokerAccountsPage() {
const { data: accounts, isLoading, error } = useBrokerAccounts();
const safeAccounts = accounts ?? [];
const accountQueries = useBrokerAccountPortfolios(safeAccounts);
if (isLoading) {
return <BrokerAccountsPageSkeleton />;
}
if (error) {
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить счета</p>;
}
if (safeAccounts.length === 0) {
return (
<div className="broker-accounts-page">
<header className="broker-accounts-page__header">
<div>
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
</div>
</header>
<section className="broker-accounts-empty">
<h2>Пока нет подключённых счетов</h2>
<p>
После подключения T-Bank здесь появятся брокерские счета и ИИС со сводкой по капиталу.
</p>
</section>
</div>
);
}
const successfulPortfolios = accountQueries
.map(({ query }) => query.data)
.filter((portfolio): portfolio is NonNullable<typeof portfolio> => 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 (
<div className="broker-accounts-page">
<header className="broker-accounts-page__header">
<div>
<p className="broker-accounts-page__eyebrow">T-Bank broker overview</p>
<h1 className="broker-accounts-page__title">Брокерские счета</h1>
</div>
<p className="broker-accounts-page__caption">
{safeAccounts.length} счетов под наблюдением
</p>
</header>
<BrokerAccountsSummary
aggregate={aggregate}
availableCount={availableCount}
totalCount={safeAccounts.length}
isLoading={availableCount === 0 && loadingCount > 0}
/>
<div className="broker-accounts-page__cards">
{accountQueries.map(({ account, query }) => (
<BrokerAccountCard
key={account.id}
account={account}
portfolio={query.data}
isLoading={(query.isLoading || query.isPending || query.isFetching) && !query.data}
error={(query.error as Error | null) ?? null}
onRetry={() => {
void query.refetch();
}}
/>
))}
</div>
</div>
);
}
export { BrokerAccountsPage } from '../broker-accounts';

View File

@ -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 (
<section className="broker-accounts-summary broker-accounts-summary--loading">
<div className="broker-accounts-summary__hero">
<SkeletonBlock height={18} width="34%" />
<SkeletonBlock height={52} width="48%" />
<SkeletonBlock height={18} width="28%" />
</div>
<div className="broker-accounts-summary__metrics">
{[1, 2, 3].map((item) => (
<div className="broker-accounts-summary__metric" key={item}>
<SkeletonBlock height={14} width="45%" />
<SkeletonBlock height={26} width="70%" />
</div>
))}
</div>
</section>
);
}
return (
<section className="broker-accounts-summary" aria-label="Общая сводка по счетам">
<div className="broker-accounts-summary__hero">
<div>
<p className="broker-accounts-summary__eyebrow">Финансовый обзор</p>
<h2 className="broker-accounts-summary__title">Счета в одном кадре</h2>
</div>
<div className="broker-accounts-summary__status">
<span>{totalCount} счетов</span>
{availableCount !== totalCount ? (
<span>
Доступно по {availableCount} из {totalCount} счетов
</span>
) : null}
</div>
</div>
<div className="broker-accounts-summary__currency-grid">
{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 (
<article
className="broker-accounts-summary__currency-card"
key={portfolioSummary.currency}
>
<div className="broker-accounts-summary__currency-header">
<span className="broker-accounts-summary__currency">
{portfolioSummary.currency}
</span>
<strong className="broker-accounts-summary__total">
{formatBrokerCurrencyValue(portfolioSummary.currency, portfolioSummary.total)}
</strong>
</div>
<dl className="broker-accounts-summary__metrics">
<div className="broker-accounts-summary__metric">
<dt>За день</dt>
<dd>
{formatBrokerSignedCurrencyValue(
portfolioSummary.currency,
portfolioSummary.daily,
)}
</dd>
</div>
<div className="broker-accounts-summary__metric">
<dt>Динамика</dt>
<dd>{formatBrokerSignedPercent(portfolioSummary.dailyPercent)}</dd>
</div>
<div className="broker-accounts-summary__metric">
<dt>Свободные деньги</dt>
<dd>
{formatBrokerCurrencyValue(
portfolioSummary.currency,
aggregate.cash.find((cash) => cash.currency === portfolioSummary.currency)
?.value ?? 0,
)}
</dd>
</div>
</dl>
<BrokerAllocationBar
title={`Распределение активов ${portfolioSummary.currency}`}
items={allocation.sectors}
/>
</article>
);
})}
</div>
</section>
);
}
export { BrokerAccountsSummary } from '../../widgets/broker-accounts-summary';

View File

@ -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<string, MutableCurrencySummary>();
const cashSummaries = new Map<string, BrokerCurrencyCashSummary>();
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';

View File

@ -0,0 +1 @@
export { BrokerAccountCard } from './ui/BrokerAccountCard';

View File

@ -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 (
<article className="broker-account-card broker-account-card--loading" aria-busy="true">
<div className="broker-account-card__header">
<div>
<p className="broker-account-card__eyebrow">{typeLabel}</p>
<h2 className="broker-account-card__title">{name}</h2>
</div>
</div>
<div className="broker-account-card__grid">
{[1, 2, 3, 4].map((item) => (
<div className="broker-account-card__stat" key={item}>
<SkeletonBlock height={12} width="50%" />
<SkeletonBlock height={24} width="75%" />
</div>
))}
</div>
<SkeletonBlock height={16} width="100%" borderRadius={999} />
<SkeletonBlock height={16} width="65%" />
</article>
);
}
function BrokerAccountCardError({
account,
onRetry,
}: {
account: BrokerAccount;
onRetry: () => void;
}) {
return (
<article className="broker-account-card broker-account-card--error">
<div className="broker-account-card__header">
<div>
<p className="broker-account-card__eyebrow">{brokerAccountTypeLabel(account.type)}</p>
<h2 className="broker-account-card__title">{account.name}</h2>
</div>
</div>
<div className="broker-account-card__alert" role="alert">
<p>Не удалось загрузить данные счёта</p>
<button className="broker-account-card__retry" type="button" onClick={onRetry}>
Повторить
</button>
</div>
</article>
);
}
function BrokerAccountCardSuccess({
account,
portfolio,
}: {
account: BrokerAccount;
portfolio: BrokerPortfolio;
}) {
const typeLabel = brokerAccountTypeLabel(account.type);
const openedAt = formatBrokerDate(account.openedAt);
const allocation = buildBrokerAllocation(portfolio);
return (
<Link
className="broker-account-card broker-account-card--link"
to={`/broker/${encodeURIComponent(account.id)}`}
>
<div className="broker-account-card__header">
<div>
<p className="broker-account-card__eyebrow">{typeLabel}</p>
<h2 className="broker-account-card__title">{account.name}</h2>
</div>
{openedAt ? <p className="broker-account-card__opened">Открыт {openedAt}</p> : null}
</div>
<div className="broker-account-card__grid">
<div className="broker-account-card__stat broker-account-card__stat--wide">
<span>Стоимость</span>
<strong>{formatBrokerMoney(portfolio.totals.portfolio)}</strong>
</div>
<div className="broker-account-card__stat">
<span>За день</span>
<strong>
{formatBrokerSignedCurrencyValue(
portfolio.totals.portfolio?.currency ?? 'RUB',
portfolio.yields.daily?.value ?? null,
)}
</strong>
</div>
<div className="broker-account-card__stat">
<span>Дневная динамика</span>
<strong>{formatBrokerSignedPercent(portfolio.yields.dailyPercent)}</strong>
</div>
<div className="broker-account-card__stat">
<span>Ожидаемая доходность</span>
<strong>{formatBrokerSignedPercent(portfolio.yields.expectedPercent)}</strong>
</div>
</div>
<BrokerAllocationBar title={`Структура счёта ${account.name}`} items={allocation.sectors} />
</Link>
);
}
export function BrokerAccountCard({
account,
portfolio,
isLoading,
error,
onRetry,
}: {
account: BrokerAccount;
portfolio?: BrokerPortfolio;
isLoading: boolean;
error: Error | null;
onRetry: () => void;
}) {
if (isLoading && !portfolio) {
return (
<BrokerAccountCardSkeleton
name={account.name}
typeLabel={brokerAccountTypeLabel(account.type)}
/>
);
}
if (error || !portfolio) {
return <BrokerAccountCardError account={account} onRetry={onRetry} />;
}
return <BrokerAccountCardSuccess account={account} portfolio={portfolio} />;
}

View File

@ -0,0 +1 @@
export { BrokerAccountsSummary } from './ui/BrokerAccountsSummary';

View File

@ -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 (
<section className="broker-accounts-summary broker-accounts-summary--loading">
<div className="broker-accounts-summary__hero">
<SkeletonBlock height={18} width="34%" />
<SkeletonBlock height={52} width="48%" />
<SkeletonBlock height={18} width="28%" />
</div>
<div className="broker-accounts-summary__metrics">
{[1, 2, 3].map((item) => (
<div className="broker-accounts-summary__metric" key={item}>
<SkeletonBlock height={14} width="45%" />
<SkeletonBlock height={26} width="70%" />
</div>
))}
</div>
</section>
);
}
return (
<section className="broker-accounts-summary" aria-label="Общая сводка по счетам">
<div className="broker-accounts-summary__hero">
<div>
<p className="broker-accounts-summary__eyebrow">Финансовый обзор</p>
<h2 className="broker-accounts-summary__title">Счета в одном кадре</h2>
</div>
<div className="broker-accounts-summary__status">
<span>{totalCount} счетов</span>
{availableCount !== totalCount ? (
<span>
Доступно по {availableCount} из {totalCount} счетов
</span>
) : null}
</div>
</div>
<div className="broker-accounts-summary__currency-grid">
{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 (
<article
className="broker-accounts-summary__currency-card"
key={portfolioSummary.currency}
>
<div className="broker-accounts-summary__currency-header">
<span className="broker-accounts-summary__currency">
{portfolioSummary.currency}
</span>
<strong className="broker-accounts-summary__total">
{formatBrokerCurrencyValue(portfolioSummary.currency, portfolioSummary.total)}
</strong>
</div>
<dl className="broker-accounts-summary__metrics">
<div className="broker-accounts-summary__metric">
<dt>За день</dt>
<dd>
{formatBrokerSignedCurrencyValue(
portfolioSummary.currency,
portfolioSummary.daily,
)}
</dd>
</div>
<div className="broker-accounts-summary__metric">
<dt>Динамика</dt>
<dd>{formatBrokerSignedPercent(portfolioSummary.dailyPercent)}</dd>
</div>
<div className="broker-accounts-summary__metric">
<dt>Свободные деньги</dt>
<dd>
{formatBrokerCurrencyValue(
portfolioSummary.currency,
aggregate.cash.find((cash) => cash.currency === portfolioSummary.currency)
?.value ?? 0,
)}
</dd>
</div>
</dl>
<BrokerAllocationBar
title={`Распределение активов ${portfolioSummary.currency}`}
items={allocation.sectors}
/>
</article>
);
})}
</div>
</section>
);
}