Compare commits

..

18 Commits

Author SHA1 Message Date
1dc27a6e9b feat: добавил инструкции к SDD подходу
All checks were successful
CI / ci (push) Successful in 3m13s
2026-06-19 07:38:33 +03:00
d8b3886130 docs: complete broker account sections 2026-06-19 07:11:18 +03:00
2223463dd3 feat: filter broker operations by exact type 2026-06-19 07:06:49 +03:00
910494a4e5 feat: add broker asset pages 2026-06-19 07:03:09 +03:00
b82771107d fix: harden broker overview edge cases 2026-06-19 06:52:22 +03:00
a19ad67a89 feat: add broker account overview 2026-06-19 06:43:38 +03:00
1c26d2a3eb fix: improve broker account shell accessibility 2026-06-19 06:32:20 +03:00
633def5ebb fix: keep broker shell styling scoped 2026-06-19 06:27:04 +03:00
dc4b6ddf1b feat: add broker account section navigation 2026-06-19 06:23:35 +03:00
6d2df6a12b fix: stabilize broker display models 2026-06-19 06:13:23 +03:00
2462f2122c feat: add broker account display models 2026-06-19 06:08:17 +03:00
5cb51e44f2 chore: sync broker portfolio contract 2026-06-19 06:00:15 +03:00
8aea56cbbf feat: expose broker position counts 2026-06-18 23:15:32 +03:00
da6d05e425 chore: ignore local worktrees 2026-06-18 23:10:25 +03:00
be24184e4f docs: approve broker account sections plan 2026-06-18 23:07:42 +03:00
62cef9b1c4 docs: plan broker account sections 2026-06-18 23:04:47 +03:00
30922aaa28 docs: link broker account feature to roadmap 2026-06-18 22:51:08 +03:00
0c12e6d610 docs: specify broker account sections 2026-06-18 22:47:24 +03:00
32 changed files with 3971 additions and 772 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
node_modules/
dist/
.superpowers/
.worktrees/
.env
*.log
.DS_Store

View File

@ -42,10 +42,27 @@ export class BrokerPortfolioYieldsDto {
dailyPercent!: number | null;
}
export class BrokerPortfolioPositionCountsDto {
@ApiProperty({ minimum: 0 })
shares!: number;
@ApiProperty({ minimum: 0 })
bonds!: number;
@ApiProperty({ minimum: 0 })
etf!: number;
@ApiProperty({ minimum: 0 })
other!: number;
}
export class BrokerPortfolioResponseDto {
@ApiProperty({ type: BrokerAccountResponseDto })
account!: BrokerAccountResponseDto;
@ApiProperty({ type: BrokerPortfolioPositionCountsDto })
positionCounts!: BrokerPortfolioPositionCountsDto;
@ApiProperty({ type: BrokerPortfolioTotalsDto })
totals!: BrokerPortfolioTotalsDto;

View File

@ -30,6 +30,12 @@ describe('portfolio.mapper', () => {
currentPrice: { currency: 'rub', units: '250', nano: 0 },
averagePositionPrice: { currency: 'rub', units: '200', nano: 0 },
},
{ instrumentType: 'SHARE' },
{ instrumentType: 'bond' },
{ instrumentType: 'etf' },
{ instrumentType: 'fund' },
{ instrumentType: 'future' },
{},
],
},
positions: {
@ -43,5 +49,6 @@ describe('portfolio.mapper', () => {
expect(result.totals.shares?.value).toBe(1000);
expect(result.cash[0].value).toBe(500);
expect(result.blockedCash[0].value).toBe(10);
expect(result.positionCounts).toEqual({ shares: 2, bonds: 1, etf: 2, other: 2 });
});
});

View File

