Compare commits
18 Commits
5b9d7f3a27
...
1dc27a6e9b
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dc27a6e9b | |||
| d8b3886130 | |||
| 2223463dd3 | |||
| 910494a4e5 | |||
| b82771107d | |||
| a19ad67a89 | |||
| 1c26d2a3eb | |||
| 633def5ebb | |||
| dc4b6ddf1b | |||
| 6d2df6a12b | |||
| 2462f2122c | |||
| 5cb51e44f2 | |||
| 8aea56cbbf | |||
| da6d05e425 | |||
| be24184e4f | |||
| 62cef9b1c4 | |||
| 30922aaa28 | |||
| 0c12e6d610 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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?: {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
49
apps/frontend/src/pages/broker/BrokerAccountLayout.tsx
Normal file
49
apps/frontend/src/pages/broker/BrokerAccountLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx
Normal file
178
apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
apps/frontend/src/pages/broker/BrokerAllocationChart.tsx
Normal file
89
apps/frontend/src/pages/broker/BrokerAllocationChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
apps/frontend/src/pages/broker/BrokerOperationsPage.tsx
Normal file
86
apps/frontend/src/pages/broker/BrokerOperationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,50 +121,55 @@ export function BrokerOperationsTable({
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 20, margin: 0 }}>Операции</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrevious}
|
||||
disabled={!canGoBack || isFetching}
|
||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'←'
|
||||
)}
|
||||
</button>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 20,
|
||||
textAlign: 'center',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{pageNumber}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={!canGoForward || isFetching}
|
||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'→'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<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}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'←'
|
||||
)}
|
||||
</button>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 20,
|
||||
textAlign: 'center',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{pageNumber}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Следующая страница"
|
||||
onClick={onNext}
|
||||
disabled={!canGoForward || isFetching}
|
||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'→'
|
||||
)}
|
||||
</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
@ -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,55 +109,57 @@ function PositionGroupTable({
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: 18, margin: 0 }}>{group.title}</h3>
|
||||
{group.type && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrevious}
|
||||
disabled={!canGoBack || isFetching}
|
||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'←'
|
||||
)}
|
||||
</button>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 20,
|
||||
textAlign: 'center',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{pageNumber}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
disabled={!canGoForward || isFetching}
|
||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'→'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<h2 id={`broker-${title.toLowerCase()}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
||||
{title}
|
||||
</h2>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Предыдущая страница"
|
||||
onClick={onPrevious}
|
||||
disabled={!canGoBack || isFetching}
|
||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'←'
|
||||
)}
|
||||
</button>
|
||||
<span
|
||||
style={{
|
||||
minWidth: 20,
|
||||
textAlign: 'center',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{pageNumber}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Следующая страница"
|
||||
onClick={onNext}
|
||||
disabled={!canGoForward || isFetching}
|
||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||
>
|
||||
{isFetching ? (
|
||||
<span
|
||||
className="loading-spinner"
|
||||
style={{ width: 14, height: 14, display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
'→'
|
||||
)}
|
||||
</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 (
|
||||
<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 (
|
||||
<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>
|
||||
<BrokerPositionTable
|
||||
title={title}
|
||||
page={positions.data}
|
||||
isLoading={positions.isLoading}
|
||||
isFetching={positions.isFetching}
|
||||
emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'}
|
||||
pageNumber={cursorStack.length + 1}
|
||||
onNext={handleNext}
|
||||
onPrevious={handlePrevious}
|
||||
/>
|
||||
);
|
||||
}
|
||||
144
apps/frontend/src/pages/broker/brokerAllocation.test.ts
Normal file
144
apps/frontend/src/pages/broker/brokerAllocation.test.ts
Normal 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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
85
apps/frontend/src/pages/broker/brokerAllocation.ts
Normal file
85
apps/frontend/src/pages/broker/brokerAllocation.ts
Normal 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 };
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
## Критерий завершения эпика
|
||||
|
||||
Пользователь может последовательно пройти от списка счетов к обзору выбранного счёта, отдельным
|
||||
классам позиций и доступной истории операций; интерфейс корректно обрабатывает загрузку, пустые
|
||||
данные и ошибки внешней интеграции.
|
||||
|
||||
1305
docs/features/broker-account-sections/plan.md
Normal file
1305
docs/features/broker-account-sections/plan.md
Normal file
File diff suppressed because it is too large
Load Diff
166
docs/features/broker-account-sections/spec.md
Normal file
166
docs/features/broker-account-sections/spec.md
Normal 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-фильтры операций по датам и инструментам;
|
||||
- изменение правил расчёта доходности;
|
||||
- экспорт позиций или операций.
|
||||
51
docs/features/broker-account-sections/tasks.md
Normal file
51
docs/features/broker-account-sections/tasks.md
Normal 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 и этого списка только после полной проверки.
|
||||
122
docs/research/2026-06-18-broker-account-sections.md
Normal file
122
docs/research/2026-06-18-broker-account-sections.md
Normal 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`.
|
||||
@ -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. [ ] Получить отдельное подтверждение пользователя перед началом реализации.
|
||||
@ -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`
|
||||
Loading…
x
Reference in New Issue
Block a user