refactor(frontend): create shared/api layer with re-export shims

This commit is contained in:
Sergey Krylov 2026-06-20 13:28:56 +03:00
parent df4b778d95
commit a9132109da
8 changed files with 2473 additions and 2221 deletions

View File

@ -1,232 +1,17 @@
import type {
ApiEnvelope,
ApiResponseMeta,
AuthResponse,
ShareResponse,
StockMarketData,
DividendItem,
ShareHistoryItem,
BondResponse,
BondMarketData,
BondHistoryItem,
CandleItem,
SearchResultItem,
HealthResponse,
} from './responses';
const BASE = '';
let accessToken: string | null = null;
let onUnauthorized: (() => void) | null = null;
let isRefreshing = false;
let refreshPromise: Promise<boolean> | null = null;
export function setAccessToken(token: string | null) {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
export function setOnUnauthorized(cb: () => void) {
onUnauthorized = cb;
}
async function refreshTokens(): Promise<boolean> {
try {
const res = await fetch(`${BASE}/api/v1/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) return false;
const json = await res.json();
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
return true;
} catch {
return false;
}
}
function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>;
if (
envelope.data &&
typeof envelope.data === 'object' &&
'data' in envelope.data &&
'meta' in envelope.data
) {
return envelope.data as { data: T; meta: ApiResponseMeta };
}
return {
data: envelope.data as T,
meta: envelope.meta,
};
}
async function handleUnauthorized(): Promise<boolean> {
if (isRefreshing && refreshPromise) {
return refreshPromise;
}
isRefreshing = true;
refreshPromise = refreshTokens().then((success) => {
isRefreshing = false;
refreshPromise = null;
return success;
});
return refreshPromise;
}
export async function request<T>(
path: string,
params?: Record<string, string | undefined>,
options?: { method?: string; body?: unknown; skipAuth?: boolean },
): Promise<{ data: T; meta: ApiResponseMeta }> {
const url = new URL(`${BASE}${path}`, window.location.origin);
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) url.searchParams.set(k, v);
}
}
const headers: Record<string, string> = {};
if (!options?.skipAuth && accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
if (options?.body && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const fetchOptions: RequestInit = {
headers,
credentials: 'include' as RequestCredentials,
};
if (options?.method) {
fetchOptions.method = options.method;
}
if (options?.body !== undefined) {
fetchOptions.body =
options.body instanceof FormData ? options.body : JSON.stringify(options.body);
}
let res = await fetch(url.toString(), fetchOptions);
if (res.status === 401 && !options?.skipAuth) {
const refreshed = await handleUnauthorized();
if (refreshed) {
headers['Authorization'] = `Bearer ${accessToken}`;
res = await fetch(url.toString(), { ...fetchOptions, headers });
} else {
accessToken = null;
onUnauthorized?.();
throw new Error('Сессия истекла');
}
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
}
const json = await res.json();
return normalizeEnvelope<T>(json);
}
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
return request<HealthResponse>('/api/v1/health');
}
export function searchSecurities(
q: string,
type: 'all' | 'share' | 'bond' = 'all',
limit = 20,
): Promise<{ data: SearchResultItem[]; meta: ApiResponseMeta }> {
return request<SearchResultItem[]>('/api/v1/securities/search', {
q,
type,
limit: String(limit),
});
}
export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> {
return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`);
}
export function getShareMarketData(
secid: string,
): Promise<{ data: StockMarketData; meta: ApiResponseMeta }> {
return request<StockMarketData>(
`/api/v1/securities/shares/${encodeURIComponent(secid)}/marketdata`,
);
}
export function getShareDividends(
secid: string,
): Promise<{ data: DividendItem[]; meta: ApiResponseMeta }> {
return request<DividendItem[]>(
`/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`,
);
}
export function getShareHistory(
secid: string,
from: string,
till: string,
): Promise<{ data: ShareHistoryItem[]; meta: ApiResponseMeta }> {
return request<ShareHistoryItem[]>(
`/api/v1/securities/shares/${encodeURIComponent(secid)}/history`,
{ from, till },
);
}
export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> {
return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`);
}
export function getBondMarketData(
secid: string,
): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> {
return request<BondMarketData>(
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`,
);
}
export function getBondHistory(
secid: string,
from: string,
till: string,
): Promise<{ data: BondHistoryItem[]; meta: ApiResponseMeta }> {
return request<BondHistoryItem[]>(
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`,
{ from, till },
);
}
export function getShareCandles(
secid: string,
interval: '1h' | '24h',
from: string,
till: string,
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
return request<CandleItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/candles`, {
interval,
from,
till,
});
}
export function getBondCandles(
secid: string,
interval: '1h' | '24h',
from: string,
till: string,
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
return request<CandleItem[]>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/candles`, {
interval,
from,
till,
});
}
export {
request,
setAccessToken,
getAccessToken,
setOnUnauthorized,
getHealth,
searchSecurities,
getShare,
getShareMarketData,
getShareDividends,
getShareHistory,
getBond,
getBondMarketData,
getBondHistory,
getShareCandles,
getBondCandles,
} from '../shared/api/client';

View File

@ -1,352 +1,32 @@
export interface ApiResponseMeta {
cachedAt: string | null;
fromCache: boolean;
}
export interface ApiEnvelope<T> {
data: T;
meta: ApiResponseMeta;
}
export interface StockMarketData {
price: number | null;
change: number | null;
changePercent: number | null;
open: number | null;
high: number | null;
low: number | null;
volume: number;
value: number;
issueCapitalization: number | null;
updatedAt: string;
}
export interface ShareResponse {
secid: string;
isin: string;
name: string;
shortName: string;
latName: string | null;
listLevel: number;
issueSize: number;
faceValue: number;
faceUnit: string;
type: string;
marketData: StockMarketData;
}
export interface DividendItem {
registryCloseDate: string;
value: number;
currency: string;
}
export interface ShareHistoryItem {
date: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
value: number;
}
export interface BondMarketData {
price: number | null;
yieldToMaturity: number | null;
duration: number | null;
accruedInt: number | null;
couponValue: number | null;
couponPercent: number | null;
nextCouponDate: string | null;
open: number;
high: number | null;
low: number | null;
volume: number;
updatedAt: string;
}
export interface BondResponse {
secid: string;
isin: string;
name: string;
shortName: string;
latName: string | null;
listLevel: number;
issueSize: number;
faceValue: number;
faceUnit: string;
matDate: string;
couponValue: number;
couponPercent: number | null;
couponPeriod: number;
nextCoupon: string | null;
accruedInt: number;
bondType: string;
bondSubType: string;
offerDate: string | null;
buybackDate: string | null;
marketData: BondMarketData;
}
export interface BondHistoryItem {
date: string;
closePrice: number;
yieldClose: number | null;
duration: number | null;
}
export interface CandleItem {
open: number;
high: number;
low: number;
close: number;
volume: number;
value: number;
begin: string;
end: string;
}
export interface SearchResultItem {
secid: string;
isin: string;
shortName: string;
type: 'share' | 'bond';
listLevel: number;
currency: string | null;
price: number | null;
}
export interface HealthResponse {
status: string;
timestamp: string;
uptime: number;
}
export interface UserResponse {
id: number;
email: string;
name: string | null;
role: string;
}
export interface AuthResponse {
user: UserResponse;
accessToken: string;
}
export interface Portfolio {
id: number;
name: string;
description: string | null;
currency: string;
createdAt: string;
updatedAt: string;
totalValue: number;
positionCount: number;
shareCount: number;
bondCount: number;
}
export interface PositionWithPrice {
id: number;
portfolioId: number;
secid: string;
shortName: string | null;
type: 'share' | 'bond';
quantity: number;
notes: string | null;
tags: string[] | null;
currentPrice: number | null;
buyPrice: number | null;
buyDate: string | null;
totalCost: number | null;
currentValue: number | null;
pnl: number | null;
pnlPercent: number | null;
dividendIncome: number | null;
totalReturn: number | null;
totalReturnPercent: number | null;
weightPercent: number;
change?: number | null;
changePercent?: number | null;
yieldToMaturity?: number | null;
duration?: number | null;
couponValue?: number | null;
couponPercent?: number | null;
nextCouponDate?: string | null;
matDate?: string | null;
accruedInt?: number | null;
bid?: number | null;
offer?: number | null;
couponPeriod?: number | null;
bondType?: string | null;
offerDate?: string | null;
}
export interface PortfolioDetail extends Portfolio {
positions: PositionWithPrice[];
totalValue: number;
analytics: PortfolioSummary;
}
export interface Position {
id: number;
secid: string;
quantity: number;
notes: string | null;
tags: string[] | null;
portfolioId: number;
createdAt: string;
updatedAt: string;
}
export interface PortfolioSummary {
totalInvested: number;
totalValue: number;
totalPnl: number;
totalPnlPercent: number | null;
totalDividends: number;
totalReturn: number;
totalReturnPercent: number | null;
positionCount: number;
weightedYield: number | null;
}
export interface AnalyticsResponse {
positions: PositionWithPrice[];
summary: PortfolioSummary;
}
export interface ScreenerItem {
secid: string;
shortName: string;
isin: string;
type: 'share' | 'bond';
price: number | null;
change: number | null;
changePercent: number | null;
volume: number;
listLevel: number;
capitalization: number | null;
yieldToMaturity: number | null;
duration: number | null;
couponValue: number | null;
couponPercent: number | null;
accruedInt: number | null;
matDate: string | null;
bondType: string | null;
}
export interface ScreenerResult {
items: ScreenerItem[];
total: number;
page: number;
pageSize: 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;
positionCounts: {
shares: number;
bonds: number;
etf: number;
other: number;
};
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[];
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;
name: 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;
}
export interface BrokerPositionsPage {
accountId: string;
items: BrokerPosition[];
nextCursor: string | null;
hasNext: boolean;
asOf: string;
}
export type {
ApiResponseMeta,
ApiEnvelope,
StockMarketData,
ShareResponse,
DividendItem,
ShareHistoryItem,
BondMarketData,
BondResponse,
BondHistoryItem,
CandleItem,
SearchResultItem,
HealthResponse,
UserResponse,
AuthResponse,
Portfolio,
PositionWithPrice,
PortfolioDetail,
Position,
PortfolioSummary,
AnalyticsResponse,
ScreenerItem,
ScreenerResult,
BrokerMoney,
BrokerAccount,
BrokerPosition,
BrokerPortfolio,
BrokerOperationCategory,
BrokerOperation,
BrokerOperationsPage,
BrokerPositionsPage,
} from '../shared/api/responses';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,153 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../test/server';
import { request, setAccessToken, getAccessToken, setOnUnauthorized } from './client';
const API = '/api/v1';
beforeEach(() => {
setAccessToken(null);
});
describe('request', () => {
it('makes GET request and returns data', async () => {
const result = await request<{ status: string; timestamp: string; uptime: number }>(
'/api/v1/health',
);
expect(result.data.status).toBe('ok');
});
it('supports the single API envelope shape documented by Swagger', async () => {
server.use(
http.get(`${API}/test-single-envelope`, () =>
HttpResponse.json({
data: { ok: true },
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
}),
),
);
const result = await request<{ ok: boolean }>('/api/v1/test-single-envelope');
expect(result).toEqual({
data: { ok: true },
meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' },
});
});
it('includes Authorization header when token is set', async () => {
setAccessToken('test-token');
let capturedAuth: string | null = null;
server.use(
http.get(`${API}/test-auth`, ({ request }) => {
capturedAuth = request.headers.get('Authorization');
return HttpResponse.json({
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
});
}),
);
await request('/api/v1/test-auth');
expect(capturedAuth).toBe('Bearer test-token');
});
it('retries on 401 and succeeds after refresh', async () => {
setAccessToken('expired-token');
let attempts = 0;
server.use(
http.get(`${API}/test-retry`, ({ request }) => {
attempts++;
const auth = request.headers.get('Authorization');
if (auth === 'Bearer expired-token') {
return new HttpResponse(null, { status: 401 });
}
return HttpResponse.json({
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
});
}),
http.post(`${API}/auth/refresh`, () =>
HttpResponse.json({
data: {
data: {
user: { id: 1, email: 'user@test.com', name: null, role: 'user' },
accessToken: 'new-token',
},
meta: { fromCache: false, cachedAt: null },
},
}),
),
);
const result = await request<{ ok: boolean }>('/api/v1/test-retry');
expect(attempts).toBe(2);
expect(result.data).toEqual({ ok: true });
expect(getAccessToken()).toBe('new-token');
});
it('throws on persistent 401 and clears token', async () => {
setAccessToken('expired-token');
let unauthorizedCalled = false;
setOnUnauthorized(() => {
unauthorizedCalled = true;
});
server.use(
http.get(`${API}/test-fail`, () => new HttpResponse(null, { status: 401 })),
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
);
await expect(request('/api/v1/test-fail')).rejects.toThrow('Сессия истекла');
expect(getAccessToken()).toBeNull();
expect(unauthorizedCalled).toBe(true);
});
it('throws on non-ok response with status text', async () => {
server.use(
http.get(
`${API}/test-error`,
() => new HttpResponse('Not found', { status: 404, statusText: 'Not Found' }),
),
);
await expect(request('/api/v1/test-error')).rejects.toThrow('Ошибка API: 404');
});
it('sends JSON body for POST requests', async () => {
let capturedBody: string | null = null;
server.use(
http.post(`${API}/test-post`, async ({ request }) => {
capturedBody = await request.text();
return HttpResponse.json({
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
});
}),
);
await request('/api/v1/test-post', undefined, { method: 'POST', body: { foo: 'bar' } });
expect(capturedBody).toBe(JSON.stringify({ foo: 'bar' }));
});
it('does not send auth header when skipAuth is true', async () => {
setAccessToken('test-token');
let capturedAuth: string | null = null;
server.use(
http.get(`${API}/test-skip`, ({ request }) => {
capturedAuth = request.headers.get('Authorization');
return HttpResponse.json({
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
});
}),
);
await request('/api/v1/test-skip', undefined, { skipAuth: true });
expect(capturedAuth).toBeNull();
});
it('sets query params correctly', async () => {
let capturedUrl = '';
server.use(
http.get(`${API}/test-params`, ({ request }) => {
capturedUrl = request.url;
return HttpResponse.json({
data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } },
});
}),
);
await request('/api/v1/test-params', { q: 'sber', type: 'share' });
expect(capturedUrl).toContain('q=sber');
expect(capturedUrl).toContain('type=share');
});
});

View File

@ -0,0 +1,232 @@
import type {
ApiEnvelope,
ApiResponseMeta,
AuthResponse,
ShareResponse,
StockMarketData,
DividendItem,
ShareHistoryItem,
BondResponse,
BondMarketData,
BondHistoryItem,
CandleItem,
SearchResultItem,
HealthResponse,
} from './responses';
const BASE = '';
let accessToken: string | null = null;
let onUnauthorized: (() => void) | null = null;
let isRefreshing = false;
let refreshPromise: Promise<boolean> | null = null;
export function setAccessToken(token: string | null) {
accessToken = token;
}
export function getAccessToken(): string | null {
return accessToken;
}
export function setOnUnauthorized(cb: () => void) {
onUnauthorized = cb;
}
async function refreshTokens(): Promise<boolean> {
try {
const res = await fetch(`${BASE}/api/v1/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) return false;
const json = await res.json();
accessToken = normalizeEnvelope<AuthResponse>(json).data.accessToken;
return true;
} catch {
return false;
}
}
function normalizeEnvelope<T>(json: unknown): { data: T; meta: ApiResponseMeta } {
const envelope = json as ApiEnvelope<T | { data: T; meta: ApiResponseMeta }>;
if (
envelope.data &&
typeof envelope.data === 'object' &&
'data' in envelope.data &&
'meta' in envelope.data
) {
return envelope.data as { data: T; meta: ApiResponseMeta };
}
return {
data: envelope.data as T,
meta: envelope.meta,
};
}
async function handleUnauthorized(): Promise<boolean> {
if (isRefreshing && refreshPromise) {
return refreshPromise;
}
isRefreshing = true;
refreshPromise = refreshTokens().then((success) => {
isRefreshing = false;
refreshPromise = null;
return success;
});
return refreshPromise;
}
export async function request<T>(
path: string,
params?: Record<string, string | undefined>,
options?: { method?: string; body?: unknown; skipAuth?: boolean },
): Promise<{ data: T; meta: ApiResponseMeta }> {
const url = new URL(`${BASE}${path}`, window.location.origin);
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) url.searchParams.set(k, v);
}
}
const headers: Record<string, string> = {};
if (!options?.skipAuth && accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
if (options?.body && !(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
const fetchOptions: RequestInit = {
headers,
credentials: 'include' as RequestCredentials,
};
if (options?.method) {
fetchOptions.method = options.method;
}
if (options?.body !== undefined) {
fetchOptions.body =
options.body instanceof FormData ? options.body : JSON.stringify(options.body);
}
let res = await fetch(url.toString(), fetchOptions);
if (res.status === 401 && !options?.skipAuth) {
const refreshed = await handleUnauthorized();
if (refreshed) {
headers['Authorization'] = `Bearer ${accessToken}`;
res = await fetch(url.toString(), { ...fetchOptions, headers });
} else {
accessToken = null;
onUnauthorized?.();
throw new Error('Сессия истекла');
}
}
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Ошибка API: ${res.status} ${res.statusText}${text ? ` - ${text}` : ''}`);
}
const json = await res.json();
return normalizeEnvelope<T>(json);
}
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
return request<HealthResponse>('/api/v1/health');
}
export function searchSecurities(
q: string,
type: 'all' | 'share' | 'bond' = 'all',
limit = 20,
): Promise<{ data: SearchResultItem[]; meta: ApiResponseMeta }> {
return request<SearchResultItem[]>('/api/v1/securities/search', {
q,
type,
limit: String(limit),
});
}
export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> {
return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`);
}
export function getShareMarketData(
secid: string,
): Promise<{ data: StockMarketData; meta: ApiResponseMeta }> {
return request<StockMarketData>(
`/api/v1/securities/shares/${encodeURIComponent(secid)}/marketdata`,
);
}
export function getShareDividends(
secid: string,
): Promise<{ data: DividendItem[]; meta: ApiResponseMeta }> {
return request<DividendItem[]>(
`/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`,
);
}
export function getShareHistory(
secid: string,
from: string,
till: string,
): Promise<{ data: ShareHistoryItem[]; meta: ApiResponseMeta }> {
return request<ShareHistoryItem[]>(
`/api/v1/securities/shares/${encodeURIComponent(secid)}/history`,
{ from, till },
);
}
export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> {
return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`);
}
export function getBondMarketData(
secid: string,
): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> {
return request<BondMarketData>(
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`,
);
}
export function getBondHistory(
secid: string,
from: string,
till: string,
): Promise<{ data: BondHistoryItem[]; meta: ApiResponseMeta }> {
return request<BondHistoryItem[]>(
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`,
{ from, till },
);
}
export function getShareCandles(
secid: string,
interval: '1h' | '24h',
from: string,
till: string,
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
return request<CandleItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/candles`, {
interval,
from,
till,
});
}
export function getBondCandles(
secid: string,
interval: '1h' | '24h',
from: string,
till: string,
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
return request<CandleItem[]>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/candles`, {
interval,
from,
till,
});
}

View File

@ -0,0 +1,49 @@
export {
request,
setAccessToken,
getAccessToken,
setOnUnauthorized,
getHealth,
searchSecurities,
getShare,
getShareMarketData,
getShareDividends,
getShareHistory,
getBond,
getBondMarketData,
getBondHistory,
getShareCandles,
getBondCandles,
} from './client';
export type {
ApiResponseMeta,
ApiEnvelope,
StockMarketData,
ShareResponse,
DividendItem,
ShareHistoryItem,
BondMarketData,
BondResponse,
BondHistoryItem,
CandleItem,
SearchResultItem,
HealthResponse,
UserResponse,
AuthResponse,
Portfolio,
PositionWithPrice,
PortfolioDetail,
Position,
PortfolioSummary,
AnalyticsResponse,
ScreenerItem,
ScreenerResult,
BrokerMoney,
BrokerAccount,
BrokerPosition,
BrokerPortfolio,
BrokerOperationCategory,
BrokerOperation,
BrokerOperationsPage,
BrokerPositionsPage,
} from './responses';

View File

@ -0,0 +1,352 @@
export interface ApiResponseMeta {
cachedAt: string | null;
fromCache: boolean;
}
export interface ApiEnvelope<T> {
data: T;
meta: ApiResponseMeta;
}
export interface StockMarketData {
price: number | null;
change: number | null;
changePercent: number | null;
open: number | null;
high: number | null;
low: number | null;
volume: number;
value: number;
issueCapitalization: number | null;
updatedAt: string;
}
export interface ShareResponse {
secid: string;
isin: string;
name: string;
shortName: string;
latName: string | null;
listLevel: number;
issueSize: number;
faceValue: number;
faceUnit: string;
type: string;
marketData: StockMarketData;
}
export interface DividendItem {
registryCloseDate: string;
value: number;
currency: string;
}
export interface ShareHistoryItem {
date: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
value: number;
}
export interface BondMarketData {
price: number | null;
yieldToMaturity: number | null;
duration: number | null;
accruedInt: number | null;
couponValue: number | null;
couponPercent: number | null;
nextCouponDate: string | null;
open: number;
high: number | null;
low: number | null;
volume: number;
updatedAt: string;
}
export interface BondResponse {
secid: string;
isin: string;
name: string;
shortName: string;
latName: string | null;
listLevel: number;
issueSize: number;
faceValue: number;
faceUnit: string;
matDate: string;
couponValue: number;
couponPercent: number | null;
couponPeriod: number;
nextCoupon: string | null;
accruedInt: number;
bondType: string;
bondSubType: string;
offerDate: string | null;
buybackDate: string | null;
marketData: BondMarketData;
}
export interface BondHistoryItem {
date: string;
closePrice: number;
yieldClose: number | null;
duration: number | null;
}
export interface CandleItem {
open: number;
high: number;
low: number;
close: number;
volume: number;
value: number;
begin: string;
end: string;
}
export interface SearchResultItem {
secid: string;
isin: string;
shortName: string;
type: 'share' | 'bond';
listLevel: number;
currency: string | null;
price: number | null;
}
export interface HealthResponse {
status: string;
timestamp: string;
uptime: number;
}
export interface UserResponse {
id: number;
email: string;
name: string | null;
role: string;
}
export interface AuthResponse {
user: UserResponse;
accessToken: string;
}
export interface Portfolio {
id: number;
name: string;
description: string | null;
currency: string;
createdAt: string;
updatedAt: string;
totalValue: number;
positionCount: number;
shareCount: number;
bondCount: number;
}
export interface PositionWithPrice {
id: number;
portfolioId: number;
secid: string;
shortName: string | null;
type: 'share' | 'bond';
quantity: number;
notes: string | null;
tags: string[] | null;
currentPrice: number | null;
buyPrice: number | null;
buyDate: string | null;
totalCost: number | null;
currentValue: number | null;
pnl: number | null;
pnlPercent: number | null;
dividendIncome: number | null;
totalReturn: number | null;
totalReturnPercent: number | null;
weightPercent: number;
change?: number | null;
changePercent?: number | null;
yieldToMaturity?: number | null;
duration?: number | null;
couponValue?: number | null;
couponPercent?: number | null;
nextCouponDate?: string | null;
matDate?: string | null;
accruedInt?: number | null;
bid?: number | null;
offer?: number | null;
couponPeriod?: number | null;
bondType?: string | null;
offerDate?: string | null;
}
export interface PortfolioDetail extends Portfolio {
positions: PositionWithPrice[];
totalValue: number;
analytics: PortfolioSummary;
}
export interface Position {
id: number;
secid: string;
quantity: number;
notes: string | null;
tags: string[] | null;
portfolioId: number;
createdAt: string;
updatedAt: string;
}
export interface PortfolioSummary {
totalInvested: number;
totalValue: number;
totalPnl: number;
totalPnlPercent: number | null;
totalDividends: number;
totalReturn: number;
totalReturnPercent: number | null;
positionCount: number;
weightedYield: number | null;
}
export interface AnalyticsResponse {
positions: PositionWithPrice[];
summary: PortfolioSummary;
}
export interface ScreenerItem {
secid: string;
shortName: string;
isin: string;
type: 'share' | 'bond';
price: number | null;
change: number | null;
changePercent: number | null;
volume: number;
listLevel: number;
capitalization: number | null;
yieldToMaturity: number | null;
duration: number | null;
couponValue: number | null;
couponPercent: number | null;
accruedInt: number | null;
matDate: string | null;
bondType: string | null;
}
export interface ScreenerResult {
items: ScreenerItem[];
total: number;
page: number;
pageSize: 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;
positionCounts: {
shares: number;
bonds: number;
etf: number;
other: number;
};
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[];
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;
name: 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;
}
export interface BrokerPositionsPage {
accountId: string;
items: BrokerPosition[];
nextCursor: string | null;
hasNext: boolean;
asOf: string;
}

File diff suppressed because it is too large Load Diff