Compare commits
No commits in common. "1dc27a6e9b6af5faa97169abd54538bfeb8ba550" and "5b9d7f3a27b4e511bb4b498acd1d6695b5a65a63" have entirely different histories.
1dc27a6e9b
...
5b9d7f3a27
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,7 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
.worktrees/
|
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@ -42,27 +42,10 @@ export class BrokerPortfolioYieldsDto {
|
|||||||
dailyPercent!: number | null;
|
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 {
|
export class BrokerPortfolioResponseDto {
|
||||||
@ApiProperty({ type: BrokerAccountResponseDto })
|
@ApiProperty({ type: BrokerAccountResponseDto })
|
||||||
account!: BrokerAccountResponseDto;
|
account!: BrokerAccountResponseDto;
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerPortfolioPositionCountsDto })
|
|
||||||
positionCounts!: BrokerPortfolioPositionCountsDto;
|
|
||||||
|
|
||||||
@ApiProperty({ type: BrokerPortfolioTotalsDto })
|
@ApiProperty({ type: BrokerPortfolioTotalsDto })
|
||||||
totals!: BrokerPortfolioTotalsDto;
|
totals!: BrokerPortfolioTotalsDto;
|
||||||
|
|
||||||
|
|||||||
@ -30,12 +30,6 @@ describe('portfolio.mapper', () => {
|
|||||||
currentPrice: { currency: 'rub', units: '250', nano: 0 },
|
currentPrice: { currency: 'rub', units: '250', nano: 0 },
|
||||||
averagePositionPrice: { currency: 'rub', units: '200', nano: 0 },
|
averagePositionPrice: { currency: 'rub', units: '200', nano: 0 },
|
||||||
},
|
},
|
||||||
{ instrumentType: 'SHARE' },
|
|
||||||
{ instrumentType: 'bond' },
|
|
||||||
{ instrumentType: 'etf' },
|
|
||||||
{ instrumentType: 'fund' },
|
|
||||||
{ instrumentType: 'future' },
|
|
||||||
{},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
positions: {
|
positions: {
|
||||||
@ -49,6 +43,5 @@ describe('portfolio.mapper', () => {
|
|||||||
expect(result.totals.shares?.value).toBe(1000);
|
expect(result.totals.shares?.value).toBe(1000);
|
||||||
expect(result.cash[0].value).toBe(500);
|
expect(result.cash[0].value).toBe(500);
|
||||||
expect(result.blockedCash[0].value).toBe(10);
|
expect(result.blockedCash[0].value).toBe(10);
|
||||||
expect(result.positionCounts).toEqual({ shares: 2, bonds: 1, etf: 2, other: 2 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -74,28 +74,8 @@ export function mapBrokerPosition(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio {
|
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 {
|
return {
|
||||||
account: input.account,
|
account: input.account,
|
||||||
positionCounts,
|
|
||||||
totals: {
|
totals: {
|
||||||
shares: mapMoneyValue(input.portfolio.totalAmountShares),
|
shares: mapMoneyValue(input.portfolio.totalAmountShares),
|
||||||
bonds: mapMoneyValue(input.portfolio.totalAmountBonds),
|
bonds: mapMoneyValue(input.portfolio.totalAmountBonds),
|
||||||
|
|||||||
@ -46,7 +46,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
|
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
|
||||||
positions: [{ instrumentType: 'share' }, { instrumentType: 'bond' }],
|
positions: [],
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
@ -60,9 +60,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
|
|
||||||
expect(result.data.account.id).toBe('acc-1');
|
expect(result.data.account.id).toBe('acc-1');
|
||||||
expect(result.data.cash[0].value).toBe(1000);
|
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('positions' in result.data).toBe(false);
|
||||||
expect(client.callUnary).toHaveBeenCalledTimes(2);
|
|
||||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||||
'tbank:portfolio',
|
'tbank:portfolio',
|
||||||
['acc-1'],
|
['acc-1'],
|
||||||
|
|||||||
@ -33,12 +33,6 @@ export type BrokerPosition = {
|
|||||||
|
|
||||||
export type BrokerPortfolio = {
|
export type BrokerPortfolio = {
|
||||||
account: BrokerAccount;
|
account: BrokerAccount;
|
||||||
positionCounts: {
|
|
||||||
shares: number;
|
|
||||||
bonds: number;
|
|
||||||
etf: number;
|
|
||||||
other: number;
|
|
||||||
};
|
|
||||||
totals: {
|
totals: {
|
||||||
shares: BrokerMoney | null;
|
shares: BrokerMoney | null;
|
||||||
bonds: BrokerMoney | null;
|
bonds: BrokerMoney | null;
|
||||||
|
|||||||
@ -25,31 +25,6 @@ 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 () => {
|
it('serializes positions query parameters', async () => {
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@ -281,12 +281,6 @@ export interface BrokerPosition {
|
|||||||
|
|
||||||
export interface BrokerPortfolio {
|
export interface BrokerPortfolio {
|
||||||
account: BrokerAccount;
|
account: BrokerAccount;
|
||||||
positionCounts: {
|
|
||||||
shares: number;
|
|
||||||
bonds: number;
|
|
||||||
etf: number;
|
|
||||||
other: number;
|
|
||||||
};
|
|
||||||
totals: {
|
totals: {
|
||||||
shares: BrokerMoney | null;
|
shares: BrokerMoney | null;
|
||||||
bonds: BrokerMoney | null;
|
bonds: BrokerMoney | null;
|
||||||
|
|||||||
@ -417,23 +417,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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': {
|
'/api/v1/broker/accounts/{accountId}/operations': {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -791,12 +774,6 @@ export interface components {
|
|||||||
data: components['schemas']['BrokerAccountResponseDto'][];
|
data: components['schemas']['BrokerAccountResponseDto'][];
|
||||||
meta: components['schemas']['BrokerResponseMetaDto'];
|
meta: components['schemas']['BrokerResponseMetaDto'];
|
||||||
};
|
};
|
||||||
BrokerPortfolioPositionCountsDto: {
|
|
||||||
shares: number;
|
|
||||||
bonds: number;
|
|
||||||
etf: number;
|
|
||||||
other: number;
|
|
||||||
};
|
|
||||||
BrokerMoneyDto: {
|
BrokerMoneyDto: {
|
||||||
currency: string;
|
currency: string;
|
||||||
units: string;
|
units: string;
|
||||||
@ -819,19 +796,6 @@ export interface components {
|
|||||||
daily: components['schemas']['BrokerMoneyDto'] | null;
|
daily: components['schemas']['BrokerMoneyDto'] | null;
|
||||||
dailyPercent: Record<string, never> | 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: {
|
BrokerPositionResponseDto: {
|
||||||
figi: Record<string, never> | null;
|
figi: Record<string, never> | null;
|
||||||
instrumentUid: Record<string, never> | null;
|
instrumentUid: Record<string, never> | null;
|
||||||
@ -848,15 +812,17 @@ export interface components {
|
|||||||
expectedYieldPercent: Record<string, never> | null;
|
expectedYieldPercent: Record<string, never> | null;
|
||||||
dailyYield: components['schemas']['BrokerMoneyDto'] | null;
|
dailyYield: components['schemas']['BrokerMoneyDto'] | null;
|
||||||
};
|
};
|
||||||
BrokerPositionsPageResponseDto: {
|
BrokerPortfolioResponseDto: {
|
||||||
accountId: string;
|
account: components['schemas']['BrokerAccountResponseDto'];
|
||||||
items: components['schemas']['BrokerPositionResponseDto'][];
|
totals: components['schemas']['BrokerPortfolioTotalsDto'];
|
||||||
nextCursor: Record<string, never> | null;
|
yields: components['schemas']['BrokerPortfolioYieldsDto'];
|
||||||
hasNext: boolean;
|
cash: components['schemas']['BrokerMoneyDto'][];
|
||||||
|
blockedCash: components['schemas']['BrokerMoneyDto'][];
|
||||||
|
positions: components['schemas']['BrokerPositionResponseDto'][];
|
||||||
asOf: string;
|
asOf: string;
|
||||||
};
|
};
|
||||||
BrokerPositionsEnvelopeDto: {
|
BrokerPortfolioEnvelopeDto: {
|
||||||
data: components['schemas']['BrokerPositionsPageResponseDto'];
|
data: components['schemas']['BrokerPortfolioResponseDto'];
|
||||||
meta: components['schemas']['BrokerResponseMetaDto'];
|
meta: components['schemas']['BrokerResponseMetaDto'];
|
||||||
};
|
};
|
||||||
BrokerOperationResponseDto: {
|
BrokerOperationResponseDto: {
|
||||||
@ -869,7 +835,6 @@ export interface components {
|
|||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
category: 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
|
category: 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
|
||||||
description: Record<string, never> | null;
|
description: Record<string, never> | null;
|
||||||
name: Record<string, never> | null;
|
|
||||||
state: Record<string, never> | null;
|
state: Record<string, never> | null;
|
||||||
instrumentUid: Record<string, never> | null;
|
instrumentUid: Record<string, never> | null;
|
||||||
figi: Record<string, never> | null;
|
figi: Record<string, never> | null;
|
||||||
@ -1554,33 +1519,6 @@ 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: {
|
TBankController_getOperations: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
135
apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx
Normal file
135
apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,49 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
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,5 +1,4 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../api/responses';
|
import type { BrokerMoney, BrokerOperation, BrokerOperationsPage } from '../../api/responses';
|
||||||
import {
|
import {
|
||||||
getBrokerInstrumentPath,
|
getBrokerInstrumentPath,
|
||||||
@ -95,23 +94,28 @@ const pagButtonDisabledStyle = {
|
|||||||
} satisfies React.CSSProperties;
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
export function BrokerOperationsTable({
|
export function BrokerOperationsTable({
|
||||||
title,
|
|
||||||
headerAction,
|
|
||||||
emptyMessage,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
page,
|
page,
|
||||||
pagination,
|
pageNumber,
|
||||||
}: BrokerOperationsTableProps) {
|
canGoBack,
|
||||||
const pageNumber = pagination?.pageNumber;
|
canGoForward,
|
||||||
const canGoBack = pagination?.canGoBack ?? false;
|
onPrevious,
|
||||||
const canGoForward = pagination?.canGoForward ?? false;
|
onNext,
|
||||||
const onPrevious = pagination?.onPrevious;
|
}: {
|
||||||
const onNext = pagination?.onNext;
|
isLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
page: BrokerOperationsPage | undefined;
|
||||||
|
pageNumber: number;
|
||||||
|
canGoBack: boolean;
|
||||||
|
canGoForward: boolean;
|
||||||
|
onPrevious: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}) {
|
||||||
const operations = page?.items ?? [];
|
const operations = page?.items ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-busy={isFetching}>
|
<section>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -121,13 +125,10 @@ export function BrokerOperationsTable({
|
|||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={{ fontSize: 20, margin: 0 }}>{title}</h2>
|
<h2 style={{ fontSize: 20, margin: 0 }}>Операции</h2>
|
||||||
{headerAction}
|
|
||||||
{pagination && (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Предыдущая страница"
|
|
||||||
onClick={onPrevious}
|
onClick={onPrevious}
|
||||||
disabled={!canGoBack || isFetching}
|
disabled={!canGoBack || isFetching}
|
||||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
@ -154,7 +155,6 @@ export function BrokerOperationsTable({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Следующая страница"
|
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={!canGoForward || isFetching}
|
disabled={!canGoForward || isFetching}
|
||||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
@ -169,7 +169,6 @@ export function BrokerOperationsTable({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@ -198,7 +197,7 @@ export function BrokerOperationsTable({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : operations.length === 0 && !isFetching ? (
|
) : operations.length === 0 && !isFetching ? (
|
||||||
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-container">
|
<div className="table-container">
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
@ -244,10 +243,10 @@ export function BrokerOperationsTable({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{isFetching && (
|
{isFetching && (
|
||||||
<div className="table-loading-overlay" role="status">
|
<div className="table-loading-overlay">
|
||||||
<div className="loading-spinner" aria-hidden="true" />
|
<div className="loading-spinner" />
|
||||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||||
{pagination ? `Загрузка страницы ${pageNumber}…` : 'Обновление операций…'}
|
Загрузка страницы {pageNumber}…
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -256,19 +255,3 @@ export function BrokerOperationsTable({
|
|||||||
</section>
|
</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,10 +1,24 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { BrokerMoney, BrokerPosition, BrokerPositionsPage } from '../../api/responses';
|
import type { BrokerMoney, BrokerPosition } from '../../api/responses';
|
||||||
import { getBrokerInstrumentPath } from './brokerDisplay';
|
import { getBrokerInstrumentPath } from './brokerDisplay';
|
||||||
import { TableSkeleton } from '../../components/TableSkeleton';
|
import { TableSkeleton } from '../../components/TableSkeleton';
|
||||||
import { useBrokerPositions } from '../../hooks/useBrokerPositions';
|
import { useBrokerPositions } from '../../hooks/useBrokerPositions';
|
||||||
import { useBrokerAccountContext } from './BrokerAccountLayout';
|
|
||||||
|
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));
|
||||||
|
|
||||||
const tableStyle = {
|
const tableStyle = {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@ -75,31 +89,50 @@ function PositionTicker({ position }: { position: BrokerPosition }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BrokerPositionTable({
|
function PositionGroupTable({
|
||||||
title,
|
accountId,
|
||||||
page,
|
group,
|
||||||
isLoading,
|
|
||||||
isFetching,
|
|
||||||
emptyMessage,
|
|
||||||
pageNumber,
|
|
||||||
onNext,
|
|
||||||
onPrevious,
|
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
accountId: string;
|
||||||
page: BrokerPositionsPage | undefined;
|
group: BrokerPositionGroupConfig;
|
||||||
isLoading: boolean;
|
|
||||||
isFetching: boolean;
|
|
||||||
emptyMessage: string;
|
|
||||||
pageNumber: number;
|
|
||||||
onNext: () => void;
|
|
||||||
onPrevious: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const positions = page?.items ?? [];
|
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
||||||
const canGoBack = pageNumber > 1;
|
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||||
const canGoForward = Boolean(page?.hasNext && page.nextCursor);
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
|
<section>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -109,14 +142,12 @@ function BrokerPositionTable({
|
|||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 id={`broker-${title.toLowerCase()}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
<h3 style={{ fontSize: 18, margin: 0 }}>{group.title}</h3>
|
||||||
{title}
|
{group.type && (
|
||||||
</h2>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Предыдущая страница"
|
onClick={handlePrevious}
|
||||||
onClick={onPrevious}
|
|
||||||
disabled={!canGoBack || isFetching}
|
disabled={!canGoBack || isFetching}
|
||||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
>
|
>
|
||||||
@ -142,8 +173,7 @@ function BrokerPositionTable({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Следующая страница"
|
onClick={handleNext}
|
||||||
onClick={onNext}
|
|
||||||
disabled={!canGoForward || isFetching}
|
disabled={!canGoForward || isFetching}
|
||||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
>
|
>
|
||||||
@ -157,9 +187,10 @@ function BrokerPositionTable({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading && (
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
<table style={tableStyle}>
|
<table style={tableStyle}>
|
||||||
<thead>
|
<thead>
|
||||||
@ -193,12 +224,12 @@ function BrokerPositionTable({
|
|||||||
/>
|
/>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : positions.length === 0 && !isFetching ? (
|
)}
|
||||||
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
|
||||||
) : (
|
{!isLoading && positions.length > 0 && (
|
||||||
<div className="table-container">
|
<div className="table-container">
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
|
<table aria-label={`Брокерские позиции: ${group.title}`} style={tableStyle}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th align="left" style={thStyle}>
|
<th align="left" style={thStyle}>
|
||||||
@ -264,53 +295,17 @@ function BrokerPositionTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrokerPositionsPageProps = {
|
type BrokerPositionsSectionProps = {
|
||||||
type: 'share' | 'bond';
|
accountId: string;
|
||||||
title: 'Акции' | 'Облигации';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
|
export function BrokerPositionsSection({ accountId }: BrokerPositionsSectionProps) {
|
||||||
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 (
|
return (
|
||||||
<section aria-labelledby={`broker-${type}-heading`}>
|
<div style={{ display: 'grid', gap: 20 }}>
|
||||||
<h2 id={`broker-${type}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
<h2 style={{ fontSize: 20, margin: 0 }}>Позиции</h2>
|
||||||
{title}
|
{GROUPS.map((group) => (
|
||||||
</h2>
|
<PositionGroupTable key={group.key} accountId={accountId} group={group} />
|
||||||
<p role="alert">
|
))}
|
||||||
{type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'}
|
</div>
|
||||||
</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}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,144 +0,0 @@
|
|||||||
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' }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
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,12 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { BrokerOperation, BrokerPosition } from '../../api/responses';
|
import type { BrokerOperation, BrokerPosition } from '../../api/responses';
|
||||||
import {
|
import {
|
||||||
BROKER_OPERATION_TYPE_OPTIONS,
|
|
||||||
getBrokerInstrumentPath,
|
getBrokerInstrumentPath,
|
||||||
getBrokerOperationImpact,
|
getBrokerOperationImpact,
|
||||||
getBrokerOperationTypeLabel,
|
getBrokerOperationTypeLabel,
|
||||||
getBrokerPositionGroup,
|
getBrokerPositionGroup,
|
||||||
isBrokerOperationType,
|
|
||||||
} from './brokerDisplay';
|
} from './brokerDisplay';
|
||||||
|
|
||||||
function position(input: Partial<BrokerPosition>): BrokerPosition {
|
function position(input: Partial<BrokerPosition>): BrokerPosition {
|
||||||
@ -39,7 +37,6 @@ function operation(input: Partial<BrokerOperation>): BrokerOperation {
|
|||||||
type: 'OPERATION_TYPE_UNSPECIFIED',
|
type: 'OPERATION_TYPE_UNSPECIFIED',
|
||||||
category: 'other',
|
category: 'other',
|
||||||
description: null,
|
description: null,
|
||||||
name: null,
|
|
||||||
state: null,
|
state: null,
|
||||||
instrumentUid: null,
|
instrumentUid: null,
|
||||||
figi: null,
|
figi: null,
|
||||||
@ -119,37 +116,6 @@ describe('broker display helpers', () => {
|
|||||||
).toBe('Custom');
|
).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', () => {
|
it('classifies operations by portfolio impact', () => {
|
||||||
expect(
|
expect(
|
||||||
getBrokerOperationImpact(
|
getBrokerOperationImpact(
|
||||||
|
|||||||
@ -94,20 +94,6 @@ const OPERATION_TYPE_LABELS: Record<string, string> = {
|
|||||||
OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг',
|
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(
|
export function getBrokerPositionGroup(
|
||||||
position: Pick<BrokerPosition, 'instrumentType'>,
|
position: Pick<BrokerPosition, 'instrumentType'>,
|
||||||
): BrokerPositionGroup {
|
): BrokerPositionGroup {
|
||||||
|
|||||||
@ -11,10 +11,7 @@ import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
|
|||||||
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
|
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
|
||||||
import { ScreenerPage } from './pages/screener/ScreenerPage';
|
import { ScreenerPage } from './pages/screener/ScreenerPage';
|
||||||
import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage';
|
import { BrokerAccountsPage } from './pages/broker/BrokerAccountsPage';
|
||||||
import { BrokerAccountLayout } from './pages/broker/BrokerAccountLayout';
|
import { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage';
|
||||||
import { BrokerAccountOverviewPage } from './pages/broker/BrokerAccountOverviewPage';
|
|
||||||
import { BrokerPositionsPage } from './pages/broker/BrokerPositionsPage';
|
|
||||||
import { BrokerOperationsPage } from './pages/broker/BrokerOperationsPage';
|
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
@ -62,15 +59,10 @@ export function AppRoutes() {
|
|||||||
path="/broker/:accountId"
|
path="/broker/:accountId"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<BrokerAccountLayout />
|
<BrokerAccountDetailPage />
|
||||||
</ProtectedRoute>
|
</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>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -18,15 +18,9 @@
|
|||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pnl-cell {
|
.pnl-cell { text-align: right; }
|
||||||
text-align: right;
|
.positive { color: var(--color-positive); }
|
||||||
}
|
.negative { color: var(--color-negative); }
|
||||||
.positive {
|
|
||||||
color: var(--color-positive);
|
|
||||||
}
|
|
||||||
.negative {
|
|
||||||
color: var(--color-negative);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
@ -41,25 +35,24 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% {
|
0% { background-position: 200% 0; }
|
||||||
background-position: 200% 0;
|
100% { background-position: -200% 0; }
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: -200% 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton {
|
.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%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.5s ease-in-out infinite;
|
animation: shimmer 1.5s ease-in-out infinite;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes loading-spin {
|
@keyframes loading-spin {
|
||||||
to {
|
to { transform: rotate(360deg); }
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
@ -87,212 +80,3 @@ a {
|
|||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
z-index: 1;
|
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,38 +1,8 @@
|
|||||||
# Портфель брокера
|
Портфель брокера
|
||||||
|
|
||||||
Статус: активный
|
Features:
|
||||||
|
- broker-operations-ui-improvements
|
||||||
## Цель
|
- broker-portfolio-display
|
||||||
|
- broker-positions-pagination-and-loading
|
||||||
Дать пользователю целостный доступ к реальным брокерским счетам T-Bank: от подключения и получения
|
- tbank-broker-portfolios
|
||||||
данных до понятного обзора состава счёта, позиций и истории операций.
|
- tbank-deadline-queue-fix
|
||||||
|
|
||||||
## Пользовательская возможность
|
|
||||||
|
|
||||||
В рамках эпика пользователь может:
|
|
||||||
|
|
||||||
- открыть список доступных брокерских счетов и ИИС;
|
|
||||||
- просмотреть состояние конкретного счёта;
|
|
||||||
- изучить позиции по классам инструментов;
|
|
||||||
- открыть историю операций и понять финансовый смысл каждой записи;
|
|
||||||
- перейти от брокерской позиции к странице поддерживаемого биржевого инструмента.
|
|
||||||
|
|
||||||
## Границы эпика
|
|
||||||
|
|
||||||
Эпик охватывает отображение и чтение данных брокерского счёта. Торговые поручения, изменение счёта,
|
|
||||||
автоматические стратегии, налоговая отчётность и экспорт документов не входят в его текущую область.
|
|
||||||
|
|
||||||
## 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
@ -1,166 +0,0 @@
|
|||||||
# Разделы брокерского счёта
|
|
||||||
|
|
||||||
Дата: 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-фильтры операций по датам и инструментам;
|
|
||||||
- изменение правил расчёта доходности;
|
|
||||||
- экспорт позиций или операций.
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
# Разделы брокерского счёта — задачи
|
|
||||||
|
|
||||||
Дата: 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 и этого списка только после полной проверки.
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
# Исследование структуры страницы брокерского счёта
|
|
||||||
|
|
||||||
Дата: 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`.
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
# Roadmap
|
|
||||||
|
|
||||||
Roadmap отражает порядок продуктовой работы, но сам по себе не является основанием для реализации.
|
|
||||||
Требования и разрешение на реализацию определяются спецификацией конкретной фичи и текущей задачей
|
|
||||||
пользователя.
|
|
||||||
|
|
||||||
## Активные эпики
|
|
||||||
|
|
||||||
### [Портфель брокера](epics/BrokerPortfolio.md)
|
|
||||||
|
|
||||||
Цель: сделать реальные брокерские счета понятными на уровне обзора, позиций и операций.
|
|
||||||
|
|
||||||
- [x] [Разделы брокерского счёта](features/broker-account-sections/spec.md) — реализовано.
|
|
||||||
|
|
||||||
## Следующие этапы для активной фичи
|
|
||||||
|
|
||||||
1. [x] Проверить и утвердить `spec.md` для планирования.
|
|
||||||
2. [x] Проверить и утвердить подготовленные `plan.md` и `tasks.md`.
|
|
||||||
3. [ ] Получить отдельное подтверждение пользователя перед началом реализации.
|
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
# 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