Compare commits
18 Commits
5b9d7f3a27
...
1dc27a6e9b
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dc27a6e9b | |||
| d8b3886130 | |||
| 2223463dd3 | |||
| 910494a4e5 | |||
| b82771107d | |||
| a19ad67a89 | |||
| 1c26d2a3eb | |||
| 633def5ebb | |||
| dc4b6ddf1b | |||
| 6d2df6a12b | |||
| 2462f2122c | |||
| 5cb51e44f2 | |||
| 8aea56cbbf | |||
| da6d05e425 | |||
| be24184e4f | |||
| 62cef9b1c4 | |||
| 30922aaa28 | |||
| 0c12e6d610 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,6 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
.worktrees/
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@ -42,10 +42,27 @@ 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,6 +30,12 @@ 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: {
|
||||||
@ -43,5 +49,6 @@ 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,8 +74,28 @@ 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: [],
|
positions: [{ instrumentType: 'share' }, { instrumentType: 'bond' }],
|
||||||
})
|
})
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
@ -60,7 +60,9 @@ 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,6 +33,12 @@ 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,6 +25,31 @@ describe('broker api', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serializes operations query parameters including operationTypes', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: 'now' },
|
||||||
|
meta: { fromCache: false, cachedAt: null },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await getBrokerOperations('acc-1', {
|
||||||
|
cursor: 'c1',
|
||||||
|
limit: 10,
|
||||||
|
operationTypes: 'OPERATION_TYPE_COUPON',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(
|
||||||
|
'/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=10&operationTypes=OPERATION_TYPE_COUPON',
|
||||||
|
),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('serializes positions query parameters', async () => {
|
it('serializes positions query parameters', async () => {
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@ -281,6 +281,12 @@ 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,6 +417,23 @@ 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;
|
||||||
@ -774,6 +791,12 @@ 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;
|
||||||
@ -796,6 +819,19 @@ 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;
|
||||||
@ -812,17 +848,15 @@ export interface components {
|
|||||||
expectedYieldPercent: Record<string, never> | null;
|
expectedYieldPercent: Record<string, never> | null;
|
||||||
dailyYield: components['schemas']['BrokerMoneyDto'] | null;
|
dailyYield: components['schemas']['BrokerMoneyDto'] | null;
|
||||||
};
|
};
|
||||||
BrokerPortfolioResponseDto: {
|
BrokerPositionsPageResponseDto: {
|
||||||
account: components['schemas']['BrokerAccountResponseDto'];
|
accountId: string;
|
||||||
totals: components['schemas']['BrokerPortfolioTotalsDto'];
|
items: components['schemas']['BrokerPositionResponseDto'][];
|
||||||
yields: components['schemas']['BrokerPortfolioYieldsDto'];
|
nextCursor: Record<string, never> | null;
|
||||||
cash: components['schemas']['BrokerMoneyDto'][];
|
hasNext: boolean;
|
||||||
blockedCash: components['schemas']['BrokerMoneyDto'][];
|
|
||||||
positions: components['schemas']['BrokerPositionResponseDto'][];
|
|
||||||
asOf: string;
|
asOf: string;
|
||||||
};
|
};
|
||||||
BrokerPortfolioEnvelopeDto: {
|
BrokerPositionsEnvelopeDto: {
|
||||||
data: components['schemas']['BrokerPortfolioResponseDto'];
|
data: components['schemas']['BrokerPositionsPageResponseDto'];
|
||||||
meta: components['schemas']['BrokerResponseMetaDto'];
|
meta: components['schemas']['BrokerResponseMetaDto'];
|
||||||
};
|
};
|
||||||
BrokerOperationResponseDto: {
|
BrokerOperationResponseDto: {
|
||||||
@ -835,6 +869,7 @@ 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;
|
||||||
@ -1519,6 +1554,33 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
TBankController_getPositions: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
/** @description Cursor for pagination (positionUid) */
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
/** @description Filter by instrument type (share, bond, etf, etc.) */
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
accountId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['BrokerPositionsEnvelopeDto'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
TBankController_getOperations: {
|
TBankController_getOperations: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@ -1,135 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import type { BrokerMoney } from '../../api/responses';
|
|
||||||
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
|
|
||||||
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
|
|
||||||
import { BrokerOperationsTable } from './BrokerOperationsTable';
|
|
||||||
import { BrokerPositionsSection } from './BrokerPositionsSection';
|
|
||||||
import { SkeletonBlock } from '../../components/SkeletonBlock';
|
|
||||||
|
|
||||||
function formatMoney(value: BrokerMoney | null | undefined) {
|
|
||||||
if (!value) return '-';
|
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: value.currency || 'RUB',
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
}).format(value.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BrokerAccountDetailPage() {
|
|
||||||
const { accountId } = useParams();
|
|
||||||
const [operationCursor, setOperationCursor] = useState<string | undefined>(undefined);
|
|
||||||
const [operationCursorStack, setOperationCursorStack] = useState<Array<string | undefined>>([]);
|
|
||||||
const portfolio = useBrokerPortfolio(accountId);
|
|
||||||
const operations = useBrokerOperations(accountId, { limit: 10, cursor: operationCursor });
|
|
||||||
|
|
||||||
if (portfolio.isLoading) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'grid', gap: 24 }}>
|
|
||||||
<div style={{ display: 'grid', gap: 12 }}>
|
|
||||||
<SkeletonBlock height={32} width="60%" />
|
|
||||||
<SkeletonBlock height={24} width="40%" />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
padding: 16,
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
border: '1px solid #e0e0e0',
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SkeletonBlock height={14} width="40%" />
|
|
||||||
<div style={{ height: 8 }} />
|
|
||||||
<SkeletonBlock height={20} width="60%" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (portfolio.error || !portfolio.data) {
|
|
||||||
return <p style={{ color: 'var(--color-negative)' }}>Не удалось загрузить портфель</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNextOperationsPage() {
|
|
||||||
const nextCursor = operations.data?.nextCursor;
|
|
||||||
if (!nextCursor || !operations.data?.hasNext) return;
|
|
||||||
setOperationCursorStack((previous) => [...previous, operationCursor]);
|
|
||||||
setOperationCursor(nextCursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePreviousOperationsPage() {
|
|
||||||
if (operationCursorStack.length === 0) return;
|
|
||||||
const nextStack = operationCursorStack.slice(0, -1);
|
|
||||||
const previousCursor = operationCursorStack[operationCursorStack.length - 1];
|
|
||||||
setOperationCursorStack(nextStack);
|
|
||||||
setOperationCursor(previousCursor);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'grid', gap: 24 }}>
|
|
||||||
<header>
|
|
||||||
<h1 style={{ fontSize: 28, lineHeight: 1.2, marginBottom: 12 }}>
|
|
||||||
{portfolio.data.account.name}
|
|
||||||
</h1>
|
|
||||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'baseline' }}>
|
|
||||||
<strong style={{ fontSize: 24 }}>{formatMoney(portfolio.data.totals.portfolio)}</strong>
|
|
||||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
|
||||||
День: {formatMoney(portfolio.data.yields.daily)}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: 'var(--color-text-secondary)' }}>
|
|
||||||
Ожидаемая: {portfolio.data.yields.expectedPercent ?? '-'}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
|
||||||
gap: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{portfolio.data.cash.map((money) => (
|
|
||||||
<div
|
|
||||||
key={money.currency}
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
border: '1px solid #e0e0e0',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>
|
|
||||||
{money.currency}
|
|
||||||
</div>
|
|
||||||
<strong>{formatMoney(money)}</strong>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<BrokerPositionsSection accountId={accountId!} />
|
|
||||||
|
|
||||||
<BrokerOperationsTable
|
|
||||||
isLoading={operations.isLoading}
|
|
||||||
isFetching={operations.isFetching}
|
|
||||||
page={operations.data}
|
|
||||||
pageNumber={operationCursorStack.length + 1}
|
|
||||||
canGoBack={operationCursorStack.length > 0}
|
|
||||||
canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)}
|
|
||||||
onPrevious={handlePreviousOperationsPage}
|
|
||||||
onNext={handleNextOperationsPage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
49
apps/frontend/src/pages/broker/BrokerAccountLayout.tsx
Normal file
49
apps/frontend/src/pages/broker/BrokerAccountLayout.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { NavLink, Outlet, useOutletContext, useParams } from 'react-router-dom';
|
||||||
|
import { useBrokerPortfolio } from '../../hooks/useBrokerPortfolio';
|
||||||
|
|
||||||
|
export type BrokerAccountContext = {
|
||||||
|
accountId: string;
|
||||||
|
portfolio: ReturnType<typeof useBrokerPortfolio>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useBrokerAccountContext() {
|
||||||
|
return useOutletContext<BrokerAccountContext>();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrokerAccountLayout() {
|
||||||
|
const { accountId = '' } = useParams();
|
||||||
|
const portfolio = useBrokerPortfolio(accountId);
|
||||||
|
const basePath = `/broker/${encodeURIComponent(accountId)}`;
|
||||||
|
const context: BrokerAccountContext = { accountId, portfolio };
|
||||||
|
const linkClassName = ({ isActive }: { isActive: boolean }) =>
|
||||||
|
`broker-account__link${isActive ? ' is-active' : ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="broker-account">
|
||||||
|
<header className="broker-account__header">
|
||||||
|
<h1>{portfolio.data?.account.name || 'Брокерский счёт'}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="broker-account__workspace">
|
||||||
|
<nav className="broker-account__navigation" aria-label="Разделы брокерского счёта">
|
||||||
|
<NavLink className={linkClassName} end to={basePath}>
|
||||||
|
Обзор
|
||||||
|
</NavLink>
|
||||||
|
<NavLink className={linkClassName} to={`${basePath}/shares`}>
|
||||||
|
Акции
|
||||||
|
</NavLink>
|
||||||
|
<NavLink className={linkClassName} to={`${basePath}/bonds`}>
|
||||||
|
Облигации
|
||||||
|
</NavLink>
|
||||||
|
<NavLink className={linkClassName} to={`${basePath}/operations`}>
|
||||||
|
Операции
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="broker-account__content">
|
||||||
|
<Outlet context={context} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx
Normal file
178
apps/frontend/src/pages/broker/BrokerAccountOverviewPage.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import type { BrokerMoney, BrokerPortfolio } from '../../api/responses';
|
||||||
|
import { SkeletonBlock } from '../../components/SkeletonBlock';
|
||||||
|
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
|
||||||
|
import { useBrokerAccountContext } from './BrokerAccountLayout';
|
||||||
|
import { BrokerAllocationChart } from './BrokerAllocationChart';
|
||||||
|
import { BrokerOperationsTable } from './BrokerOperationsTable';
|
||||||
|
|
||||||
|
function formatMoney(value: BrokerMoney | null | undefined) {
|
||||||
|
if (!value) return '—';
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: value.currency || 'RUB',
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPercent(value: number | null) {
|
||||||
|
if (value === null) return '—';
|
||||||
|
return `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 2 }).format(value)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluralize(count: number, one: string, few: string, many: string) {
|
||||||
|
const modulo100 = Math.abs(count) % 100;
|
||||||
|
const modulo10 = modulo100 % 10;
|
||||||
|
if (modulo100 > 10 && modulo100 < 20) return many;
|
||||||
|
if (modulo10 === 1) return one;
|
||||||
|
if (modulo10 >= 2 && modulo10 <= 4) return few;
|
||||||
|
return many;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrokerSummary({ portfolio }: { portfolio: BrokerPortfolio }) {
|
||||||
|
return (
|
||||||
|
<section className="broker-overview__summary" aria-label="Сводка счёта">
|
||||||
|
<div className="broker-overview__card">
|
||||||
|
<span className="broker-overview__label">Стоимость портфеля</span>
|
||||||
|
<strong className="broker-overview__total">
|
||||||
|
{formatMoney(portfolio.totals.portfolio)}
|
||||||
|
</strong>
|
||||||
|
<span>За день: {formatMoney(portfolio.yields.daily)}</span>
|
||||||
|
<span>Дневная доходность: {formatPercent(portfolio.yields.dailyPercent)}</span>
|
||||||
|
<span>Ожидаемая доходность: {formatPercent(portfolio.yields.expectedPercent)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="broker-overview__card">
|
||||||
|
<span className="broker-overview__label">Денежный остаток</span>
|
||||||
|
{portfolio.cash.length === 0 ? (
|
||||||
|
<span>Нет денежных остатков</span>
|
||||||
|
) : (
|
||||||
|
<ul className="broker-overview__cash">
|
||||||
|
{portfolio.cash.map((money, index) => (
|
||||||
|
<li key={`${money.currency}-${index}`}>
|
||||||
|
<span>{money.currency}</span>
|
||||||
|
<strong>{formatMoney(money)}</strong>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function allocationPercent(value: BrokerMoney | null, total: BrokerMoney | null) {
|
||||||
|
if (!value || !total || total.value <= 0) return null;
|
||||||
|
return (value.value / total.value) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAllocationPercent(value: number | null) {
|
||||||
|
return value === null ? '—' : `${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrokerAssetCards({
|
||||||
|
accountId,
|
||||||
|
portfolio,
|
||||||
|
}: {
|
||||||
|
accountId: string;
|
||||||
|
portfolio: BrokerPortfolio;
|
||||||
|
}) {
|
||||||
|
const basePath = `/broker/${encodeURIComponent(accountId)}`;
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
label: 'Акции',
|
||||||
|
count: portfolio.positionCounts.shares,
|
||||||
|
countLabel: pluralize(portfolio.positionCounts.shares, 'позиция', 'позиции', 'позиций'),
|
||||||
|
value: portfolio.totals.shares,
|
||||||
|
path: `${basePath}/shares`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Облигации',
|
||||||
|
count: portfolio.positionCounts.bonds,
|
||||||
|
countLabel: pluralize(portfolio.positionCounts.bonds, 'выпуск', 'выпуска', 'выпусков'),
|
||||||
|
value: portfolio.totals.bonds,
|
||||||
|
path: `${basePath}/bonds`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="broker-overview__assets" aria-label="Основные классы активов">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Link
|
||||||
|
className="broker-overview__card broker-overview__asset-link"
|
||||||
|
key={card.label}
|
||||||
|
to={card.path}
|
||||||
|
>
|
||||||
|
<strong className="broker-overview__asset-title">{card.label}</strong>
|
||||||
|
<span>
|
||||||
|
{card.count} {card.countLabel}
|
||||||
|
</span>
|
||||||
|
<span>{formatMoney(card.value)}</span>
|
||||||
|
<span>
|
||||||
|
{formatAllocationPercent(allocationPercent(card.value, portfolio.totals.portfolio))}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrokerOverviewSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="broker-overview" aria-label="Загрузка сводки счёта">
|
||||||
|
<div className="broker-overview__summary">
|
||||||
|
{[1, 2].map((item) => (
|
||||||
|
<div className="broker-overview__card" key={item}>
|
||||||
|
<SkeletonBlock height={16} width="45%" />
|
||||||
|
<SkeletonBlock height={28} width="70%" />
|
||||||
|
<SkeletonBlock height={16} width="55%" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="broker-allocation">
|
||||||
|
<SkeletonBlock height={160} width={160} borderRadius={80} />
|
||||||
|
<SkeletonBlock height={80} width="60%" />
|
||||||
|
</div>
|
||||||
|
<div className="broker-overview__assets">
|
||||||
|
{[1, 2].map((item) => (
|
||||||
|
<div className="broker-overview__card" key={item}>
|
||||||
|
<SkeletonBlock height={20} width="35%" />
|
||||||
|
<SkeletonBlock height={16} width="55%" />
|
||||||
|
<SkeletonBlock height={16} width="70%" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrokerAccountOverviewPage() {
|
||||||
|
const { accountId, portfolio } = useBrokerAccountContext();
|
||||||
|
const operations = useBrokerOperations(accountId, { limit: 5 });
|
||||||
|
|
||||||
|
if (portfolio.isLoading) return <BrokerOverviewSkeleton />;
|
||||||
|
if (portfolio.error || !portfolio.data) {
|
||||||
|
return <p role="alert">Не удалось загрузить сводку счёта</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="broker-overview">
|
||||||
|
<BrokerSummary portfolio={portfolio.data} />
|
||||||
|
<BrokerAllocationChart portfolio={portfolio.data} />
|
||||||
|
<BrokerAssetCards accountId={accountId} portfolio={portfolio.data} />
|
||||||
|
{operations.error ? (
|
||||||
|
<p role="alert">Не удалось загрузить последние операции</p>
|
||||||
|
) : (
|
||||||
|
<BrokerOperationsTable
|
||||||
|
title="Последние операции"
|
||||||
|
headerAction={
|
||||||
|
<Link to={`/broker/${encodeURIComponent(accountId)}/operations`}>Вся история</Link>
|
||||||
|
}
|
||||||
|
emptyMessage="Операций с начала текущего года нет"
|
||||||
|
isLoading={operations.isLoading}
|
||||||
|
isFetching={operations.isFetching}
|
||||||
|
page={operations.data}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/frontend/src/pages/broker/BrokerAllocationChart.tsx
Normal file
89
apps/frontend/src/pages/broker/BrokerAllocationChart.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import type { BrokerPortfolio } from '../../api/responses';
|
||||||
|
import { buildBrokerAllocation } from './brokerAllocation';
|
||||||
|
|
||||||
|
const RADIUS = 44;
|
||||||
|
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||||
|
|
||||||
|
function formatMoneyValue(value: number, currency: string) {
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function allocationCurrency(portfolio: BrokerPortfolio) {
|
||||||
|
return (
|
||||||
|
portfolio.totals.portfolio?.currency ||
|
||||||
|
Object.values(portfolio.totals).find((total) => total?.currency)?.currency ||
|
||||||
|
'RUB'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrokerAllocationChart({ portfolio }: { portfolio: BrokerPortfolio }) {
|
||||||
|
const { sectors, negative } = buildBrokerAllocation(portfolio);
|
||||||
|
const currency = allocationCurrency(portfolio);
|
||||||
|
let remaining = CIRCUMFERENCE;
|
||||||
|
const arcs = sectors.map((sector) => {
|
||||||
|
const dashOffset = -(CIRCUMFERENCE - remaining);
|
||||||
|
const rawDashLength = (sector.percent / 100) * CIRCUMFERENCE;
|
||||||
|
const dashLength = Math.min(Math.max(rawDashLength, 0), remaining);
|
||||||
|
remaining = Math.max(0, remaining - dashLength);
|
||||||
|
return { ...sector, dashOffset, dashLength };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure className="broker-allocation">
|
||||||
|
<svg role="img" aria-label="Структура брокерского портфеля" viewBox="0 0 120 120">
|
||||||
|
<title>Структура брокерского портфеля</title>
|
||||||
|
{arcs.map((sector) => (
|
||||||
|
<circle
|
||||||
|
key={sector.key}
|
||||||
|
cx="60"
|
||||||
|
cy="60"
|
||||||
|
r={RADIUS}
|
||||||
|
fill="none"
|
||||||
|
stroke={sector.color}
|
||||||
|
strokeWidth="14"
|
||||||
|
strokeDasharray={`${sector.dashLength} ${CIRCUMFERENCE - sector.dashLength}`}
|
||||||
|
strokeDashoffset={sector.dashOffset}
|
||||||
|
transform="rotate(-90 60 60)"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<figcaption>
|
||||||
|
{sectors.length === 0 ? (
|
||||||
|
<p>Нет данных для распределения</p>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{sectors.map((sector) => (
|
||||||
|
<li key={sector.key}>
|
||||||
|
<span
|
||||||
|
className="broker-allocation__swatch"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ background: sector.color }}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{sector.label}: {formatMoneyValue(sector.value, currency)} ·{' '}
|
||||||
|
{sector.percent.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{negative.length > 0 && (
|
||||||
|
<ul
|
||||||
|
className="broker-allocation__negative"
|
||||||
|
aria-label="Отрицательные значения распределения"
|
||||||
|
>
|
||||||
|
{negative.map((item) => (
|
||||||
|
<li key={item.key}>
|
||||||
|
{item.label}: отрицательное значение {formatMoneyValue(item.value, currency)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
apps/frontend/src/pages/broker/BrokerOperationsPage.tsx
Normal file
86
apps/frontend/src/pages/broker/BrokerOperationsPage.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { useBrokerAccountContext } from './BrokerAccountLayout';
|
||||||
|
import { useBrokerOperations } from '../../hooks/useBrokerOperations';
|
||||||
|
import { BrokerOperationsTable } from './BrokerOperationsTable';
|
||||||
|
import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType } from './brokerDisplay';
|
||||||
|
|
||||||
|
export function BrokerOperationsPage() {
|
||||||
|
const { accountId } = useBrokerAccountContext();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const urlType = searchParams.get('type');
|
||||||
|
const selectedType = isBrokerOperationType(urlType) ? urlType : '';
|
||||||
|
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||||
|
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
||||||
|
const operations = useBrokerOperations(accountId, {
|
||||||
|
limit: 10,
|
||||||
|
cursor,
|
||||||
|
operationTypes: selectedType || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCursor(undefined);
|
||||||
|
setCursorStack([]);
|
||||||
|
}, [selectedType]);
|
||||||
|
|
||||||
|
function handleTypeChange(event: React.ChangeEvent<HTMLSelectElement>) {
|
||||||
|
const nextType = event.target.value;
|
||||||
|
setSearchParams(nextType ? { type: nextType } : {}, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
const nextCursor = operations.data?.nextCursor;
|
||||||
|
if (!nextCursor || !operations.data?.hasNext) return;
|
||||||
|
setCursorStack((previous) => [...previous, cursor]);
|
||||||
|
setCursor(nextCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrevious() {
|
||||||
|
if (cursorStack.length === 0) return;
|
||||||
|
setCursor(cursorStack[cursorStack.length - 1]);
|
||||||
|
setCursorStack((previous) => previous.slice(0, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = operations.error ? (
|
||||||
|
<p role="alert">Не удалось загрузить историю операций</p>
|
||||||
|
) : (
|
||||||
|
<BrokerOperationsTable
|
||||||
|
title="История операций"
|
||||||
|
emptyMessage={
|
||||||
|
selectedType ? 'Операций выбранного типа нет' : 'Операций с начала текущего года нет'
|
||||||
|
}
|
||||||
|
isLoading={operations.isLoading}
|
||||||
|
isFetching={operations.isFetching}
|
||||||
|
page={operations.data}
|
||||||
|
pagination={{
|
||||||
|
pageNumber: cursorStack.length + 1,
|
||||||
|
canGoBack: cursorStack.length > 0,
|
||||||
|
canGoForward: Boolean(operations.data?.hasNext && operations.data.nextCursor),
|
||||||
|
onPrevious: handlePrevious,
|
||||||
|
onNext: handleNext,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-labelledby="broker-operations-heading">
|
||||||
|
<div className="broker-operations__toolbar">
|
||||||
|
<h2 id="broker-operations-heading" style={{ fontSize: 20, margin: 0 }}>
|
||||||
|
Операции
|
||||||
|
</h2>
|
||||||
|
<label>
|
||||||
|
<span>Тип операции</span>
|
||||||
|
<select value={selectedType} onChange={handleTypeChange}>
|
||||||
|
<option value="">Все операции</option>
|
||||||
|
{BROKER_OPERATION_TYPE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{history}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { 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,
|
||||||
@ -94,28 +95,23 @@ const pagButtonDisabledStyle = {
|
|||||||
} satisfies React.CSSProperties;
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
export function BrokerOperationsTable({
|
export function BrokerOperationsTable({
|
||||||
|
title,
|
||||||
|
headerAction,
|
||||||
|
emptyMessage,
|
||||||
isLoading,
|
isLoading,
|
||||||
isFetching,
|
isFetching,
|
||||||
page,
|
page,
|
||||||
pageNumber,
|
pagination,
|
||||||
canGoBack,
|
}: BrokerOperationsTableProps) {
|
||||||
canGoForward,
|
const pageNumber = pagination?.pageNumber;
|
||||||
onPrevious,
|
const canGoBack = pagination?.canGoBack ?? false;
|
||||||
onNext,
|
const canGoForward = pagination?.canGoForward ?? false;
|
||||||
}: {
|
const onPrevious = pagination?.onPrevious;
|
||||||
isLoading: boolean;
|
const onNext = pagination?.onNext;
|
||||||
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>
|
<section aria-busy={isFetching}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -125,10 +121,13 @@ export function BrokerOperationsTable({
|
|||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={{ fontSize: 20, margin: 0 }}>Операции</h2>
|
<h2 style={{ fontSize: 20, margin: 0 }}>{title}</h2>
|
||||||
|
{headerAction}
|
||||||
|
{pagination && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<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}
|
||||||
@ -155,6 +154,7 @@ 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,6 +169,7 @@ export function BrokerOperationsTable({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@ -197,7 +198,7 @@ export function BrokerOperationsTable({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
) : operations.length === 0 && !isFetching ? (
|
) : operations.length === 0 && !isFetching ? (
|
||||||
<p style={{ color: 'var(--color-text-secondary)' }}>Операций за выбранный период нет</p>
|
<p style={{ color: 'var(--color-text-secondary)' }}>{emptyMessage}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-container">
|
<div className="table-container">
|
||||||
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
<div style={{ overflowX: 'auto', background: 'var(--color-surface)' }}>
|
||||||
@ -243,10 +244,10 @@ export function BrokerOperationsTable({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{isFetching && (
|
{isFetching && (
|
||||||
<div className="table-loading-overlay">
|
<div className="table-loading-overlay" role="status">
|
||||||
<div className="loading-spinner" />
|
<div className="loading-spinner" aria-hidden="true" />
|
||||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||||
Загрузка страницы {pageNumber}…
|
{pagination ? `Загрузка страницы ${pageNumber}…` : 'Обновление операций…'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -255,3 +256,19 @@ 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,24 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import type { BrokerMoney, BrokerPosition } from '../../api/responses';
|
import type { BrokerMoney, BrokerPosition, BrokerPositionsPage } from '../../api/responses';
|
||||||
import { getBrokerInstrumentPath } from './brokerDisplay';
|
import { 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%',
|
||||||
@ -89,50 +75,31 @@ function PositionTicker({ position }: { position: BrokerPosition }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PositionGroupTable({
|
function BrokerPositionTable({
|
||||||
accountId,
|
title,
|
||||||
group,
|
page,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
emptyMessage,
|
||||||
|
pageNumber,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
}: {
|
}: {
|
||||||
accountId: string;
|
title: string;
|
||||||
group: BrokerPositionGroupConfig;
|
page: BrokerPositionsPage | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
emptyMessage: string;
|
||||||
|
pageNumber: number;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrevious: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
const positions = page?.items ?? [];
|
||||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
const canGoBack = pageNumber > 1;
|
||||||
|
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>
|
<section aria-labelledby={`broker-${title.toLowerCase()}-heading`}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -142,12 +109,14 @@ function PositionGroupTable({
|
|||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3 style={{ fontSize: 18, margin: 0 }}>{group.title}</h3>
|
<h2 id={`broker-${title.toLowerCase()}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
||||||
{group.type && (
|
{title}
|
||||||
|
</h2>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePrevious}
|
aria-label="Предыдущая страница"
|
||||||
|
onClick={onPrevious}
|
||||||
disabled={!canGoBack || isFetching}
|
disabled={!canGoBack || isFetching}
|
||||||
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
style={canGoBack && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
>
|
>
|
||||||
@ -173,7 +142,8 @@ function PositionGroupTable({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleNext}
|
aria-label="Следующая страница"
|
||||||
|
onClick={onNext}
|
||||||
disabled={!canGoForward || isFetching}
|
disabled={!canGoForward || isFetching}
|
||||||
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
style={canGoForward && !isFetching ? pagButtonStyle : pagButtonDisabledStyle}
|
||||||
>
|
>
|
||||||
@ -187,10 +157,9 @@ function PositionGroupTable({
|
|||||||
)}
|
)}
|
||||||
</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>
|
||||||
@ -224,12 +193,12 @@ function PositionGroupTable({
|
|||||||
/>
|
/>
|
||||||
</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={`Брокерские позиции: ${group.title}`} style={tableStyle}>
|
<table aria-label={`Брокерские позиции: ${title}`} style={tableStyle}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th align="left" style={thStyle}>
|
<th align="left" style={thStyle}>
|
||||||
@ -295,17 +264,53 @@ function PositionGroupTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type BrokerPositionsSectionProps = {
|
type BrokerPositionsPageProps = {
|
||||||
accountId: string;
|
type: 'share' | 'bond';
|
||||||
|
title: 'Акции' | 'Облигации';
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BrokerPositionsSection({ accountId }: BrokerPositionsSectionProps) {
|
export function BrokerPositionsPage({ type, title }: BrokerPositionsPageProps) {
|
||||||
|
const { accountId } = useBrokerAccountContext();
|
||||||
|
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||||
|
const [cursorStack, setCursorStack] = useState<Array<string | undefined>>([]);
|
||||||
|
const positions = useBrokerPositions(accountId, { type, limit: 10, cursor });
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
const nextCursor = positions.data?.nextCursor;
|
||||||
|
if (!nextCursor || !positions.data?.hasNext) return;
|
||||||
|
setCursorStack((previous) => [...previous, cursor]);
|
||||||
|
setCursor(nextCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrevious() {
|
||||||
|
if (cursorStack.length === 0) return;
|
||||||
|
setCursor(cursorStack[cursorStack.length - 1]);
|
||||||
|
setCursorStack((previous) => previous.slice(0, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (positions.error) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gap: 20 }}>
|
<section aria-labelledby={`broker-${type}-heading`}>
|
||||||
<h2 style={{ fontSize: 20, margin: 0 }}>Позиции</h2>
|
<h2 id={`broker-${type}-heading`} style={{ fontSize: 20, margin: 0 }}>
|
||||||
{GROUPS.map((group) => (
|
{title}
|
||||||
<PositionGroupTable key={group.key} accountId={accountId} group={group} />
|
</h2>
|
||||||
))}
|
<p role="alert">
|
||||||
</div>
|
{type === 'share' ? 'Не удалось загрузить акции' : 'Не удалось загрузить облигации'}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrokerPositionTable
|
||||||
|
title={title}
|
||||||
|
page={positions.data}
|
||||||
|
isLoading={positions.isLoading}
|
||||||
|
isFetching={positions.isFetching}
|
||||||
|
emptyMessage={type === 'share' ? 'На счёте нет акций' : 'На счёте нет облигаций'}
|
||||||
|
pageNumber={cursorStack.length + 1}
|
||||||
|
onNext={handleNext}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
144
apps/frontend/src/pages/broker/brokerAllocation.test.ts
Normal file
144
apps/frontend/src/pages/broker/brokerAllocation.test.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { BrokerMoney, BrokerPortfolio } from '../../api/responses';
|
||||||
|
import { buildBrokerAllocation } from './brokerAllocation';
|
||||||
|
|
||||||
|
function money(value: number): BrokerMoney {
|
||||||
|
return {
|
||||||
|
currency: 'RUB',
|
||||||
|
units: String(Math.trunc(value)),
|
||||||
|
nano: 0,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function portfolio(
|
||||||
|
values: Partial<Record<'shares' | 'bonds' | 'etf' | 'currencies' | 'portfolio', number | null>>,
|
||||||
|
): BrokerPortfolio {
|
||||||
|
const total = (key: keyof typeof values): BrokerMoney | null => {
|
||||||
|
const value = values[key];
|
||||||
|
return value == null ? null : money(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
account: {
|
||||||
|
id: 'acc-1',
|
||||||
|
type: 'brokerage',
|
||||||
|
name: 'Основной',
|
||||||
|
status: 'open',
|
||||||
|
openedAt: '2024-01-01T00:00:00.000Z',
|
||||||
|
accessLevel: 'full_access',
|
||||||
|
},
|
||||||
|
positionCounts: {
|
||||||
|
shares: 1,
|
||||||
|
bonds: 1,
|
||||||
|
etf: 1,
|
||||||
|
other: 0,
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
shares: total('shares'),
|
||||||
|
bonds: total('bonds'),
|
||||||
|
etf: total('etf'),
|
||||||
|
currencies: total('currencies'),
|
||||||
|
futures: null,
|
||||||
|
options: null,
|
||||||
|
structuredProducts: null,
|
||||||
|
dfa: null,
|
||||||
|
portfolio: total('portfolio'),
|
||||||
|
},
|
||||||
|
yields: {
|
||||||
|
expectedPercent: null,
|
||||||
|
daily: null,
|
||||||
|
dailyPercent: null,
|
||||||
|
},
|
||||||
|
cash: [],
|
||||||
|
blockedCash: [],
|
||||||
|
asOf: '2025-01-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildBrokerAllocation', () => {
|
||||||
|
it('builds allocation sectors in display order', () => {
|
||||||
|
expect(
|
||||||
|
buildBrokerAllocation(
|
||||||
|
portfolio({ shares: 400, bonds: 300, etf: 100, currencies: 150, portfolio: 1000 }),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
total: 1000,
|
||||||
|
sectors: [
|
||||||
|
{ key: 'shares', label: 'Акции', value: 400, percent: 40, color: '#4969f5' },
|
||||||
|
{ key: 'bonds', label: 'Облигации', value: 300, percent: 30, color: '#e5a33c' },
|
||||||
|
{ key: 'etf', label: 'ETF/фонды', value: 100, percent: 10, color: '#62b889' },
|
||||||
|
{ key: 'cash', label: 'Деньги', value: 150, percent: 15, color: '#7b63cf' },
|
||||||
|
{ key: 'other', label: 'Прочие', value: 50, percent: 5, color: '#aeb6c5' },
|
||||||
|
],
|
||||||
|
negative: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits zero-value sectors', () => {
|
||||||
|
const result = buildBrokerAllocation(
|
||||||
|
portfolio({ shares: 600, bonds: 0, etf: null, currencies: 400, portfolio: 1000 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'cash']);
|
||||||
|
expect(result.negative).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a negative residual outside the sectors', () => {
|
||||||
|
const result = buildBrokerAllocation(
|
||||||
|
portfolio({ shares: 700, bonds: 300, etf: 100, currencies: 50, portfolio: 1000 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds', 'etf', 'cash']);
|
||||||
|
expect(result.negative).toEqual([
|
||||||
|
{ key: 'other', label: 'Прочие', value: -150, color: '#aeb6c5' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a tiny negative residual caused by decimal arithmetic', () => {
|
||||||
|
const result = buildBrokerAllocation(portfolio({ shares: 0.1, bonds: 0.2, portfolio: 0.3 }));
|
||||||
|
|
||||||
|
expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds']);
|
||||||
|
expect(result.negative).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a tiny positive residual caused by decimal arithmetic', () => {
|
||||||
|
const result = buildBrokerAllocation(
|
||||||
|
portfolio({ shares: 0.3, portfolio: 0.30000000000000004 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.sectors.map(({ key }) => key)).toEqual(['shares']);
|
||||||
|
expect(result.negative).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no allocation for missing or nonpositive portfolio totals', () => {
|
||||||
|
expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: null }))).toEqual({
|
||||||
|
total: 0,
|
||||||
|
sectors: [],
|
||||||
|
negative: [],
|
||||||
|
});
|
||||||
|
expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: 0 }))).toEqual({
|
||||||
|
total: 0,
|
||||||
|
sectors: [],
|
||||||
|
negative: [],
|
||||||
|
});
|
||||||
|
expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: -10 }))).toEqual({
|
||||||
|
total: -10,
|
||||||
|
sectors: [],
|
||||||
|
negative: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves named negative components when the portfolio total is nonpositive', () => {
|
||||||
|
expect(buildBrokerAllocation(portfolio({ shares: 100, bonds: -20, portfolio: 0 }))).toEqual({
|
||||||
|
total: 0,
|
||||||
|
sectors: [],
|
||||||
|
negative: [{ key: 'bonds', label: 'Облигации', value: -20, color: '#e5a33c' }],
|
||||||
|
});
|
||||||
|
expect(buildBrokerAllocation(portfolio({ currencies: -30, etf: 5, portfolio: -10 }))).toEqual({
|
||||||
|
total: -10,
|
||||||
|
sectors: [],
|
||||||
|
negative: [{ key: 'cash', label: 'Деньги', value: -30, color: '#7b63cf' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
85
apps/frontend/src/pages/broker/brokerAllocation.ts
Normal file
85
apps/frontend/src/pages/broker/brokerAllocation.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import type { BrokerPortfolio } from '../../api/responses';
|
||||||
|
|
||||||
|
export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other';
|
||||||
|
|
||||||
|
export interface BrokerAllocationItem {
|
||||||
|
key: BrokerAllocationKey;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
percent: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrokerNegativeAllocationItem = Omit<BrokerAllocationItem, 'percent'>;
|
||||||
|
|
||||||
|
const ALLOCATION_CONFIG: Array<Pick<BrokerAllocationItem, 'key' | 'label' | 'color'>> = [
|
||||||
|
{ key: 'shares', label: 'Акции', color: '#4969f5' },
|
||||||
|
{ key: 'bonds', label: 'Облигации', color: '#e5a33c' },
|
||||||
|
{ key: 'etf', label: 'ETF/фонды', color: '#62b889' },
|
||||||
|
{ key: 'cash', label: 'Деньги', color: '#7b63cf' },
|
||||||
|
{ key: 'other', label: 'Прочие', color: '#aeb6c5' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildBrokerAllocation(portfolio: BrokerPortfolio): {
|
||||||
|
total: number;
|
||||||
|
sectors: BrokerAllocationItem[];
|
||||||
|
negative: BrokerNegativeAllocationItem[];
|
||||||
|
} {
|
||||||
|
const total = portfolio.totals.portfolio?.value ?? 0;
|
||||||
|
const shares = portfolio.totals.shares?.value ?? 0;
|
||||||
|
const bonds = portfolio.totals.bonds?.value ?? 0;
|
||||||
|
const etf = portfolio.totals.etf?.value ?? 0;
|
||||||
|
const cash = portfolio.totals.currencies?.value ?? 0;
|
||||||
|
const namedValues: Record<Exclude<BrokerAllocationKey, 'other'>, number> = {
|
||||||
|
shares,
|
||||||
|
bonds,
|
||||||
|
etf,
|
||||||
|
cash,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (total <= 0) {
|
||||||
|
const negative = ALLOCATION_CONFIG.filter(
|
||||||
|
(
|
||||||
|
item,
|
||||||
|
): item is (typeof ALLOCATION_CONFIG)[number] & {
|
||||||
|
key: Exclude<BrokerAllocationKey, 'other'>;
|
||||||
|
} => item.key !== 'other',
|
||||||
|
)
|
||||||
|
.filter((item) => namedValues[item.key] < 0)
|
||||||
|
.map((item) => ({ ...item, value: namedValues[item.key] }));
|
||||||
|
return { total, sectors: [], negative };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedTotal = shares + bonds + etf + cash;
|
||||||
|
const residual = total - mappedTotal;
|
||||||
|
const residualTolerance =
|
||||||
|
Number.EPSILON *
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
Math.abs(total),
|
||||||
|
Math.abs(shares) + Math.abs(bonds) + Math.abs(etf) + Math.abs(cash),
|
||||||
|
) *
|
||||||
|
8;
|
||||||
|
const values: Record<BrokerAllocationKey, number> = {
|
||||||
|
shares,
|
||||||
|
bonds,
|
||||||
|
etf,
|
||||||
|
cash,
|
||||||
|
other: Math.abs(residual) <= residualTolerance ? 0 : residual,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectors: BrokerAllocationItem[] = [];
|
||||||
|
const negative: BrokerNegativeAllocationItem[] = [];
|
||||||
|
|
||||||
|
for (const item of ALLOCATION_CONFIG) {
|
||||||
|
const value = values[item.key];
|
||||||
|
|
||||||
|
if (value > 0) {
|
||||||
|
sectors.push({ ...item, value, percent: (value / total) * 100 });
|
||||||
|
} else if (value < 0) {
|
||||||
|
negative.push({ ...item, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, sectors, negative };
|
||||||
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { 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 {
|
||||||
@ -37,6 +39,7 @@ 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,
|
||||||
@ -116,6 +119,37 @@ 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,6 +94,20 @@ 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,7 +11,10 @@ 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 { BrokerAccountDetailPage } from './pages/broker/BrokerAccountDetailPage';
|
import { BrokerAccountLayout } from './pages/broker/BrokerAccountLayout';
|
||||||
|
import { BrokerAccountOverviewPage } from './pages/broker/BrokerAccountOverviewPage';
|
||||||
|
import { BrokerPositionsPage } from './pages/broker/BrokerPositionsPage';
|
||||||
|
import { BrokerOperationsPage } from './pages/broker/BrokerOperationsPage';
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
@ -59,10 +62,15 @@ export function AppRoutes() {
|
|||||||
path="/broker/:accountId"
|
path="/broker/:accountId"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<BrokerAccountDetailPage />
|
<BrokerAccountLayout />
|
||||||
</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,9 +18,15 @@
|
|||||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pnl-cell { text-align: right; }
|
.pnl-cell {
|
||||||
.positive { color: var(--color-positive); }
|
text-align: right;
|
||||||
.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;
|
||||||
@ -35,24 +41,25 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { background-position: 200% 0; }
|
0% {
|
||||||
100% { background-position: -200% 0; }
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton {
|
.skeleton {
|
||||||
background: linear-gradient(
|
background: linear-gradient(90deg, var(--color-bg) 25%, #f0f0f0 50%, var(--color-bg) 75%);
|
||||||
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 { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
@ -80,3 +87,212 @@ 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,8 +1,38 @@
|
|||||||
Портфель брокера
|
# Портфель брокера
|
||||||
|
|
||||||
Features:
|
Статус: активный
|
||||||
- broker-operations-ui-improvements
|
|
||||||
- broker-portfolio-display
|
## Цель
|
||||||
- broker-positions-pagination-and-loading
|
|
||||||
- tbank-broker-portfolios
|
Дать пользователю целостный доступ к реальным брокерским счетам T-Bank: от подключения и получения
|
||||||
- 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)
|
||||||
|
|
||||||
|
## Критерий завершения эпика
|
||||||
|
|
||||||
|
Пользователь может последовательно пройти от списка счетов к обзору выбранного счёта, отдельным
|
||||||
|
классам позиций и доступной истории операций; интерфейс корректно обрабатывает загрузку, пустые
|
||||||
|
данные и ошибки внешней интеграции.
|
||||||
|
|||||||
1305
docs/features/broker-account-sections/plan.md
Normal file
1305
docs/features/broker-account-sections/plan.md
Normal file
File diff suppressed because it is too large
Load Diff
166
docs/features/broker-account-sections/spec.md
Normal file
166
docs/features/broker-account-sections/spec.md
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
# Разделы брокерского счёта
|
||||||
|
|
||||||
|
Дата: 2026-06-18
|
||||||
|
Статус: реализовано
|
||||||
|
Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md)
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Сделать страницу конкретного брокерского счёта кратким и понятным обзором, а подробные позиции и
|
||||||
|
операции разнести по самостоятельным разделам, не теряя контекст выбранного счёта.
|
||||||
|
|
||||||
|
## Пользовательский результат
|
||||||
|
|
||||||
|
Пользователь может:
|
||||||
|
|
||||||
|
- быстро увидеть полную стоимость счёта, денежный остаток и текущую структуру активов;
|
||||||
|
- понять, сколько разных акций и выпусков облигаций находится на счёте;
|
||||||
|
- перейти к отдельной таблице акций или облигаций;
|
||||||
|
- увидеть последние операции на overview и открыть всю доступную историю;
|
||||||
|
- отфильтровать историю по одному точному типу операции, например только по выплатам купонов или
|
||||||
|
только по обычному налогу.
|
||||||
|
|
||||||
|
## Область изменений
|
||||||
|
|
||||||
|
Фича изменяет опыт работы с конкретным брокерским счётом и включает:
|
||||||
|
|
||||||
|
- обзор счёта;
|
||||||
|
- общую навигацию разделов счёта;
|
||||||
|
- отдельный раздел акций;
|
||||||
|
- отдельный раздел облигаций;
|
||||||
|
- отдельный раздел операций;
|
||||||
|
- данные сводки, необходимые для точных счётчиков позиций.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
### 1. Общая навигация
|
||||||
|
|
||||||
|
- Разделы `Обзор`, `Акции`, `Облигации` и `Операции` принадлежат одному выбранному брокерскому счёту.
|
||||||
|
- На широком экране разделы доступны через постоянное боковое меню.
|
||||||
|
- На узком экране боковое меню заменяется горизонтальными прокручиваемыми вкладками.
|
||||||
|
- Активный раздел визуально и семантически обозначен.
|
||||||
|
- Переход между разделами не меняет выбранный брокерский счёт.
|
||||||
|
|
||||||
|
### 2. Обзор счёта
|
||||||
|
|
||||||
|
Overview показывает:
|
||||||
|
|
||||||
|
- название счёта;
|
||||||
|
- полную стоимость портфеля;
|
||||||
|
- денежный остаток;
|
||||||
|
- существующие показатели дневной и ожидаемой доходности;
|
||||||
|
- круговую диаграмму структуры портфеля;
|
||||||
|
- карточку акций с количеством разных позиций, стоимостью и долей;
|
||||||
|
- карточку облигаций с количеством разных выпусков, стоимостью и долей;
|
||||||
|
- пять последних доступных операций;
|
||||||
|
- переход ко всей доступной истории операций.
|
||||||
|
|
||||||
|
Карточки акций и облигаций ведут в соответствующие разделы счёта.
|
||||||
|
|
||||||
|
### 3. Распределение портфеля
|
||||||
|
|
||||||
|
- Проценты рассчитываются от полной стоимости счёта, включая денежный остаток.
|
||||||
|
- Диаграмма поддерживает срезы `Акции`, `Облигации`, `ETF/фонды`, `Деньги` и `Прочие`.
|
||||||
|
- `Прочие` объединяет остальные и неизвестные классы инструментов.
|
||||||
|
- Срез с нулевым значением не отображается.
|
||||||
|
- Отрицательное значение не отображается как сектор диаграммы и показывается текстом рядом со
|
||||||
|
сводкой.
|
||||||
|
- Легенда показывает название среза, денежную стоимость и процент.
|
||||||
|
- Информация остаётся понятной без различения цветов.
|
||||||
|
|
||||||
|
### 4. Счётчики позиций
|
||||||
|
|
||||||
|
- Количество акций означает число разных позиций акций, а не сумму штук всех акций.
|
||||||
|
- Количество облигаций означает число разных выпусков облигаций, а не сумму штук всех облигаций.
|
||||||
|
- Счётчики отражают полный состав счёта и не зависят от текущей страницы таблицы.
|
||||||
|
|
||||||
|
### 5. Раздел акций
|
||||||
|
|
||||||
|
- Раздел показывает только позиции типа `share`.
|
||||||
|
- Первая версия содержит таблицу с существующими колонками: тикер, название, количество, текущая
|
||||||
|
цена и текущая стоимость.
|
||||||
|
- Тикер ведёт на страницу акции, когда маршрут инструмента может быть определён.
|
||||||
|
- Таблица сохраняет cursor-пагинацию по 10 позиций.
|
||||||
|
- При отсутствии акций показывается отдельное пустое состояние.
|
||||||
|
|
||||||
|
### 6. Раздел облигаций
|
||||||
|
|
||||||
|
- Раздел показывает только позиции типа `bond`.
|
||||||
|
- Первая версия содержит таблицу с существующими колонками: тикер, название, количество, текущая
|
||||||
|
цена и текущая стоимость.
|
||||||
|
- Тикер ведёт на страницу облигации, когда маршрут инструмента может быть определён.
|
||||||
|
- Таблица сохраняет cursor-пагинацию по 10 позиций.
|
||||||
|
- При отсутствии облигаций показывается отдельное пустое состояние.
|
||||||
|
|
||||||
|
### 7. Последние операции на overview
|
||||||
|
|
||||||
|
- Overview показывает не более пяти последних операций доступного по умолчанию периода.
|
||||||
|
- Отображение операции сохраняет дату, русское название типа, инструмент и сумму.
|
||||||
|
- Блок содержит переход к полному разделу операций.
|
||||||
|
- Если операций нет, блок показывает спокойное пустое состояние и сохраняет переход к полной
|
||||||
|
истории.
|
||||||
|
|
||||||
|
### 8. Раздел операций
|
||||||
|
|
||||||
|
- Раздел показывает cursor-пагинированную историю по 10 операций.
|
||||||
|
- Без отдельного фильтра дат раздел использует существующий период по умолчанию: с начала текущего
|
||||||
|
календарного года до текущего момента.
|
||||||
|
- Под всей доступной историей в рамках первой версии понимаются все cursor-страницы этого периода,
|
||||||
|
а не операции за всё время существования счёта.
|
||||||
|
- Пользователь может выбрать ровно один точный тип операции либо значение `Все операции`.
|
||||||
|
- Типы с разным финансовым смыслом не объединяются. В частности, `Налог`, `Налог по облигациям` и
|
||||||
|
`Налог на дивиденды` являются отдельными значениями.
|
||||||
|
- В интерфейсе показываются русские названия известных типов, а не технические enum-значения.
|
||||||
|
- Выбор нового типа начинает просмотр результатов с первой cursor-страницы.
|
||||||
|
- Выбранный фильтр сохраняется в адресе страницы и восстанавливается при открытии ссылки.
|
||||||
|
- Значение `Все операции` удаляет фильтр типа.
|
||||||
|
- Фильтр по датам, инструменту и выбор нескольких типов не входят в первую версию.
|
||||||
|
|
||||||
|
### 9. Загрузка, ошибки и пустые состояния
|
||||||
|
|
||||||
|
- Первичная загрузка сводки и таблиц показывает skeleton соответствующей формы.
|
||||||
|
- При переходе между cursor-страницами текущие строки не исчезают; поверх таблицы показывается
|
||||||
|
состояние обновления.
|
||||||
|
- Ошибка одного раздела не скрывает общую навигацию счёта.
|
||||||
|
- Для пустых акций, пустых облигаций и отсутствия операций выбранного типа используются отдельные
|
||||||
|
понятные сообщения.
|
||||||
|
- Недоступные отдельные значения отображаются как `—` и не подменяются нулём.
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
- Backend остаётся единственным клиентом T-Bank.
|
||||||
|
- Денежные значения форматируются в валюте, указанной в данных.
|
||||||
|
- Точная структура портфеля и счётчики не должны требовать загрузки всех cursor-страниц на frontend.
|
||||||
|
- Существующая cursor-пагинация позиций и операций сохраняется.
|
||||||
|
- Фича не добавляет отдельные страницы для ETF, фондов и прочих инструментов.
|
||||||
|
- Фича не добавляет новую аналитику доходности, риска или прогнозов.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- На широком экране у счёта есть боковая навигация `Обзор`, `Акции`, `Облигации`, `Операции`.
|
||||||
|
- На узком экране те же разделы доступны через горизонтальные прокручиваемые вкладки.
|
||||||
|
- Overview не содержит полных таблиц позиций и полной истории операций.
|
||||||
|
- Overview показывает полную стоимость, деньги, существующую доходность, диаграмму, карточки акций
|
||||||
|
и облигаций и не более пяти последних операций.
|
||||||
|
- Диаграмма рассчитывает доли от полной стоимости счёта вместе с деньгами.
|
||||||
|
- Диаграмма имеет текстовую легенду со стоимостью и процентом каждого ненулевого среза.
|
||||||
|
- Карточки акций и облигаций показывают количество разных позиций, а не сумму штук.
|
||||||
|
- Количество позиций корректно для портфеля, содержащего больше одной cursor-страницы.
|
||||||
|
- Раздел акций содержит только акции и сохраняет пагинацию по 10 строк.
|
||||||
|
- Раздел облигаций содержит только облигации и сохраняет пагинацию по 10 строк.
|
||||||
|
- Раздел операций содержит пагинацию по 10 строк и фильтр по одному точному типу.
|
||||||
|
- Без параметров дат раздел операций показывает доступный период с начала текущего календарного года.
|
||||||
|
- Фильтры `Выплата купона`, `Налог`, `Налог по облигациям` и `Налог на дивиденды` дают независимые
|
||||||
|
результаты.
|
||||||
|
- После смены типа операций открывается первая страница отфильтрованной истории.
|
||||||
|
- Выбранный тип восстанавливается из URL после перезагрузки.
|
||||||
|
- Загрузка, обновление, ошибка и пустое состояние каждого раздела отображаются согласно требованиям.
|
||||||
|
|
||||||
|
## Вне области фичи
|
||||||
|
|
||||||
|
- страницы ETF, фондов, валют, деривативов и прочих инструментов;
|
||||||
|
- multi-select типов операций;
|
||||||
|
- продуктовые группы операций;
|
||||||
|
- UI-фильтры операций по датам и инструментам;
|
||||||
|
- изменение правил расчёта доходности;
|
||||||
|
- экспорт позиций или операций.
|
||||||
51
docs/features/broker-account-sections/tasks.md
Normal file
51
docs/features/broker-account-sections/tasks.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
# Разделы брокерского счёта — задачи
|
||||||
|
|
||||||
|
Дата: 2026-06-18
|
||||||
|
Статус: реализовано
|
||||||
|
|
||||||
|
Связанные документы:
|
||||||
|
|
||||||
|
- [Epic](../../epics/BrokerPortfolio.md)
|
||||||
|
- [Spec](spec.md)
|
||||||
|
- [Plan](plan.md)
|
||||||
|
- [Research](../../research/2026-06-18-broker-account-sections.md)
|
||||||
|
|
||||||
|
## Backend и API
|
||||||
|
|
||||||
|
- [x] Получить отдельное утверждение `plan.md` и `tasks.md` и обновить SDD-статусы до начала кода.
|
||||||
|
- [x] Добавить в broker portfolio contract счётчики `shares`, `bonds`, `etf`, `other`.
|
||||||
|
- [x] Покрыть классификацию позиций mapper- и service-тестами.
|
||||||
|
- [x] Обновить Swagger DTO и сгенерировать актуальные frontend OpenAPI types.
|
||||||
|
- [x] Синхронизировать handwritten `BrokerPortfolio` response type.
|
||||||
|
|
||||||
|
## Чистые frontend-модели
|
||||||
|
|
||||||
|
- [x] Добавить и протестировать расчёт секторов полного брокерского портфеля.
|
||||||
|
- [x] Обработать нулевые, отрицательные и отсутствующие значения диаграммы.
|
||||||
|
- [x] Экспортировать и протестировать список точных типов операций для select.
|
||||||
|
|
||||||
|
## Навигация и overview
|
||||||
|
|
||||||
|
- [x] Создать nested-route shell брокерского счёта.
|
||||||
|
- [x] Добавить desktop sidebar и mobile horizontal tabs с активным состоянием.
|
||||||
|
- [x] Создать overview со стоимостью, деньгами и существующей доходностью.
|
||||||
|
- [x] Добавить доступную диаграмму, легенду и предупреждения об отрицательных значениях.
|
||||||
|
- [x] Добавить кликабельные карточки акций и облигаций с точными счётчиками.
|
||||||
|
- [x] Показывать на overview последние пять операций и ссылку на историю.
|
||||||
|
|
||||||
|
## Отдельные страницы
|
||||||
|
|
||||||
|
- [x] Создать страницу акций с cursor-пагинацией по 10 строк.
|
||||||
|
- [x] Создать страницу облигаций с cursor-пагинацией по 10 строк.
|
||||||
|
- [x] Создать страницу операций с cursor-пагинацией по 10 строк.
|
||||||
|
- [x] Добавить single-select точного operation type, синхронизацию URL и сброс cursor.
|
||||||
|
- [x] Удалить старую общую detail page и grouped positions section после переноса поведения.
|
||||||
|
|
||||||
|
## Состояния и качество
|
||||||
|
|
||||||
|
- [x] Покрыть shell, overview, asset pages и operations page компонентными тестами.
|
||||||
|
- [x] Проверить skeleton, loading overlay, локальные ошибки и отдельные empty states.
|
||||||
|
- [x] Выполнить backend/frontend tests, builds и lint.
|
||||||
|
- [ ] Проверить четыре маршрута в локальном браузере на desktop и mobile ширине.
|
||||||
|
- [x] Провести code review и устранить блокирующие замечания.
|
||||||
|
- [x] Обновить статусы spec, epic, roadmap и этого списка только после полной проверки.
|
||||||
122
docs/research/2026-06-18-broker-account-sections.md
Normal file
122
docs/research/2026-06-18-broker-account-sections.md
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
# Исследование структуры страницы брокерского счёта
|
||||||
|
|
||||||
|
Дата: 2026-06-18
|
||||||
|
|
||||||
|
## Цель исследования
|
||||||
|
|
||||||
|
Определить, как упростить страницу конкретного брокерского счёта, разделить обзор и подробные
|
||||||
|
данные по классам инструментов, а также добавить понятную фильтрацию истории операций.
|
||||||
|
|
||||||
|
## Текущее состояние
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- Маршрут `/broker/:accountId` открывает `BrokerAccountDetailPage`.
|
||||||
|
- На одной странице находятся сводка, отдельные секции позиций и полная таблица операций.
|
||||||
|
- `BrokerPositionsSection` загружает акции, облигации, ETF и фонды отдельными запросами и показывает
|
||||||
|
cursor-пагинацию.
|
||||||
|
- `BrokerOperationsTable` показывает 10 операций, русские названия известных типов и
|
||||||
|
cursor-пагинацию.
|
||||||
|
- Для известных типов инструментов уже существуют переходы на `/stocks/:ticker` и
|
||||||
|
`/bonds/:ticker`.
|
||||||
|
- В `brokerDisplay.ts` уже есть отображаемые названия известных T-Bank operation types и правила
|
||||||
|
визуальной классификации операций.
|
||||||
|
|
||||||
|
### Backend и контракты
|
||||||
|
|
||||||
|
- `GET /api/v1/broker/accounts/:accountId/portfolio` возвращает итоговую стоимость, суммы по классам
|
||||||
|
активов, доходность, деньги и заблокированные деньги.
|
||||||
|
- Ответ portfolio не содержит точного количества разных позиций по классам.
|
||||||
|
- `GET /api/v1/broker/accounts/:accountId/positions` поддерживает `type`, `cursor` и `limit`, но не
|
||||||
|
возвращает общее количество элементов.
|
||||||
|
- `GET /api/v1/broker/accounts/:accountId/operations` поддерживает `operationTypes`, `cursor`,
|
||||||
|
`limit`, `from`, `to`, `instrumentId` и `state`.
|
||||||
|
- `operationTypes` уже преобразуется backend в список точных T-Bank enum-значений.
|
||||||
|
- Если период операций не указан, backend использует период от начала текущего календарного года
|
||||||
|
до текущего момента.
|
||||||
|
|
||||||
|
## Выявленное ограничение
|
||||||
|
|
||||||
|
Точный счётчик разных акций и выпусков облигаций нельзя эффективно получить из текущего frontend-
|
||||||
|
контракта: endpoint позиций пагинирован и не возвращает total. Загрузка всех cursor-страниц ради
|
||||||
|
двух счётчиков увеличит число запросов и свяжет overview с размером портфеля.
|
||||||
|
|
||||||
|
Рекомендуемое техническое направление для последующего plan.md — дополнить существующий portfolio
|
||||||
|
summary счётчиками разных позиций по классам. Новый summary endpoint не нужен, потому что он будет
|
||||||
|
дублировать назначение существующего portfolio endpoint.
|
||||||
|
|
||||||
|
## Рассмотренные продуктовые варианты
|
||||||
|
|
||||||
|
### Размещение операций
|
||||||
|
|
||||||
|
1. Полная история на overview.
|
||||||
|
2. Только отдельная страница операций.
|
||||||
|
3. Гибрид: последние операции на overview и вся доступная история отдельно.
|
||||||
|
|
||||||
|
Выбран вариант 3. Он сохраняет полезный контекст на overview и не превращает сводку в длинную
|
||||||
|
рабочую таблицу.
|
||||||
|
|
||||||
|
### Область круговой диаграммы
|
||||||
|
|
||||||
|
1. Только инструменты.
|
||||||
|
2. Весь портфель вместе с денежным остатком.
|
||||||
|
|
||||||
|
Выбран вариант 2. Он показывает реальную долю свободных денег и не создаёт впечатление, что весь
|
||||||
|
счёт инвестирован.
|
||||||
|
|
||||||
|
### Семантика количества
|
||||||
|
|
||||||
|
1. Количество разных позиций.
|
||||||
|
2. Сумма штук всех бумаг класса.
|
||||||
|
|
||||||
|
Выбран вариант 1. Складывать штуки разных акций или облигаций малоинформативно. Рядом со счётчиком
|
||||||
|
должны оставаться стоимость класса и его доля в портфеле.
|
||||||
|
|
||||||
|
### Фильтр операций
|
||||||
|
|
||||||
|
1. Один точный тип операции.
|
||||||
|
2. Несколько точных типов одновременно.
|
||||||
|
3. Семантические группы, например «Все налоги».
|
||||||
|
|
||||||
|
Выбран вариант 1. Значения «Налог», «Налог по облигациям» и «Налог на дивиденды» остаются разными
|
||||||
|
фильтрами. Multi-select и группировка категорий не входят в первую версию.
|
||||||
|
|
||||||
|
### Навигация
|
||||||
|
|
||||||
|
Рассматривались верхние вкладки, боковое меню и карточки-переходы на overview. Выбрано постоянное
|
||||||
|
боковое меню на широком экране. На узком экране оно заменяется горизонтальными прокручиваемыми
|
||||||
|
вкладками.
|
||||||
|
|
||||||
|
## Согласованная информационная архитектура
|
||||||
|
|
||||||
|
- Overview счёта: баланс, денежный остаток, существующие показатели доходности, распределение всего
|
||||||
|
портфеля, карточки акций и облигаций, последние пять операций.
|
||||||
|
- Страница акций: таблица только акций.
|
||||||
|
- Страница облигаций: таблица только облигаций.
|
||||||
|
- Страница операций: полная доступная история с cursor-пагинацией и фильтром по одному точному типу.
|
||||||
|
|
||||||
|
## Согласованные правила диаграммы
|
||||||
|
|
||||||
|
- Основа процентов — полная стоимость счёта.
|
||||||
|
- Срезы: акции, облигации, ETF/фонды, деньги и прочие инструменты.
|
||||||
|
- В «прочие» входят фьючерсы, опционы, структурные продукты, ЦФА и неизвестные типы.
|
||||||
|
- Нулевые срезы не отображаются.
|
||||||
|
- Отрицательные значения не рисуются как сектор, но показываются текстом рядом со сводкой.
|
||||||
|
- Легенда всегда содержит название, стоимость и процент; цвет не является единственным носителем
|
||||||
|
информации.
|
||||||
|
|
||||||
|
## Зафиксированные границы первой версии
|
||||||
|
|
||||||
|
Не входят в первую версию:
|
||||||
|
|
||||||
|
- отдельные страницы ETF, фондов и прочих инструментов;
|
||||||
|
- фильтр операций по датам или инструменту в UI;
|
||||||
|
- выбор нескольких типов операций;
|
||||||
|
- группировка точных типов в продуктовые категории;
|
||||||
|
- новая аналитика доходности или риска;
|
||||||
|
- изменение интеграции с T-Bank помимо данных, необходимых для согласованной сводки.
|
||||||
|
|
||||||
|
## Результат
|
||||||
|
|
||||||
|
Исследование завершено, продуктовые неоднозначности закрыты. Требования зафиксированы в
|
||||||
|
`docs/features/broker-account-sections/spec.md`.
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
Roadmap отражает порядок продуктовой работы, но сам по себе не является основанием для реализации.
|
||||||
|
Требования и разрешение на реализацию определяются спецификацией конкретной фичи и текущей задачей
|
||||||
|
пользователя.
|
||||||
|
|
||||||
|
## Активные эпики
|
||||||
|
|
||||||
|
### [Портфель брокера](epics/BrokerPortfolio.md)
|
||||||
|
|
||||||
|
Цель: сделать реальные брокерские счета понятными на уровне обзора, позиций и операций.
|
||||||
|
|
||||||
|
- [x] [Разделы брокерского счёта](features/broker-account-sections/spec.md) — реализовано.
|
||||||
|
|
||||||
|
## Следующие этапы для активной фичи
|
||||||
|
|
||||||
|
1. [x] Проверить и утвердить `spec.md` для планирования.
|
||||||
|
2. [x] Проверить и утвердить подготовленные `plan.md` и `tasks.md`.
|
||||||
|
3. [ ] Получить отдельное подтверждение пользователя перед началом реализации.
|
||||||
@ -1,88 +0,0 @@
|
|||||||
# Broker API Performance Optimization
|
|
||||||
|
|
||||||
## Проблема
|
|
||||||
|
|
||||||
- `GET /api/v1/broker/accounts/:id/portfolio` — **~5s**
|
|
||||||
- `GET /api/v1/broker/accounts/:id/positions` — **1-3s**
|
|
||||||
|
|
||||||
## Диагностика
|
|
||||||
|
|
||||||
### 1. Мёртвый код в `getPortfolio`
|
|
||||||
|
|
||||||
`buildInstrumentMap` делает N gRPC вызовов `GetInstrumentBy` (по одному на каждый `instrumentUid` в портфеле), но результат **не используется** в `mapBrokerPortfolio`. Это чистое WASTE.
|
|
||||||
|
|
||||||
### 2. Блокирующий instrument enrichment в `getPositions`
|
|
||||||
|
|
||||||
`buildInstrumentMap` вызывает `findByInstrumentUid` для каждого инструмента через gRPC. Даже при `Promise.allSettled`, все вызовы проходят через единый `p-queue` с 5 req/s. Для 10-20 позиций = 2-4 секунды ожидания в очереди.
|
|
||||||
|
|
||||||
Из всех полей `GetInstrumentBy` `mapBrokerPosition` использует только `name` — `ticker`, `classCode`, `instrumentType` уже есть в ответе `PortfolioPosition` (proto fields 32, 33, 2).
|
|
||||||
|
|
||||||
### 3. Единый rate limiter
|
|
||||||
|
|
||||||
Один `p-queue` на 5 req/s для всех gRPC вызовов. Instrument lookups конкурируют за очередь с portfolio/operations запросами.
|
|
||||||
|
|
||||||
## Изменения
|
|
||||||
|
|
||||||
### Change 1: Убрать `buildInstrumentMap` из `getPortfolio`
|
|
||||||
|
|
||||||
**Файлы:** `broker-portfolio.service.ts`
|
|
||||||
|
|
||||||
Удалить вызов `buildInstrumentMap` и передачу `instruments` в `mapBrokerPortfolio`. Исключить `BrokerInstrumentsService` из зависимостей (если не используется больше нигде в сервисе).
|
|
||||||
|
|
||||||
### Change 2: Instrument enrichment из кэша без блокировки
|
|
||||||
|
|
||||||
**Файлы:** `broker-portfolio.service.ts`, `cache.service.ts`
|
|
||||||
|
|
||||||
- `buildInstrumentMap` пытается достать данные из кэша без триггера gRPC
|
|
||||||
- Если данных нет — возвращаем `null` для имени (не блокируем ответ)
|
|
||||||
- Новый метод `CacheService.getIfPresent(key)` — проверяет кэш без вызова fetchFn
|
|
||||||
|
|
||||||
### Change 3: Разделить rate limiter на 3 очереди
|
|
||||||
|
|
||||||
**Файлы:** `tbank-client.service.ts`
|
|
||||||
|
|
||||||
Заменить единый `p-queue` на:
|
|
||||||
|
|
||||||
| Очередь | Rate | Сервисы |
|
|
||||||
|---|---|---|
|
|
||||||
| `operationsQueue` | 5 req/s | OperationsService |
|
|
||||||
| `instrumentsQueue` | 20 req/s | InstrumentsService |
|
|
||||||
| `usersQueue` | 5 req/s | UsersService |
|
|
||||||
|
|
||||||
Метод `callUnary` принимает параметр `queueName`. Клиентские методы выбирают очередь по типу сервиса.
|
|
||||||
|
|
||||||
### Change 4: Увеличить rate limit по умолчанию
|
|
||||||
|
|
||||||
**Файлы:** `configuration.ts`
|
|
||||||
|
|
||||||
`rateLimitPerSecond` по умолчанию: 5 → 20.
|
|
||||||
|
|
||||||
### Change 5: Shared cache сырого `GetPortfolio`
|
|
||||||
|
|
||||||
**Файлы:** `broker-portfolio.service.ts`
|
|
||||||
|
|
||||||
Оба эндпоинта вызывают `GetPortfolio` с одинаковым `accountId`. Кэшировать сырой ответ отдельно (TTL 60s, ключ `tbank:raw-portfolio:{accountId}`), чтобы второй запрос в том же окне не дублировал вызов.
|
|
||||||
|
|
||||||
## Ожидаемый эффект
|
|
||||||
|
|
||||||
| Endpoint | До | После |
|
|
||||||
|---|---|---|
|
|
||||||
| Portfolio | ~5s | ~0.3-0.5s (2 параллельных gRPC, без instrument enrichment) |
|
|
||||||
| Positions | 1-3s | ~0.2-0.3s (1 gRPC GetPortfolio, name из кэша / null) |
|
|
||||||
|
|
||||||
## Этапы реализации (по порядку)
|
|
||||||
|
|
||||||
1. Убрать `buildInstrumentMap` из `getPortfolio`
|
|
||||||
2. `CacheService.getIfPresent()` для instrument enrichment в positions
|
|
||||||
3. Разделить rate limiter на очереди
|
|
||||||
4. Увеличить rate limit по умолчанию
|
|
||||||
5. Shared cache сырого GetPortfolio
|
|
||||||
|
|
||||||
Каждый этап отдельным коммитом.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
1. Portfolio endpoint < 1s при тёплом кэше account/instrument, < 1.5s при холодном
|
|
||||||
2. Positions endpoint < 0.5s при тёплом кэше, < 1s при холодном
|
|
||||||
3. Все существующие тесты проходят
|
|
||||||
4. Instrument name показывается если есть в кэше, иначе `null`
|
|
||||||
Loading…
x
Reference in New Issue
Block a user