feat: add broker frontend api hooks

This commit is contained in:
Sergey Krylov 2026-06-16 22:56:20 +03:00
parent b848d256d1
commit 50bff3dbe7
8 changed files with 477 additions and 0 deletions

View File

@ -0,0 +1,27 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getBrokerOperations } from './broker';
describe('broker api', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('serializes operations query parameters', 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: 50 });
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/broker/accounts/acc-1/operations?cursor=c1&limit=50'),
expect.any(Object),
);
});
});

View File

@ -0,0 +1,51 @@
import { request } from './client';
import type {
ApiResponseMeta,
BrokerAccount,
BrokerOperationsPage,
BrokerPortfolio,
} from './responses';
export type BrokerOperationQuery = {
from?: string;
to?: string;
cursor?: string;
limit?: number;
instrumentId?: string;
operationTypes?: string;
state?: string;
};
export function getBrokerAccounts(): Promise<{
data: BrokerAccount[];
meta: ApiResponseMeta;
}> {
return request<BrokerAccount[]>('/api/v1/broker/accounts');
}
export function getBrokerPortfolio(accountId: string): Promise<{
data: BrokerPortfolio;
meta: ApiResponseMeta;
}> {
return request<BrokerPortfolio>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/portfolio`,
);
}
export function getBrokerOperations(
accountId: string,
query: BrokerOperationQuery = {},
): Promise<{ data: BrokerOperationsPage; meta: ApiResponseMeta }> {
return request<BrokerOperationsPage>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations`,
{
from: query.from,
to: query.to,
cursor: query.cursor,
limit: query.limit ? String(query.limit) : undefined,
instrumentId: query.instrumentId,
operationTypes: query.operationTypes,
state: query.state,
},
);
}

View File

@ -245,3 +245,94 @@ export interface ScreenerResult {
pageSize: number; pageSize: number;
totalPages: number; totalPages: number;
} }
export interface BrokerMoney {
currency: string;
units: string;
nano: number;
value: number;
}
export interface BrokerAccount {
id: string;
type: 'brokerage' | 'iis';
name: string;
status: string;
openedAt: string | null;
accessLevel: string | null;
}
export interface BrokerPosition {
figi: string | null;
instrumentUid: string | null;
positionUid: string | null;
ticker: string | null;
classCode: string | null;
instrumentType: string | null;
name: string | null;
quantity: number | null;
blockedLots: number | null;
currentPrice: BrokerMoney | null;
currentValue: BrokerMoney | null;
averagePositionPrice: BrokerMoney | null;
expectedYieldPercent: number | null;
dailyYield: BrokerMoney | null;
}
export interface BrokerPortfolio {
account: BrokerAccount;
totals: {
shares: BrokerMoney | null;
bonds: BrokerMoney | null;
etf: BrokerMoney | null;
currencies: BrokerMoney | null;
futures: BrokerMoney | null;
options: BrokerMoney | null;
structuredProducts: BrokerMoney | null;
dfa: BrokerMoney | null;
portfolio: BrokerMoney | null;
};
yields: {
expectedPercent: number | null;
daily: BrokerMoney | null;
dailyPercent: number | null;
};
cash: BrokerMoney[];
blockedCash: BrokerMoney[];
positions: BrokerPosition[];
asOf: string;
}
export type BrokerOperationCategory = 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
export interface BrokerOperation {
cursor: string | null;
accountId: string;
id: string | null;
parentOperationId: string | null;
date: string | null;
type: string;
category: BrokerOperationCategory;
description: string | null;
state: string | null;
instrumentUid: string | null;
figi: string | null;
ticker: string | null;
classCode: string | null;
instrumentType: string | null;
payment: BrokerMoney | null;
price: BrokerMoney | null;
commission: BrokerMoney | null;
yield: BrokerMoney | null;
accruedInt: BrokerMoney | null;
quantity: number | null;
quantityDone: number | null;
}
export interface BrokerOperationsPage {
accountId: string;
items: BrokerOperation[];
nextCursor: string | null;
hasNext: boolean;
asOf: string;
}