@ -74,8 +74,28 @@ export function mapBrokerPosition(input: {
}
export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio {
const positionCounts = { shares: 0, bonds: 0, etf: 0, other: 0 };
for (const position of input.portfolio.positions ?? []) {
switch (position.instrumentType?.toLowerCase()) {
case 'share':
positionCounts.shares += 1;
break;
case 'bond':
positionCounts.bonds += 1;
break;
case 'etf':
case 'fund':
positionCounts.etf += 1;
break;
default:
positionCounts.other += 1;
}
}
return {
account: input.account,
positionCounts,
totals: {
shares: mapMoneyValue(input.portfolio.totalAmountShares),
bonds: mapMoneyValue(input.portfolio.totalAmountBonds),

View File

@ -46,7 +46,7 @@ describe('BrokerPortfolioService', () => {
.mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [],
positions: [{ instrumentType: 'share' }, { instrumentType: 'bond' }],
})
.mockResolvedValueOnce({
accountId: 'acc-1',
@ -60,7 +60,9 @@ describe('BrokerPortfolioService', () => {
expect(result.data.account.id).toBe('acc-1');
expect(result.data.cash[0].value).toBe(1000);
expect(result.data.positionCounts).toEqual({ shares: 1, bonds: 1, etf: 0, other: 0 });
expect('positions' in result.data).toBe(false);
expect(client.callUnary).toHaveBeenCalledTimes(2);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:portfolio',
['acc-1'],

View File

@ -33,6 +33,12 @@ export type BrokerPosition = {
export type BrokerPortfolio = {
account: BrokerAccount;
positionCounts: {
shares: number;
bonds: number;
etf: number;
other: number;
};
totals: {
shares: BrokerMoney | null;
bonds: BrokerMoney | null;

View File

@ -25,6 +25,31 @@ describe('broker api', () => {
);
});
it('serializes operations query parameters including operationTypes', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({
data: {
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
meta: { fromCache: false, cachedAt: null },
},
}),
} as Response);
await getBrokerOperations('acc-1', {
cursor: 'c1',
limit: 10,
operationTypes: 'OPERATION_TYPE_COUPON',
});
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining(
'/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=10&operationTypes=OPERATION_TYPE_COUPON',
),
expect.any(Object),
);
});
it('serializes positions query parameters', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,

View File

@ -281,6 +281,12 @@ export interface BrokerPosition {
export interface BrokerPortfolio {
account: BrokerAccount;
positionCounts: {
shares: number;
bonds: number;
etf: number;
other: number;
};
totals: {
shares: BrokerMoney | null;
bonds: BrokerMoney | null;

View File

@ -417,6 +417,23 @@ export interface paths {
patch?: never;
trace?: never;
};
'/api/v1/broker/accounts/{accountId}/positions': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get paginated T-Bank broker account positions */
get: operations['TBankController_getPositions'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/broker/accounts/{accountId}/operations': {
parameters: {
query?: never;
@ -774,6 +791,12 @@ export interface components {
data: components['schemas']['BrokerAccountResponseDto'][];
meta: components['schemas']['BrokerResponseMetaDto'];
};
BrokerPortfolioPositionCountsDto: {
shares: number;
bonds: number;
etf: number;
other: number;
};
BrokerMoneyDto: {
currency: string;
units: string;
@ -796,6 +819,19 @@ export interface components {
daily: components['schemas']['BrokerMoneyDto'] | null;
dailyPercent: Record<string, never> | null;
};
BrokerPortfolioResponseDto: {
account: components['schemas']['BrokerAccountResponseDto'];
positionCounts: components['schemas']['BrokerPortfolioPositionCountsDto'];
totals: components['schemas']['BrokerPortfolioTotalsDto'];
yields: components['schemas']['BrokerPortfolioYieldsDto'];
cash: components['schemas']['BrokerMoneyDto'][];
blockedCash: components['schemas']['BrokerMoneyDto'][];
asOf: string;
};
BrokerPortfolioEnvelopeDto: {
data: components['schemas']['BrokerPortfolioResponseDto'];
meta: components['schemas']['BrokerResponseMetaDto'];
};
BrokerPositionResponseDto: {
figi: Record<string, never> | null;
instrumentUid: Record<string, never> | null;
@ -812,17 +848,15 @@ export interface components {
expectedYieldPercent: Record<string, never> | null;
dailyYield: components['schemas']['BrokerMoneyDto'] | null;
};
BrokerPortfolioResponseDto: {
account: components['schemas']['BrokerAccountResponseDto'];
totals: components['schemas']['BrokerPortfolioTotalsDto'];
yields: components['schemas']['BrokerPortfolioYieldsDto'];
cash: components['schemas']['BrokerMoneyDto'][];
blockedCash: components['schemas']['BrokerMoneyDto'][];
positions: components['schemas']['BrokerPositionResponseDto'][];
BrokerPositionsPageResponseDto: {
accountId: string;
items: components['schemas']['BrokerPositionResponseDto'][];
nextCursor: Record<string, never> | null;
hasNext: boolean;
asOf: string;
};
BrokerPortfolioEnvelopeDto: {
data: components['schemas']['BrokerPortfolioResponseDto'];
BrokerPositionsEnvelopeDto: {
data: components['schemas']['BrokerPositionsPageResponseDto'];
meta: components['schemas']['BrokerResponseMetaDto'];
};
BrokerOperationResponseDto: {
@ -835,6 +869,7 @@ export interface components {
/** @enum {string} */
category: 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
description: Record<string, never> | null;
name: Record<string, never> | null;
state: Record<string, never> | null;
instrumentUid: Record<string, never> | null;
figi: Record<string, never> | null;
@ -1519,6 +1554,33 @@ export interface operations {
};
};
};
TBankController_getPositions: {
parameters: {
query?: {
/** @description Cursor for pagination (positionUid) */
cursor?: string;
limit?: number;
/** @description Filter by instrument type (share, bond, etf, etc.) */
type?: string;
};
header?: never;
path: {
accountId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['BrokerPositionsEnvelopeDto'];
};
};
};
};
TBankController_getOperations: {
parameters: {
query?: {

View File

@ -1,135 +0,0 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import type { BrokerMoney } from '../../api/responses';
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
import { BrokerOperationsTable } from './BrokerOperationsTable';
import { BrokerPositionsSection } from './BrokerPositionsSection';
import { SkeletonBlock } from '../../components/SkeletonBlock';
function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '-';
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: value.currency || 'RUB',
maximumFractionDigits: 2,
}).format(value.value);
}
export function BrokerAccountDetailPage() {
const { accountId } = useParams();
const [operationCursor, setOperationCursor] = useState<string | undefined>(undefined);
const [operationCursorStack, setOperationCursorStack] = useState<Array<string | undefined>>([]);
const portfolio = useBrokerPortfolio(accountId);
const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor });
if (portfolio.isLoading) {
return (
<div style={{ display: 'grid', gap: 24 }}>
<div style={{ display: 'grid', gap: 12 }}>
<SkeletonBlock height={32} width="60%" />
<SkeletonBlock height={24} width="40%" />
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 12,
}}
>
{[1, 2, 3].map((i) => (
<div
key={i}
style={{
padding: 16,
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 8,
}}
>
<SkeletonBlock height={14} width="40%" />
<div style={{ height: 8 }} />
<SkeletonBlock height={20} width="60%" />
</div>
))}
</div>
</div>
);
}
if (portfolio.error || !portfolio.data) {
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить портфель</p>;
}
function handleNextOperationsPage() {
const nextCursor = operations.data?.nextCursor;
if (!nextCursor || !operations.data?.hasNext) return;
setOperationCursorStack((previous) => [...previous, operationCursor]);
setOperationCursor(nextCursor);
}
function handlePreviousOperationsPage() {
if (operationCursorStack.length === 0) return;
const nextStack = operationCursorStack.slice(0, -1);
const previousCursor = operationCursorStack[operationCursorStack.length - 1];
setOperationCursorStack(nextStack);
setOperationCursor(previousCursor);
}
return (
<div style={{ display: 'grid', gap: 24 }}>
<header>
<h1 style={{ fontSize: 28, lineHeight: 1.2, marginBottom: 12 }}>
{portfolio.data.account.name}
</h1>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'baseline' }}>
<strong style={{ fontSize: 24 }}>{formatMoney(portfolio.data.totals.portfolio)}</strong>
<span style={{ color: 'var(--color-text-secondary)' }}>
День: {formatMoney(portfolio.data.yields.daily)}
</span>
<span style={{ color: 'var(--color-text-secondary)' }}>
Ожидаемая: {portfolio.data.yields.expectedPercent ?? '-'}%
</span>
</div>
</header>
<section
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: 12,
}}
>
{portfolio.data.cash.map((money) => (
<div
key={money.currency}
style={{
background: 'var(--color-surface)',
border: '1px solid #e0e0e0',
borderRadius: 8,
padding: 16,
}}
>
<div style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>
{money.currency}
</div>
<strong>{formatMoney(money)}</strong>
</div>
))}
</section>
<BrokerPositionsSection accountId={accountId!} />
<BrokerOperationsTable
isLoading={operations.isLoading}
isFetching={operations.isFetching}
page={operations.data}
pageNumber={operationCursorStack.length + 1}
canGoBack={operationCursorStack.length > 0}
canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)}
onPrevious={handlePreviousOperationsPage}
onNext={handleNextOperationsPage}
/>
</div>
);
}

View File

@ -0,0 +1,49 @@
import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom';
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
export type BrokerAccountContext = {
accountId: string;
portfolio: ReturnType<typeof useBrokerPortfolio>;
};
export function useBrokerAccountContext() {
return useOutletContext<BrokerAccountContext>();
}
export function BrokerAccountLayout() {
const { accountId = '' } = useParams();
const portfolio = useBrokerPortfolio(accountId);
const basePath = `/broker/${encodeURIComponent(accountId)}`;
const context: BrokerAccountContext = { accountId, portfolio };
const linkClassName = ({ isActive }: { isActive: boolean }) =>
`broker-account__link${isActive ? ' is-active' : ''}`;
return (
<div className="broker-account">
<header className="broker-account__header">
<h1>{portfolio.data?.account.name || 'Брокерский счёт'}</h1>
</header>
<div className="broker-account__workspace">
<nav className="broker-account__navigation" aria-label="Разделы брокерского счёта">
<NavLink className={linkClassName} end to={basePath}>
Обзор
</NavLink>
<NavLink className={linkClassName} to={`${basePath}/shares`}>
Акции
</NavLink>
<NavLink className={linkClassName} to={`${basePath}/bonds`}>
Облигации
</NavLink>
<NavLink className={linkClassName} to={`${basePath}/operations`}>
Операции
</NavLink>
</nav>
<div className="broker-account__content">
<Outlet context={context} />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,178 @@
import { Link } from 'react-router-dom';
import type { BrokerMoney, BrokerPortfolio } from '../../api/responses';
import { SkeletonBlock } from '../../components/SkeletonBlock';
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
import { useBrokerAccountContext } from './BrokerAccountLayout';
import { BrokerAllocationChart } from './BrokerAllocationChart';
import { BrokerOperationsTable } from './BrokerOperationsTable';
function formatMoney(value: BrokerMoney | null | undefined) {
if (!value) return '—';
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: value.currency || 'RUB',
maximumFractionDigits: 2,
}).format(value.value);
}
function formatPercent(value: number | null) {
if (value === null) return '—';
return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`;
}
function pluralize(count: number, one: string, few: string, many: string) {
const modulo100 = Math.abs(count) % 100;
const modulo10 = modulo100 % 10;
if (modulo100 > 10 && modulo100 < 20) return many;
if (modulo10 === 1) return one;
if (modulo10 >= 2 && modulo10 <= 4) return few;
return many;
}
function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) {
return (
<section className="broker-overview__summary" aria-label="Сводка счёта">
<div className="broker-overview__card">
<span className="broker-overview__label">Стоимость портфеля</span>
<strong className="broker-overview__total">
{formatMoney(portfolio.totals.portfolio)}
</strong>
<span>За день: {formatMoney(portfolio.yields.daily)}</span>
<span>Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)}</span>
<span>Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)}</span>
</div>
<div className="broker-overview__card">
<span className="broker-overview__label">Денежный остаток</span>
{portfolio.cash.length === 0 ? (
<span>Нет денежных остатков</span>
) : (
<ul className="broker-overview__cash">
{portfolio.cash.map((money, index) => (
<li key={`${money.currency}-${index}`}>
<span>{money.currency}</span>
<strong>{formatMoney(money)}</strong>
</li>
))}
</ul>
)}
</div>
</section>
);
}
function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) {
if (!value || !total || total.value <= 0) return null;
return (value.value / total.value) * 100;
}
function formatAllocationPercent(value: number | null) {
return value === null ? '—' : `${value.toFixed(1)}%`;
}
function BrokerAssetCards({
accountId,
portfolio,
}: {
accountId: string;
portfolio: BrokerPortfolio;
}) {
const basePath = `/broker/${encodeURIComponent(accountId)}`;
const cards = [
{
label: 'Акции',
count: portfolio.positionCounts.shares,
countLabel: pluralize(portfolio.positionCounts.shares, 'позиция', 'позиции', 'позиций'),
value: portfolio.totals.shares,
path: `${basePath}/shares`,
},
{
label: 'Облигации',
count: portfolio.positionCounts.bonds,
countLabel: pluralize(portfolio.positionCounts.bonds, 'выпуск', 'выпуска', 'выпусков'),
value: portfolio.totals.bonds,
path: `${basePath}/bonds`,
},
];
return (
<section className="broker-overview__assets" aria-label="Основные классы активов">
{cards.map((card) => (
<Link
className="broker-overview__card broker-overview__asset-link"
key={card.label}
to={card.path}
>
<strong className="broker-overview__asset-title">{card.label}</strong>
<span>
{card.count} {card.countLabel}
</span>
<span>{formatMoney(card.value)}</span>
<span>
{formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))}
</span>
</Link>
))}
</section>
);
}
function BrokerOverviewSkeleton() {
return (
<div className="broker-overview" aria-label="Загрузка сводки счёта">
<div className="broker-overview__summary">
{[1, 2].map((item) => (
<div className="broker-overview__card" key={item}>
<SkeletonBlock height={16} width="45%" />
<SkeletonBlock height={28} width="70%" />
<SkeletonBlock height={16} width="55%" />
</div>
))}
</div>
<div className="broker-allocation">
<SkeletonBlock height={160} width={160} borderRadius={80} />
<SkeletonBlock height={80} width="60%" />
</div>
<div className="broker-overview__assets">
{[1, 2].map((item) => (
<div className="broker-overview__card" key={item}>
<SkeletonBlock height={20} width="35%" />
<SkeletonBlock height={16} width="55%" />
<SkeletonBlock height={16} width="70%" />
</div>
))}
</div>
</div>
);
}
export function BrokerAccountOverviewPage() {
const { accountId, portfolio } = useBrokerAccountContext();
const operations = useBrokerOperations(accountId, { limit: 5 });
if (portfolio.isLoading) return <BrokerOverviewSkeleton />;
if (portfolio.error || !portfolio.data) {
return <p role="alert">Не удалось загрузить сводку счёта</p>;
}
return (
<div className="broker-overview">
<BrokerSummary portfolio={portfolio.data} />
<BrokerAllocationChart portfolio={portfolio.data} />
<BrokerAssetCards accountId={accountId} portfolio={portfolio.data} />
{operations.error ? (
<p role="alert">Не удалось загрузить последние операции</p>
) : (
<BrokerOperationsTable
title="Последние операции"
headerAction={
<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Вся история</Link>
}
emptyMessage="Операций с начала текущего года нет"
isLoading={operations.isLoading}
isFetching={operations.isFetching}
page={operations.data}
/>
)}
</div>
);
}

View File

@ -0,0 +1,89 @@
import type { BrokerPortfolio } from '../../api/responses';
import { buildBrokerAllocation } from './brokerAllocation';
const RADIUS = 44;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
function formatMoneyValue(value: number, currency: string) {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency,
maximumFractionDigits: 2,
}).format(value);
}
function allocationCurrency(portfolio: BrokerPortfolio) {
return (
portfolio.totals.portfolio?.currency ||
Object.values(portfolio.totals).find((total) => total?.currency)?.currency ||
'RUB'
);
}
export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfolio }) {
const { sectors, negative } = buildBrokerAllocation(portfolio);
const currency = allocationCurrency(portfolio);
let remaining = CIRCUMFERENCE;
const arcs = sectors.map((sector) => {
const dashOffset = -(CIRCUMFERENCE - remaining);
const rawDashLength = (sector.percent / 100) * CIRCUMFERENCE;
const dashLength = Math.min(Math.max(rawDashLength, 0), remaining);
remaining = Math.max(0, remaining - dashLength);
return { ...sector, dashOffset, dashLength };
});
return (
<figure className="broker-allocation">
<svg role="img" aria-label="Структура брокерского портфеля" viewBox="0 0 120 120">
<title>Структура брокерского портфеля</title>
{arcs.map((sector) => (
<circle
key={sector.key}
cx="60"
cy="60"
r={RADIUS}
fill="none"
stroke={sector.color}
strokeWidth="14"
strokeDasharray={`${sector.dashLength} ${CIRCUMFERENCE - sector.dashLength}`}
strokeDashoffset={sector.dashOffset}
transform="rotate(-90 60 60)"
/>
))}
</svg>
<figcaption>
{sectors.length === 0 ? (
<p>Нет данных для распределения</p>
) : (
<ul>
{sectors.map((sector) => (
<li key={sector.key}>
<span
className="broker-allocation__swatch"
aria-hidden="true"
style={{ background: sector.color }}
/>
<span>
{sector.label}: {formatMoneyValue(sector.value, currency)} ·{' '}
{sector.percent.toFixed(1)}%
</span>
</li>
))}
</ul>
)}
{negative.length > 0 && (
<ul
className="broker-allocation__negative"
aria-label="Отрицательные значения распределения"
>
{negative.map((item) => (
<li key={item.key}>
{item.label}: отрицательное значение {formatMoneyValue(item.value, currency)}
</li>
))}
</ul>
)}
</figcaption>
</figure>
);
}

View File

@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useBrokerAccountContext } from './BrokerAccountLayout';
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
import { BrokerOperationsTable } from './BrokerOperationsTable';
import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType } from './brokerDisplay';
export function BrokerOperationsPage() {
const { accountId } = useBrokerAccountContext();
const [searchParams, setSearchParams] = useSearchParams();
const urlType = searchParams.get('type');
const selectedType = isBrokerOperationType(urlType) ? urlType : '';
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
const operations = useBrokerOperations(accountId, {
limit: 10,
cursor,
operationTypes: selectedType || undefined,
});
useEffect(() => {
setCursor(undefined);
setCursorStack([]);
}, [selectedType]);
function handleTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
const nextType = event.target.value;
setSearchParams(nextType ? { type: nextType } : {}, { replace: true });
}
function handleNext() {
const nextCursor = operations.data?.nextCursor;
if (!nextCursor || !operations.data?.hasNext) return;
setCursorStack((previous) => [...previous, cursor]);
setCursor(nextCursor);
}
function handlePrevious() {
if (cursorStack.length === 0) return;
setCursor(cursorStack[cursorStack.length - 1]);
setCursorStack((previous) => previous.slice(0, -1));
}
const history = operations.error ? (
<p role="alert">Не удалось загрузить историю операций</p>
) : (
<BrokerOperationsTable
title="История операций"
emptyMessage={
selectedType ? 'Операций выбранного типа нет' : 'Операций с начала текущего года нет'
}
isLoading={operations.isLoading}
isFetching={operations.isFetching}
page={operations.data}
pagination={{
pageNumber: cursorStack.length + 1,
canGoBack: cursorStack.length > 0,
canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor),
onPrevious: handlePrevious,
onNext: handleNext,
}}
/>
);
return (
<section aria-labelledby="broker-operations-heading">
<div className="broker-operations__toolbar">
<h2 id="broker-operations-heading" style={{ fontSize: 20, margin: 0 }}>
Операции
</h2>
<label>
<span>Тип операции</span>
<select value={selectedType} onChange={handleTypeChange}>
<option value="">Все операции</option>
{BROKER_OPERATION_TYPE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
{history}
</section>
);
}

View File

@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
import type { ReactNode } from 'react';
import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../api/responses';
import {
getBrokerInstrumentPath,
@ -94,28 +95,23 @@ const pagButtonDisabledStyle = {
} satisfies React.CSSProperties;
export function BrokerOperationsTable({
title,
headerAction,
emptyMessage,
isLoading,
isFetching,
page,
pageNumber,
canGoBack,
canGoForward,
onPrevious,
onNext,
}: {
isLoading: boolean;
isFetching: boolean;
page: BrokerOperationsPage | undefined;
pageNumber: number;
canGoBack: boolean;
canGoForward: boolean;
onPrevious: () => void;
onNext: () => void;
}) {
pagination,
}: BrokerOperationsTableProps) {
const pageNumber = pagination?.pageNumber;
const canGoBack = pagination?.canGoBack ?? false;
const canGoForward = pagination?.canGoForward ?? false;
const onPrevious = pagination?.onPrevious;
const onNext = pagination?.onNext;
const operations = page?.items ?? [];
return (
<section>
<section aria-busy={isFetching}>
<div
style={{
display: 'flex',
@ -125,10 +121,13 @@ export function BrokerOperationsTable({
marginBottom: 12,
}}
>
<h2 style={{ fontSize: 20, margin: 0 }}>Операции</h2>
<h2 style={{ fontSize: 20, margin: 0 }}>{title}</h2>
{headerAction}
{pagination && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
aria-label="Предыдущая страница"
onClick={onPrevious}
disabled={!canGoBack || isFetching}
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
@ -155,6 +154,7 @@ export function BrokerOperationsTable({
</span>
<button
type="button"
aria-label="Следующая страница"
onClick={onNext}
disabled={!canGoForward || isFetching}
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
@ -169,6 +169,7 @@ export function BrokerOperationsTable({
)}
</button>
</div>
)}
</div>
{isLoading ? (
@ -197,7 +198,7 @@ export function BrokerOperationsTable({
</table>
</div>
) : operations.length === 0 && !isFetching ? (
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
) : (
<div className="table-container">
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
@ -243,10 +244,10 @@ export function BrokerOperationsTable({
</table>
</div>
{isFetching && (
<div className="table-loading-overlay">
<div className="loading-spinner" />
<div className="table-loading-overlay" role="status">
<div className="loading-spinner" aria-hidden="true" />
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
Загрузка страницы {pageNumber}
{pagination ? `Загрузка страницы ${pageNumber}` : 'Обновление операций…'}
</span>
</div>
)}
@ -255,3 +256,19 @@ export function BrokerOperationsTable({
</section>
);
}
type BrokerOperationsTableProps = {
title: string;
headerAction?: ReactNode;
emptyMessage: string;
isLoading: boolean;
isFetching: boolean;
page: BrokerOperationsPage | undefined;
pagination?: {
pageNumber: number;
canGoBack: boolean;
canGoForward: boolean;
onPrevious: () => void;
onNext: () => void;
};
};

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,10 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import type { BrokerMoney, BrokerPosition } from '../../api/responses';
import type { BrokerMoney, BrokerPosition, BrokerPositionsPage } from '../../api/responses';
import { getBrokerInstrumentPath } from './brokerDisplay';
import { TableSkeleton } from '../../components/TableSkeleton';
import { useBrokerPositions } from '../../hooks/useBrokerPositions';
type BrokerPositionGroupConfig = {
key: string;
type?: string;
title: string;
};
const GROUPS: BrokerPositionGroupConfig[] = [
{ key: 'shares', type: 'share', title: 'Акции' },
{ key: 'bonds', type: 'bond', title: 'Облигации' },
{ key: 'etf', type: 'etf', title: 'ETF' },
{ key: 'fund', type: 'fund', title: 'Фонды' },
];
const KNOWN_TYPES = new Set(GROUPS.map((g) => g.type).filter(Boolean));
import { useBrokerAccountContext } from './BrokerAccountLayout';
const tableStyle = {
width: '100%',
@ -89,50 +75,31 @@ function PositionTicker({ position }: { position: BrokerPosition }) {
);
}
function PositionGroupTable({
accountId,
group,
function BrokerPositionTable({
title,
page,
isLoading,
isFetching,
emptyMessage,
pageNumber,
onNext,
onPrevious,
}: {
accountId: string;
group: BrokerPositionGroupConfig;
title: string;
page: BrokerPositionsPage | undefined;
isLoading: boolean;
isFetching: boolean;
emptyMessage: string;
pageNumber: number;
onNext: () => void;
onPrevious: () => void;
}) {
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const query = group.type ? { type: group.type, limit: 10, cursor } : { limit: 100, cursor };
const { data: page, isLoading, isFetching } = useBrokerPositions(accountId, query);
const rawPositions = page?.items ?? [];
const positions = group.type
? rawPositions
: rawPositions.filter(
(p) => p.instrumentType && !KNOWN_TYPES.has(p.instrumentType.toLowerCase()),
);
const pageNumber = cursorStack.length + 1;
const canGoBack = cursorStack.length > 0;
const canGoForward = Boolean(page?.hasNext && page.nextCursor && !!group.type);
function handleNext() {
const nextCursor = page?.nextCursor;
if (!nextCursor || !page?.hasNext || !group.type) return;
setCursorStack((prev) => [...prev, cursor]);
setCursor(nextCursor);
}
function handlePrevious() {
if (cursorStack.length === 0) return;
const prev = cursorStack[cursorStack.length - 1];
setCursorStack((prevStack) => prevStack.slice(0, -1));
setCursor(prev);
}
if (!isLoading && positions.length === 0) {
return null;
}
const positions = page?.items ?? [];
const canGoBack = pageNumber > 1;
const canGoForward = Boolean(page?.hasNext && page.nextCursor);
return (
<section>
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
<div
style={{
display: 'flex',
@ -142,12 +109,14 @@ function PositionGroupTable({
marginBottom: 10,
}}
>
<h3 style={{ fontSize: 18, margin: 0 }}>{group.title}</h3>
{group.type && (
<h2 id={`broker-${title.toLowerCase()}-heading`} style={{ fontSize: 20, margin: 0 }}>
{title}
</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
onClick={handlePrevious}
aria-label="Предыдущая страница"
onClick={onPrevious}
disabled={!canGoBack || isFetching}
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
@ -173,7 +142,8 @@ function PositionGroupTable({
</span>
<button
type="button"
onClick={handleNext}
aria-label="Следующая страница"
onClick={onNext}
disabled={!canGoForward || isFetching}
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
>
@ -187,10 +157,9 @@ function PositionGroupTable({
)}
</button>
</div>
)}
</div>
{isLoading && (
{isLoading ? (
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table style={tableStyle}>
<thead>
@ -224,12 +193,12 @@ function PositionGroupTable({
/>
</table>
</div>
)}
{!isLoading && positions.length > 0 && (
) : positions.length === 0 && !isFetching ? (
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
) : (
<div className="table-container">
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
<thead>
<tr>
<th align="left" style={thStyle}>
@ -295,17 +264,53 @@ function PositionGroupTable({
);
}
type BrokerPositionsSectionProps = {
accountId: string;
type BrokerPositionsPageProps = {
type: 'share' | 'bond';
title: 'Акции' | 'Облигации';
};
export function BrokerPositionsSection({ accountId }: BrokerPositionsSectionProps) {
export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
const { accountId } = useBrokerAccountContext();
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
const positions = useBrokerPositions(accountId, { type, limit: 10, cursor });
function handleNext() {
const nextCursor = positions.data?.nextCursor;
if (!nextCursor || !positions.data?.hasNext) return;
setCursorStack((previous) => [...previous, cursor]);
setCursor(nextCursor);
}
function handlePrevious() {
if (cursorStack.length === 0) return;
setCursor(cursorStack[cursorStack.length - 1]);
setCursorStack((previous) => previous.slice(0, -1));
}
if (positions.error) {
return (
<div style={{ display: 'grid', gap: 20 }}>
<h2 style={{ fontSize: 20, margin: 0 }}>Позиции</h2>
{GROUPS.map((group) => (
<PositionGroupTable key={group.key} accountId={accountId} group={group} />
))}
</div>
<section aria-labelledby={`broker-${type}-heading`}>
<h2 id={`broker-${type}-heading`} style={{ fontSize: 20, margin: 0 }}>
{title}
</h2>
<p role="alert">
{type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'}
</p>
</section>
);
}
return (
<BrokerPositionTable
title={title}
page={positions.data}
isLoading={positions.isLoading}
isFetching={positions.isFetching}
emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'}
pageNumber={cursorStack.length + 1}
onNext={handleNext}
onPrevious={handlePrevious}
/>
);
}

View File

@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import type { BrokerMoney, BrokerPortfolio } from '../../api/responses';
import { buildBrokerAllocation } from './brokerAllocation';
function money(value: number): BrokerMoney {
return {
currency: 'RUB',
units: String(Math.trunc(value)),
nano: 0,
value,
};
}
function portfolio(
values: Partial<Record<'shares' | 'bonds' | 'etf' | 'currencies' | 'portfolio', number | null>>,
): BrokerPortfolio {
const total = (key: keyof typeof values): BrokerMoney | null => {
const value = values[key];
return value == null ? null : money(value);
};
return {
account: {
id: 'acc-1',
type: 'brokerage',
name: 'Основной',
status: 'open',
openedAt: '2024-01-01T00:00:00.000Z',
accessLevel: 'full_access',
},
positionCounts: {
shares: 1,
bonds: 1,
etf: 1,
other: 0,
},
totals: {
shares: total('shares'),
bonds: total('bonds'),
etf: total('etf'),
currencies: total('currencies'),
futures: null,
options: null,
structuredProducts: null,
dfa: null,
portfolio: total('portfolio'),
},
yields: {
expectedPercent: null,
daily: null,
dailyPercent: null,
},
cash: [],
blockedCash: [],
asOf: '2025-01-01T00:00:00.000Z',
};
}
describe('buildBrokerAllocation', () => {
it('builds allocation sectors in display order', () => {
expect(
buildBrokerAllocation(
portfolio({ shares: 400, bonds: 300, etf: 100, currencies: 150, portfolio: 1000 }),
),
).toEqual({
total: 1000,
sectors: [
{ key: 'shares', label: 'Акции', value: 400, percent: 40, color: '#4969f5' },
{ key: 'bonds', label: 'Облигации', value: 300, percent: 30, color: '#e5a33c' },
{ key: 'etf', label: 'ETF/фонды', value: 100, percent: 10, color: '#62b889' },
{ key: 'cash', label: 'Деньги', value: 150, percent: 15, color: '#7b63cf' },
{ key: 'other', label: 'Прочие', value: 50, percent: 5, color: '#aeb6c5' },
],
negative: [],
});
});
it('omits zero-value sectors', () => {
const result = buildBrokerAllocation(
portfolio({ shares: 600, bonds: 0, etf: null, currencies: 400, portfolio: 1000 }),
);
expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'cash']);
expect(result.negative).toEqual([]);
});
it('reports a negative residual outside the sectors', () => {
const result = buildBrokerAllocation(
portfolio({ shares: 700, bonds: 300, etf: 100, currencies: 50, portfolio: 1000 }),
);
expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds', 'etf', 'cash']);
expect(result.negative).toEqual([
{ key: 'other', label: 'Прочие', value: -150, color: '#aeb6c5' },
]);
});
it('ignores a tiny negative residual caused by decimal arithmetic', () => {
const result = buildBrokerAllocation(portfolio({ shares: 0.1, bonds: 0.2, portfolio: 0.3 }));
expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds']);
expect(result.negative).toEqual([]);
});
it('ignores a tiny positive residual caused by decimal arithmetic', () => {
const result = buildBrokerAllocation(
portfolio({ shares: 0.3, portfolio: 0.30000000000000004 }),
);
expect(result.sectors.map(({ key }) => key)).toEqual(['shares']);
expect(result.negative).toEqual([]);
});
it('returns no allocation for missing or nonpositive portfolio totals', () => {
expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: null }))).toEqual({
total: 0,
sectors: [],
negative: [],
});
expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: 0 }))).toEqual({
total: 0,
sectors: [],
negative: [],
});
expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: -10 }))).toEqual({
total: -10,
sectors: [],
negative: [],
});
});
it('preserves named negative components when the portfolio total is nonpositive', () => {
expect(buildBrokerAllocation(portfolio({ shares: 100, bonds: -20, portfolio: 0 }))).toEqual({
total: 0,
sectors: [],
negative: [{ key: 'bonds', label: 'Облигации', value: -20, color: '#e5a33c' }],
});
expect(buildBrokerAllocation(portfolio({ currencies: -30, etf: 5, portfolio: -10 }))).toEqual({
total: -10,
sectors: [],
negative: [{ key: 'cash', label: 'Деньги', value: -30, color: '#7b63cf' }],
});
});
});

View File

@ -0,0 +1,85 @@
import type { BrokerPortfolio } from '../../api/responses';
export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other';
export interface BrokerAllocationItem {
key: BrokerAllocationKey;
label: string;
value: number;
percent: number;
color: string;
}
type BrokerNegativeAllocationItem = Omit<BrokerAllocationItem, 'percent'>;
const ALLOCATION_CONFIG: Array<Pick<BrokerAllocationItem, 'key' | 'label' | 'color'>> = [
{ key: 'shares', label: 'Акции', color: '#4969f5' },
{ key: 'bonds', label: 'Облигации', color: '#e5a33c' },
{ key: 'etf', label: 'ETF/фонды', color: '#62b889' },
{ key: 'cash', label: 'Деньги', color: '#7b63cf' },
{ key: 'other', label: 'Прочие', color: '#aeb6c5' },
];
export function buildBrokerAllocation(portfolio: BrokerPortfolio): {
total: number;
sectors: BrokerAllocationItem[];
negative: BrokerNegativeAllocationItem[];
} {
const total = portfolio.totals.portfolio?.value ?? 0;
const shares = portfolio.totals.shares?.value ?? 0;
const bonds = portfolio.totals.bonds?.value ?? 0;
const etf = portfolio.totals.etf?.value ?? 0;
const cash = portfolio.totals.currencies?.value ?? 0;
const namedValues: Record<Exclude<BrokerAllocationKey, 'other'>, number> = {
shares,
bonds,
etf,
cash,
};
if (total <= 0) {
const negative = ALLOCATION_CONFIG.filter(
(
item,
): item is (typeof ALLOCATION_CONFIG)[number] & {
key: Exclude<BrokerAllocationKey, 'other'>;
} => item.key !== 'other',
)
.filter((item) => namedValues[item.key] < 0)
.map((item) => ({ ...item, value: namedValues[item.key] }));
return { total, sectors: [], negative };
}
const mappedTotal = shares + bonds + etf + cash;
const residual = total - mappedTotal;
const residualTolerance =
Number.EPSILON *
Math.max(
1,
Math.abs(total),
Math.abs(shares) + Math.abs(bonds) + Math.abs(etf) + Math.abs(cash),
) *
8;
const values: Record<BrokerAllocationKey, number> = {
shares,
bonds,
etf,
cash,
other: Math.abs(residual) <= residualTolerance ? 0 : residual,
};
const sectors: BrokerAllocationItem[] = [];
const negative: BrokerNegativeAllocationItem[] = [];
for (const item of ALLOCATION_CONFIG) {
const value = values[item.key];
if (value > 0) {
sectors.push({ ...item, value, percent: (value / total) * 100 });
} else if (value < 0) {
negative.push({ ...item, value });
}
}
return { total, sectors, negative };
}

View File

@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest';
import type { BrokerOperation, BrokerPosition } from '../../api/responses';
import {
BROKER_OPERATION_TYPE_OPTIONS,
getBrokerInstrumentPath,
getBrokerOperationImpact,
getBrokerOperationTypeLabel,
getBrokerPositionGroup,
isBrokerOperationType,
} from './brokerDisplay';
function position(input: Partial<BrokerPosition>): BrokerPosition {
@ -37,6 +39,7 @@ function operation(input: Partial<BrokerOperation>): BrokerOperation {
type: 'OPERATION_TYPE_UNSPECIFIED',
category: 'other',
description: null,
name: null,
state: null,
instrumentUid: null,
figi: null,
@ -116,6 +119,37 @@ describe('broker display helpers', () => {
).toBe('Custom');
});
it('exposes independently selectable known operation types', () => {
expect(BROKER_OPERATION_TYPE_OPTIONS).toEqual(
expect.arrayContaining([
{ value: 'OPERATION_TYPE_COUPON', label: 'Выплата купона' },
{ value: 'OPERATION_TYPE_TAX', label: 'Налог' },
{ value: 'OPERATION_TYPE_BOND_TAX', label: 'Налог по облигациям' },
{ value: 'OPERATION_TYPE_DIVIDEND_TAX', label: 'Налог на дивиденды' },
]),
);
});
it('keeps operation type option values unique and labels in Russian order', () => {
const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value);
const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label);
expect(new Set(values).size).toBe(values.length);
expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru')));
});
it('keeps operation type options immutable at runtime', () => {
expect(Object.isFrozen(BROKER_OPERATION_TYPE_OPTIONS)).toBe(true);
expect(BROKER_OPERATION_TYPE_OPTIONS.every((option) => Object.isFrozen(option))).toBe(true);
});
it('validates only exact known operation type values', () => {
expect(isBrokerOperationType('OPERATION_TYPE_COUPON')).toBe(true);
expect(isBrokerOperationType('operation_type_coupon')).toBe(false);
expect(isBrokerOperationType('OPERATION_TYPE_UNKNOWN')).toBe(false);
expect(isBrokerOperationType(null)).toBe(false);
});
it('classifies operations by portfolio impact', () => {
expect(
getBrokerOperationImpact(

View File

@ -94,6 +94,20 @@ const OPERATION_TYPE_LABELS: Record<string, string> = {
OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг',
};
export const BROKER_OPERATION_TYPE_OPTIONS: ReadonlyArray<
Readonly<{ value: string; label: string }>
> = Object.freeze(
Object.entries(OPERATION_TYPE_LABELS)
.map(([value, label]) => Object.freeze({ value, label }))
.sort((left, right) => left.label.localeCompare(right.label, 'ru')),
);
const BROKER_OPERATION_TYPES = new Set(BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value));
export function isBrokerOperationType(value: string | null): value is string {
return value !== null && BROKER_OPERATION_TYPES.has(value);
}
export function getBrokerPositionGroup(
position: Pick<BrokerPosition, 'instrumentType'>,
): BrokerPositionGroup {

View File

@ -11,7 +11,10 @@ import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
import { ScreenerPage } from './pages/screener/ScreenerPage';
import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage';
import { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage';
import { BrokerAccountLayout } from './pages/broker/BrokerAccountLayout';
import { BrokerAccountOverviewPage } from './pages/broker/BrokerAccountOverviewPage';
import { BrokerPositionsPage } from './pages/broker/BrokerPositionsPage';
import { BrokerOperationsPage } from './pages/broker/BrokerOperationsPage';
export function AppRoutes() {
return (
@ -59,10 +62,15 @@ export function AppRoutes() {
path="/broker/:accountId"
element={
<ProtectedRoute>
<BrokerAccountDetailPage />
<BrokerAccountLayout />
</ProtectedRoute>
}
/>
>
<Route index element={<BrokerAccountOverviewPage />} />
<Route path="shares" element={<BrokerPositionsPage type="share" title="Акции" />} />
<Route path="bonds" element={<BrokerPositionsPage type="bond" title="Облигации" />} />
<Route path="operations" element={<BrokerOperationsPage />} />
</Route>
</Route>
</Routes>
);

View File

@ -18,9 +18,15 @@
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
.pnl-cell { text-align: right; }
.positive { color: var(--color-positive); }
.negative { color: var(--color-negative); }
.pnl-cell {
text-align: right;
}
.positive {
color: var(--color-positive);
}
.negative {
color: var(--color-negative);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@ -35,24 +41,25 @@ a {
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skeleton {
background: linear-gradient(
90deg,
var(--color-bg) 25%,
#f0f0f0 50%,
var(--color-bg) 75%
);
background: linear-gradient(90deg, var(--color-bg) 25%, #f0f0f0 50%, var(--color-bg) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: 4px;
}
@keyframes loading-spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
.loading-spinner {
@ -80,3 +87,212 @@ a {
transition: opacity 0.2s ease;
z-index: 1;
}
.broker-account__workspace {
display: grid;
grid-template-columns: minmax(150px, 190px) minmax(0, 1fr);
gap: 24px;
}
.broker-account__navigation {
display: flex;
flex-direction: column;
gap: 4px;
}
.broker-account__link {
padding: 10px 12px;
border-radius: var(--border-radius);
color: var(--color-text-secondary);
}
.broker-account__link.is-active,
.broker-account__link[aria-current='page'] {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
color: var(--color-primary);
font-weight: 700;
}
.broker-account__link:focus-visible {
outline: 3px solid var(--color-primary);
outline-offset: 2px;
}
.broker-account__content {
min-width: 0;
}
.broker-overview {
display: grid;
gap: 24px;
}
.broker-overview__summary,
.broker-overview__assets {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.broker-overview__card,
.broker-allocation {
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: var(--border-radius);
background: var(--color-surface);
}
.broker-overview__card {
display: grid;
align-content: start;
gap: 8px;
}
.broker-overview__label,
.broker-overview__card > span {
color: var(--color-text-secondary);
}
.broker-overview__total {
font-size: 24px;
}
.broker-overview__cash,
.broker-allocation ul {
list-style: none;
display: grid;
gap: 8px;
}
.broker-overview__cash li {
display: flex;
justify-content: space-between;
gap: 12px;
}
.broker-overview__asset-link {
color: var(--color-text);
}
.broker-overview__asset-title {
color: var(--color-primary);
font-size: 18px;
}
.broker-overview__asset-link:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-primary) 35%, transparent);
outline-offset: 2px;
}
.broker-allocation {
display: flex;
align-items: center;
gap: 20px;
}
.broker-allocation svg {
width: 160px;
max-width: 40%;
flex: 0 0 auto;
}
.broker-allocation figcaption {
display: grid;
gap: 12px;
}
.broker-allocation li {
display: flex;
align-items: center;
gap: 8px;
}
.broker-allocation__swatch {
width: 12px;
height: 12px;
border: 1px solid color-mix(in srgb, var(--color-text) 20%, transparent);
border-radius: 2px;
flex: 0 0 auto;
}
.broker-allocation__negative {
color: var(--color-negative);
}
@media (max-width: 720px) {
.broker-account__header {
padding: 0 0 8px;
}
.broker-account__workspace {
gap: 16px;
grid-template-columns: minmax(0, 1fr);
}
.broker-account__navigation {
flex-direction: row;
overflow-x: auto;
scrollbar-width: thin;
}
.broker-account__link {
flex: none;
white-space: nowrap;
}
.broker-overview__summary,
.broker-overview__assets {
grid-template-columns: 1fr;
}
.broker-allocation {
align-items: stretch;
flex-direction: column;
}
.broker-allocation svg {
max-width: 180px;
width: 100%;
align-self: center;
}
}
.broker-operations__toolbar {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.broker-operations__toolbar label {
display: grid;
gap: 6px;
color: var(--color-text-secondary);
font-size: 13px;
}
.broker-operations__toolbar select {
min-width: 240px;
padding: 8px 10px;
border: 1px solid #d8d8d8;
border-radius: var(--border-radius);
background: var(--color-surface);
color: var(--color-text);
}
.broker-operations__toolbar select:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-primary) 35%, transparent);
outline-offset: 2px;
}
@media (max-width: 720px) {
.broker-operations__toolbar {
align-items: stretch;
flex-direction: column;
}
.broker-operations__toolbar select {
width: 100%;
min-width: 0;
}
}

View File

@ -1,8 +1,38 @@
Портфель брокера
# Портфель брокера
Features:
- broker-operations-ui-improvements
- broker-portfolio-display
- broker-positions-pagination-and-loading
- tbank-broker-portfolios
- tbank-deadline-queue-fix
Статус: активный
## Цель
Дать пользователю целостный доступ к реальным брокерским счетам T-Bank: от подключения и получения
данных до понятного обзора состава счёта, позиций и истории операций.
## Пользовательская возможность
В рамках эпика пользователь может:
- открыть список доступных брокерских счетов и ИИС;
- просмотреть состояние конкретного счёта;
- изучить позиции по классам инструментов;
- открыть историю операций и понять финансовый смысл каждой записи;
- перейти от брокерской позиции к странице поддерживаемого биржевого инструмента.
## Границы эпика
Эпик охватывает отображение и чтение данных брокерского счёта. Торговые поручения, изменение счёта,
автоматические стратегии, налоговая отчётность и экспорт документов не входят в его текущую область.
## Features
- [T-Bank broker portfolios](../features/tbank-broker-portfolios/spec.md)
- [Отображение брокерского портфеля](../features/broker-portfolio-display/spec.md)
- [x] [Разделы брокерского счёта](../features/broker-account-sections/spec.md) — реализовано
- [Улучшение UI операций](../features/broker-operations-ui-improvements/spec.md)
- [Пагинация и загрузка позиций](../features/broker-positions-pagination-and-loading/spec.md)
- [Исправление deadline и очереди T-Bank](../features/tbank-deadline-queue-fix/spec.md)
## Критерий завершения эпика
Пользователь может последовательно пройти от списка счетов к обзору выбранного счёта, отдельным
классам позиций и доступной истории операций; интерфейс корректно обрабатывает загрузку, пустые
данные и ошибки внешней интеграции.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,166 @@
# Разделы брокерского счёта
Дата: 2026-06-18
Статус: реализовано
Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md)
## Цель
Сделать страницу конкретного брокерского счёта кратким и понятным обзором, а подробные позиции и
операции разнести по самостоятельным разделам, не теряя контекст выбранного счёта.
## Пользовательский результат
Пользователь может:
- быстро увидеть полную стоимость счёта, денежный остаток и текущую структуру активов;
- понять, сколько разных акций и выпусков облигаций находится на счёте;
- перейти к отдельной таблице акций или облигаций;
- увидеть последние операции на overview и открыть всю доступную историю;
- отфильтровать историю по одному точному типу операции, например только по выплатам купонов или
только по обычному налогу.
## Область изменений
Фича изменяет опыт работы с конкретным брокерским счётом и включает:
- обзор счёта;
- общую навигацию разделов счёта;
- отдельный раздел акций;
- отдельный раздел облигаций;
- отдельный раздел операций;
- данные сводки, необходимые для точных счётчиков позиций.
## Требования
### 1. Общая навигация
- Разделы `Обзор`, `Акции`, `Облигации` и `Операции` принадлежат одному выбранному брокерскому счёту.
- На широком экране разделы доступны через постоянное боковое меню.
- На узком экране боковое меню заменяется горизонтальными прокручиваемыми вкладками.
- Активный раздел визуально и семантически обозначен.
- Переход между разделами не меняет выбранный брокерский счёт.
### 2. Обзор счёта
Overview показывает:
- название счёта;
- полную стоимость портфеля;
- денежный остаток;
- существующие показатели дневной и ожидаемой доходности;
- круговую диаграмму структуры портфеля;
- карточку акций с количеством разных позиций, стоимостью и долей;
- карточку облигаций с количеством разных выпусков, стоимостью и долей;
- пять последних доступных операций;
- переход ко всей доступной истории операций.
Карточки акций и облигаций ведут в соответствующие разделы счёта.
### 3. Распределение портфеля
- Проценты рассчитываются от полной стоимости счёта, включая денежный остаток.
- Диаграмма поддерживает срезы `Акции`, `Облигации`, `ETF/фонды`, `Деньги` и `Прочие`.
- `Прочие` объединяет остальные и неизвестные классы инструментов.
- Срез с нулевым значением не отображается.
- Отрицательное значение не отображается как сектор диаграммы и показывается текстом рядом со
сводкой.
- Легенда показывает название среза, денежную стоимость и процент.
- Информация остаётся понятной без различения цветов.
### 4. Счётчики позиций
- Количество акций означает число разных позиций акций, а не сумму штук всех акций.
- Количество облигаций означает число разных выпусков облигаций, а не сумму штук всех облигаций.
- Счётчики отражают полный состав счёта и не зависят от текущей страницы таблицы.
### 5. Раздел акций
- Раздел показывает только позиции типа `share`.
- Первая версия содержит таблицу с существующими колонками: тикер, название, количество, текущая
цена и текущая стоимость.
- Тикер ведёт на страницу акции, когда маршрут инструмента может быть определён.
- Таблица сохраняет cursor-пагинацию по 10 позиций.
- При отсутствии акций показывается отдельное пустое состояние.
### 6. Раздел облигаций
- Раздел показывает только позиции типа `bond`.
- Первая версия содержит таблицу с существующими колонками: тикер, название, количество, текущая
цена и текущая стоимость.
- Тикер ведёт на страницу облигации, когда маршрут инструмента может быть определён.
- Таблица сохраняет cursor-пагинацию по 10 позиций.
- При отсутствии облигаций показывается отдельное пустое состояние.
### 7. Последние операции на overview
- Overview показывает не более пяти последних операций доступного по умолчанию периода.
- Отображение операции сохраняет дату, русское название типа, инструмент и сумму.
- Блок содержит переход к полному разделу операций.
- Если операций нет, блок показывает спокойное пустое состояние и сохраняет переход к полной
истории.
### 8. Раздел операций
- Раздел показывает cursor-пагинированную историю по 10 операций.
- Без отдельного фильтра дат раздел использует существующий период по умолчанию: с начала текущего
календарного года до текущего момента.
- Под всей доступной историей в рамках первой версии понимаются все cursor-страницы этого периода,
а не операции за всё время существования счёта.
- Пользователь может выбрать ровно один точный тип операции либо значение `Все операции`.
- Типы с разным финансовым смыслом не объединяются. В частности, `Налог`, `Налог по облигациям` и
`Налог на дивиденды` являются отдельными значениями.
- В интерфейсе показываются русские названия известных типов, а не технические enum-значения.
- Выбор нового типа начинает просмотр результатов с первой cursor-страницы.
- Выбранный фильтр сохраняется в адресе страницы и восстанавливается при открытии ссылки.
- Значение `Все операции` удаляет фильтр типа.
- Фильтр по датам, инструменту и выбор нескольких типов не входят в первую версию.
### 9. Загрузка, ошибки и пустые состояния
- Первичная загрузка сводки и таблиц показывает skeleton соответствующей формы.
- При переходе между cursor-страницами текущие строки не исчезают; поверх таблицы показывается
состояние обновления.
- Ошибка одного раздела не скрывает общую навигацию счёта.
- Для пустых акций, пустых облигаций и отсутствия операций выбранного типа используются отдельные
понятные сообщения.
- Недоступные отдельные значения отображаются как `—` и не подменяются нулём.
## Ограничения
- Backend остаётся единственным клиентом T-Bank.
- Денежные значения форматируются в валюте, указанной в данных.
- Точная структура портфеля и счётчики не должны требовать загрузки всех cursor-страниц на frontend.
- Существующая cursor-пагинация позиций и операций сохраняется.
- Фича не добавляет отдельные страницы для ETF, фондов и прочих инструментов.
- Фича не добавляет новую аналитику доходности, риска или прогнозов.
## Acceptance Criteria
- На широком экране у счёта есть боковая навигация `Обзор`, `Акции`, `Облигации`, `Операции`.
- На узком экране те же разделы доступны через горизонтальные прокручиваемые вкладки.
- Overview не содержит полных таблиц позиций и полной истории операций.
- Overview показывает полную стоимость, деньги, существующую доходность, диаграмму, карточки акций
и облигаций и не более пяти последних операций.
- Диаграмма рассчитывает доли от полной стоимости счёта вместе с деньгами.
- Диаграмма имеет текстовую легенду со стоимостью и процентом каждого ненулевого среза.
- Карточки акций и облигаций показывают количество разных позиций, а не сумму штук.
- Количество позиций корректно для портфеля, содержащего больше одной cursor-страницы.
- Раздел акций содержит только акции и сохраняет пагинацию по 10 строк.
- Раздел облигаций содержит только облигации и сохраняет пагинацию по 10 строк.
- Раздел операций содержит пагинацию по 10 строк и фильтр по одному точному типу.
- Без параметров дат раздел операций показывает доступный период с начала текущего календарного года.
- Фильтры `Выплата купона`, `Налог`, `Налог по облигациям` и `Налог на дивиденды` дают независимые
результаты.
- После смены типа операций открывается первая страница отфильтрованной истории.
- Выбранный тип восстанавливается из URL после перезагрузки.
- Загрузка, обновление, ошибка и пустое состояние каждого раздела отображаются согласно требованиям.
## Вне области фичи
- страницы ETF, фондов, валют, деривативов и прочих инструментов;
- multi-select типов операций;
- продуктовые группы операций;
- UI-фильтры операций по датам и инструментам;
- изменение правил расчёта доходности;
- экспорт позиций или операций.

View File

@ -0,0 +1,51 @@
# Разделы брокерского счёта — задачи
Дата: 2026-06-18
Статус: реализовано
Связанные документы:
- [Epic](../../epics/BrokerPortfolio.md)
- [Spec](spec.md)
- [Plan](plan.md)
- [Research](../../research/2026-06-18-broker-account-sections.md)
## Backend и API
- [x] Получить отдельное утверждение `plan.md` и `tasks.md` и обновить SDD-статусы до начала кода.
- [x] Добавить в broker portfolio contract счётчики `shares`, `bonds`, `etf`, `other`.
- [x] Покрыть классификацию позиций mapper- и service-тестами.
- [x] Обновить Swagger DTO и сгенерировать актуальные frontend OpenAPI types.
- [x] Синхронизировать handwritten `BrokerPortfolio` response type.
## Чистые frontend-модели
- [x] Добавить и протестировать расчёт секторов полного брокерского портфеля.
- [x] Обработать нулевые, отрицательные и отсутствующие значения диаграммы.
- [x] Экспортировать и протестировать список точных типов операций для select.
## Навигация и overview
- [x] Создать nested-route shell брокерского счёта.
- [x] Добавить desktop sidebar и mobile horizontal tabs с активным состоянием.
- [x] Создать overview со стоимостью, деньгами и существующей доходностью.
- [x] Добавить доступную диаграмму, легенду и предупреждения об отрицательных значениях.
- [x] Добавить кликабельные карточки акций и облигаций с точными счётчиками.
- [x] Показывать на overview последние пять операций и ссылку на историю.
## Отдельные страницы
- [x] Создать страницу акций с cursor-пагинацией по 10 строк.
- [x] Создать страницу облигаций с cursor-пагинацией по 10 строк.
- [x] Создать страницу операций с cursor-пагинацией по 10 строк.
- [x] Добавить single-select точного operation type, синхронизацию URL и сброс cursor.
- [x] Удалить старую общую detail page и grouped positions section после переноса поведения.
## Состояния и качество
- [x] Покрыть shell, overview, asset pages и operations page компонентными тестами.
- [x] Проверить skeleton, loading overlay, локальные ошибки и отдельные empty states.
- [x] Выполнить backend/frontend tests, builds и lint.
- [ ] Проверить четыре маршрута в локальном браузере на desktop и mobile ширине.
- [x] Провести code review и устранить блокирующие замечания.
- [x] Обновить статусы spec, epic, roadmap и этого списка только после полной проверки.

View File

@ -0,0 +1,122 @@
# Исследование структуры страницы брокерского счёта
Дата: 2026-06-18
## Цель исследования
Определить, как упростить страницу конкретного брокерского счёта, разделить обзор и подробные
данные по классам инструментов, а также добавить понятную фильтрацию истории операций.
## Текущее состояние
### Frontend
- Маршрут `/broker/:accountId` открывает `BrokerAccountDetailPage`.
- На одной странице находятся сводка, отдельные секции позиций и полная таблица операций.
- `BrokerPositionsSection` загружает акции, облигации, ETF и фонды отдельными запросами и показывает
cursor-пагинацию.
- `BrokerOperationsTable` показывает 10 операций, русские названия известных типов и
cursor-пагинацию.
- Для известных типов инструментов уже существуют переходы на `/stocks/:ticker` и
`/bonds/:ticker`.
- В `brokerDisplay.ts` уже есть отображаемые названия известных T-Bank operation types и правила
визуальной классификации операций.
### Backend и контракты
- `GET /api/v1/broker/accounts/:accountId/portfolio` возвращает итоговую стоимость, суммы по классам
активов, доходность, деньги и заблокированные деньги.
- Ответ portfolio не содержит точного количества разных позиций по классам.
- `GET /api/v1/broker/accounts/:accountId/positions` поддерживает `type`, `cursor` и `limit`, но не
возвращает общее количество элементов.
- `GET /api/v1/broker/accounts/:accountId/operations` поддерживает `operationTypes`, `cursor`,
`limit`, `from`, `to`, `instrumentId` и `state`.
- `operationTypes` уже преобразуется backend в список точных T-Bank enum-значений.
- Если период операций не указан, backend использует период от начала текущего календарного года
до текущего момента.
## Выявленное ограничение
Точный счётчик разных акций и выпусков облигаций нельзя эффективно получить из текущего frontend-
контракта: endpoint позиций пагинирован и не возвращает total. Загрузка всех cursor-страниц ради
двух счётчиков увеличит число запросов и свяжет overview с размером портфеля.
Рекомендуемое техническое направление для последующего plan.md — дополнить существующий portfolio
summary счётчиками разных позиций по классам. Новый summary endpoint не нужен, потому что он будет
дублировать назначение существующего portfolio endpoint.
## Рассмотренные продуктовые варианты
### Размещение операций
1. Полная история на overview.
2. Только отдельная страница операций.
3. Гибрид: последние операции на overview и вся доступная история отдельно.
Выбран вариант 3. Он сохраняет полезный контекст на overview и не превращает сводку в длинную
рабочую таблицу.
### Область круговой диаграммы
1. Только инструменты.
2. Весь портфель вместе с денежным остатком.
Выбран вариант 2. Он показывает реальную долю свободных денег и не создаёт впечатление, что весь
счёт инвестирован.
### Семантика количества
1. Количество разных позиций.
2. Сумма штук всех бумаг класса.
Выбран вариант 1. Складывать штуки разных акций или облигаций малоинформативно. Рядом со счётчиком
должны оставаться стоимость класса и его доля в портфеле.
### Фильтр операций
1. Один точный тип операции.
2. Несколько точных типов одновременно.
3. Семантические группы, например «Все налоги».
Выбран вариант 1. Значения «Налог», «Налог по облигациям» и «Налог на дивиденды» остаются разными
фильтрами. Multi-select и группировка категорий не входят в первую версию.
### Навигация
Рассматривались верхние вкладки, боковое меню и карточки-переходы на overview. Выбрано постоянное
боковое меню на широком экране. На узком экране оно заменяется горизонтальными прокручиваемыми
вкладками.
## Согласованная информационная архитектура
- Overview счёта: баланс, денежный остаток, существующие показатели доходности, распределение всего
портфеля, карточки акций и облигаций, последние пять операций.
- Страница акций: таблица только акций.
- Страница облигаций: таблица только облигаций.
- Страница операций: полная доступная история с cursor-пагинацией и фильтром по одному точному типу.
## Согласованные правила диаграммы
- Основа процентов — полная стоимость счёта.
- Срезы: акции, облигации, ETF/фонды, деньги и прочие инструменты.
- В «прочие» входят фьючерсы, опционы, структурные продукты, ЦФА и неизвестные типы.
- Нулевые срезы не отображаются.
- Отрицательные значения не рисуются как сектор, но показываются текстом рядом со сводкой.
- Легенда всегда содержит название, стоимость и процент; цвет не является единственным носителем
информации.
## Зафиксированные границы первой версии
Не входят в первую версию:
- отдельные страницы ETF, фондов и прочих инструментов;
- фильтр операций по датам или инструменту в UI;
- выбор нескольких типов операций;
- группировка точных типов в продуктовые категории;
- новая аналитика доходности или риска;
- изменение интеграции с T-Bank помимо данных, необходимых для согласованной сводки.
## Результат
Исследование завершено, продуктовые неоднозначности закрыты. Требования зафиксированы в
`docs/features/broker-account-sections/spec.md`.

View File

@ -0,0 +1,19 @@
# Roadmap
Roadmap отражает порядок продуктовой работы, но сам по себе не является основанием для реализации.
Требования и разрешение на реализацию определяются спецификацией конкретной фичи и текущей задачей
пользователя.
## Активные эпики
### [Портфель брокера](epics/BrokerPortfolio.md)
Цель: сделать реальные брокерские счета понятными на уровне обзора, позиций и операций.
- [x] [Разделы брокерского счёта](features/broker-account-sections/spec.md) — реализовано.
## Следующие этапы для активной фичи
1. [x] Проверить и утвердить `spec.md` для планирования.
2. [x] Проверить и утвердить подготовленные `plan.md` и `tasks.md`.
3. [ ] Получить отдельное подтверждение пользователя перед началом реализации.

View File

@ -1,88 +0,0 @@
# Broker API Performance Optimization
## Проблема
- `GET /api/v1/broker/accounts/:id/portfolio`**~5s**
- `GET /api/v1/broker/accounts/:id/positions` — **1-3s**
## Диагностика
### 1. Мёртвый код в `getPortfolio`
`buildInstrumentMap` делает N gRPC вызовов `GetInstrumentBy` (по одному на каждый `instrumentUid` в портфеле), но результат **не используется** в `mapBrokerPortfolio`. Это чистое WASTE.
### 2. Блокирующий instrument enrichment в `getPositions`
`buildInstrumentMap` вызывает `findByInstrumentUid` для каждого инструмента через gRPC. Даже при `Promise.allSettled`, все вызовы проходят через единый `p-queue` с 5 req/s. Для 10-20 позиций = 2-4 секунды ожидания в очереди.
Из всех полей `GetInstrumentBy` `mapBrokerPosition` использует только `name``ticker`, `classCode`, `instrumentType` уже есть в ответе `PortfolioPosition` (proto fields 32, 33, 2).
### 3. Единый rate limiter
Один `p-queue` на 5 req/s для всех gRPC вызовов. Instrument lookups конкурируют за очередь с portfolio/operations запросами.
## Изменения
### Change 1: Убрать `buildInstrumentMap` из `getPortfolio`
**Файлы:** `broker-portfolio.service.ts`
Удалить вызов `buildInstrumentMap` и передачу `instruments` в `mapBrokerPortfolio`. Исключить `BrokerInstrumentsService` из зависимостей (если не используется больше нигде в сервисе).
### Change 2: Instrument enrichment из кэша без блокировки
**Файлы:** `broker-portfolio.service.ts`, `cache.service.ts`
- `buildInstrumentMap` пытается достать данные из кэша без триггера gRPC
- Если данных нет — возвращаем `null` для имени (не блокируем ответ)
- Новый метод `CacheService.getIfPresent(key)` — проверяет кэш без вызова fetchFn
### Change 3: Разделить rate limiter на 3 очереди
**Файлы:** `tbank-client.service.ts`
Заменить единый `p-queue` на:
| Очередь | Rate | Сервисы |
|---|---|---|
| `operationsQueue` | 5 req/s | OperationsService |
| `instrumentsQueue` | 20 req/s | InstrumentsService |
| `usersQueue` | 5 req/s | UsersService |
Метод `callUnary` принимает параметр `queueName`. Клиентские методы выбирают очередь по типу сервиса.
### Change 4: Увеличить rate limit по умолчанию
**Файлы:** `configuration.ts`
`rateLimitPerSecond` по умолчанию: 5 → 20.
### Change 5: Shared cache сырого `GetPortfolio`
**Файлы:** `broker-portfolio.service.ts`
Оба эндпоинта вызывают `GetPortfolio` с одинаковым `accountId`. Кэшировать сырой ответ отдельно (TTL 60s, ключ `tbank:raw-portfolio:{accountId}`), чтобы второй запрос в том же окне не дублировал вызов.
## Ожидаемый эффект
| Endpoint | До | После |
|---|---|---|
| Portfolio | ~5s | ~0.3-0.5s (2 параллельных gRPC, без instrument enrichment) |
| Positions | 1-3s | ~0.2-0.3s (1 gRPC GetPortfolio, name из кэша / null) |
## Этапы реализации (по порядку)
1. Убрать `buildInstrumentMap` из `getPortfolio`
2. `CacheService.getIfPresent()` для instrument enrichment в positions
3. Разделить rate limiter на очереди
4. Увеличить rate limit по умолчанию
5. Shared cache сырого GetPortfolio
Каждый этап отдельным коммитом.
## Acceptance Criteria
1. Portfolio endpoint < 1s при тёплом кэше account/instrument, < 1.5s при холодном
2. Positions endpoint < 0.5s при тёплом кэше, < 1s при холодном
3. Все существующие тесты проходят
4. Instrument name показывается если есть в кэше, иначе `null`