View File

@ -383,6 +383,57 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
'/api/v1/broker/accounts': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get open T-Bank brokerage and IIS accounts */
get: operations['TBankController_getAccounts'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/broker/accounts/{accountId}/portfolio': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get T-Bank broker account portfolio with cash and positions */
get: operations['TBankController_getPortfolio'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
'/api/v1/broker/accounts/{accountId}/operations': {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get paginated T-Bank broker account operations */
get: operations['TBankController_getOperations'];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
@ -689,6 +740,109 @@ export interface components {
data: components['schemas']['AnalyticsResponseDto']; data: components['schemas']['AnalyticsResponseDto'];
meta: components['schemas']['PortfolioResponseMetaDto']; meta: components['schemas']['PortfolioResponseMetaDto'];
}; };
BrokerAccountResponseDto: {
id: string;
/** @enum {string} */
type: 'brokerage' | 'iis';
name: string;
status: string;
openedAt: Record<string, never> | null;
accessLevel: Record<string, never> | null;
};
BrokerResponseMetaDto: {
cachedAt: Record<string, never> | null;
fromCache: boolean;
};
BrokerAccountsEnvelopeDto: {
data: components['schemas']['BrokerAccountResponseDto'][];
meta: components['schemas']['BrokerResponseMetaDto'];
};
BrokerMoneyDto: {
currency: string;
units: string;
nano: number;
value: number;
};
BrokerPortfolioTotalsDto: {
shares: components['schemas']['BrokerMoneyDto'] | null;
bonds: components['schemas']['BrokerMoneyDto'] | null;
etf: components['schemas']['BrokerMoneyDto'] | null;
currencies: components['schemas']['BrokerMoneyDto'] | null;
futures: components['schemas']['BrokerMoneyDto'] | null;
options: components['schemas']['BrokerMoneyDto'] | null;
structuredProducts: components['schemas']['BrokerMoneyDto'] | null;
dfa: components['schemas']['BrokerMoneyDto'] | null;
portfolio: components['schemas']['BrokerMoneyDto'] | null;
};
BrokerPortfolioYieldsDto: {
expectedPercent: Record<string, never> | null;
daily: components['schemas']['BrokerMoneyDto'] | null;
dailyPercent: Record<string, never> | null;
};
BrokerPositionResponseDto: {
figi: Record<string, never> | null;
instrumentUid: Record<string, never> | null;
positionUid: Record<string, never> | null;
ticker: Record<string, never> | null;
classCode: Record<string, never> | null;
instrumentType: Record<string, never> | null;
name: Record<string, never> | null;
quantity: Record<string, never> | null;
blockedLots: Record<string, never> | null;
currentPrice: components['schemas']['BrokerMoneyDto'] | null;
currentValue: components['schemas']['BrokerMoneyDto'] | null;
averagePositionPrice: components['schemas']['BrokerMoneyDto'] | null;
expectedYieldPercent: Record<string, never> | null;
dailyYield: components['schemas']['BrokerMoneyDto'] | null;
};
BrokerPortfolioResponseDto: {
account: components['schemas']['BrokerAccountResponseDto'];
totals: components['schemas']['BrokerPortfolioTotalsDto'];
yields: components['schemas']['BrokerPortfolioYieldsDto'];
cash: components['schemas']['BrokerMoneyDto'][];
blockedCash: components['schemas']['BrokerMoneyDto'][];
positions: components['schemas']['BrokerPositionResponseDto'][];
asOf: string;
};
BrokerPortfolioEnvelopeDto: {
data: components['schemas']['BrokerPortfolioResponseDto'];
meta: components['schemas']['BrokerResponseMetaDto'];
};
BrokerOperationResponseDto: {
cursor: Record<string, never> | null;
accountId: string;
id: Record<string, never> | null;
parentOperationId: Record<string, never> | null;
date: Record<string, never> | null;
type: string;
/** @enum {string} */
category: 'trade' | 'income' | 'tax' | 'fee' | 'transfer' | 'other';
description: Record<string, never> | null;
state: Record<string, never> | null;
instrumentUid: Record<string, never> | null;
figi: Record<string, never> | null;
ticker: Record<string, never> | null;
classCode: Record<string, never> | null;
instrumentType: Record<string, never> | null;
payment: components['schemas']['BrokerMoneyDto'] | null;
price: components['schemas']['BrokerMoneyDto'] | null;
commission: components['schemas']['BrokerMoneyDto'] | null;
yield: components['schemas']['BrokerMoneyDto'] | null;
accruedInt: components['schemas']['BrokerMoneyDto'] | null;
quantity: Record<string, never> | null;
quantityDone: Record<string, never> | null;
};
BrokerOperationsPageResponseDto: {
accountId: string;
items: components['schemas']['BrokerOperationResponseDto'][];
nextCursor: Record<string, never> | null;
hasNext: boolean;
asOf: string;
};
BrokerOperationsEnvelopeDto: {
data: components['schemas']['BrokerOperationsPageResponseDto'];
meta: components['schemas']['BrokerResponseMetaDto'];
};
}; };
responses: never; responses: never;
parameters: never; parameters: never;
@ -1300,4 +1454,73 @@ export interface operations {
}; };
}; };
}; };
TBankController_getAccounts: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['BrokerAccountsEnvelopeDto'];
};
};
};
};
TBankController_getPortfolio: {
parameters: {
query?: never;
header?: never;
path: {
accountId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['BrokerPortfolioEnvelopeDto'];
};
};
};
};
TBankController_getOperations: {
parameters: {
query?: {
from?: string;
to?: string;
cursor?: string;
limit?: number;
instrumentId?: string;
operationTypes?: string;
state?: string;
};
header?: never;
path: {
accountId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
'application/json': components['schemas']['BrokerOperationsEnvelopeDto'];
};
};
};
};
} }

View File

@ -0,0 +1,41 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { type ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { getBrokerAccounts } from '../api/broker';
import { useBrokerAccounts } from './useBrokerAccounts';
vi.mock('../api/broker', () => ({
getBrokerAccounts: vi.fn(),
}));
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
describe('useBrokerAccounts', () => {
it('returns broker accounts from API', async () => {
vi.mocked(getBrokerAccounts).mockResolvedValue({
data: [
{
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
},
],
meta: { fromCache: false, cachedAt: null },
});
const { result } = renderHook(() => useBrokerAccounts(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.[0].name).toBe('Broker');
});
});

View File

@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { getBrokerAccounts } from '../api/broker';
import type { BrokerAccount } from '../api/responses';
export function useBrokerAccounts() {
return useQuery<BrokerAccount[]>({
queryKey: ['broker', 'accounts'],
queryFn: async () => (await getBrokerAccounts()).data,
staleTime: 3_600_000,
retry: 2,
refetchOnWindowFocus: false,
});
}

View File

@ -0,0 +1,17 @@
import { useQuery } from '@tanstack/react-query';
import { getBrokerOperations, type BrokerOperationQuery } from '../api/broker';
import type { BrokerOperationsPage } from '../api/responses';
export function useBrokerOperations(
accountId: string | undefined,
query: BrokerOperationQuery = {},
) {
return useQuery<BrokerOperationsPage>({
queryKey: ['broker', 'operations', accountId, query],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerOperations(accountId!, query)).data,
staleTime: 300_000,
retry: 2,
refetchOnWindowFocus: false,
});
}

View File

@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { getBrokerPortfolio } from '../api/broker';
import type { BrokerPortfolio } from '../api/responses';
export function useBrokerPortfolio(accountId: string | undefined) {
return useQuery<BrokerPortfolio>({
queryKey: ['broker', 'portfolio', accountId],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerPortfolio(accountId!)).data,
staleTime: 60_000,
retry: 2,
refetchOnWindowFocus: false,
});
}