refactor(frontend): create shared/api layer with re-export shims
This commit is contained in:
parent
df4b778d95
commit
a9132109da
@ -1,232 +1,17 @@
|
|||||||
import type {
|
export {
|
||||||
ApiEnvelope,
|
request,
|
||||||
ApiResponseMeta,
|
setAccessToken,
|
||||||
AuthResponse,
|
getAccessToken,
|
||||||
ShareResponse,
|
setOnUnauthorized,
|
||||||
StockMarketData,
|
getHealth,
|
||||||
DividendItem,
|
searchSecurities,
|
||||||
ShareHistoryItem,
|
getShare,
|
||||||
BondResponse,
|
getShareMarketData,
|
||||||
BondMarketData,
|
getShareDividends,
|
||||||
BondHistoryItem,
|
getShareHistory,
|
||||||
CandleItem,
|
getBond,
|
||||||
SearchResultItem,
|
getBondMarketData,
|
||||||
HealthResponse,
|
getBondHistory,
|
||||||
} from './responses';
|
getShareCandles,
|
||||||
|
getBondCandles,
|
||||||
const BASE = '';
|
} from '../shared/api/client';
|
||||||
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,352 +1,32 @@
|
|||||||
export interface ApiResponseMeta {
|
export type {
|
||||||
cachedAt: string | null;
|
ApiResponseMeta,
|
||||||
fromCache: boolean;
|
ApiEnvelope,
|
||||||
}
|
StockMarketData,
|
||||||
|
ShareResponse,
|
||||||
export interface ApiEnvelope<T> {
|
DividendItem,
|
||||||
data: T;
|
ShareHistoryItem,
|
||||||
meta: ApiResponseMeta;
|
BondMarketData,
|
||||||
}
|
BondResponse,
|
||||||
|
BondHistoryItem,
|
||||||
export interface StockMarketData {
|
CandleItem,
|
||||||
price: number | null;
|
SearchResultItem,
|
||||||
change: number | null;
|
HealthResponse,
|
||||||
changePercent: number | null;
|
UserResponse,
|
||||||
open: number | null;
|
AuthResponse,
|
||||||
high: number | null;
|
Portfolio,
|
||||||
low: number | null;
|
PositionWithPrice,
|
||||||
volume: number;
|
PortfolioDetail,
|
||||||
value: number;
|
Position,
|
||||||
issueCapitalization: number | null;
|
PortfolioSummary,
|
||||||
updatedAt: string;
|
AnalyticsResponse,
|
||||||
}
|
ScreenerItem,
|
||||||
|
ScreenerResult,
|
||||||
export interface ShareResponse {
|
BrokerMoney,
|
||||||
secid: string;
|
BrokerAccount,
|
||||||
isin: string;
|
BrokerPosition,
|
||||||
name: string;
|
BrokerPortfolio,
|
||||||
shortName: string;
|
BrokerOperationCategory,
|
||||||
latName: string | null;
|
BrokerOperation,
|
||||||
listLevel: number;
|
BrokerOperationsPage,
|
||||||
issueSize: number;
|
BrokerPositionsPage,
|
||||||
faceValue: number;
|
} from '../shared/api/responses';
|
||||||
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
153
apps/frontend/src/shared/api/client.test.ts
Normal file
153
apps/frontend/src/shared/api/client.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
232
apps/frontend/src/shared/api/client.ts
Normal file
232
apps/frontend/src/shared/api/client.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
49
apps/frontend/src/shared/api/index.ts
Normal file
49
apps/frontend/src/shared/api/index.ts
Normal 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';
|
||||||
352
apps/frontend/src/shared/api/responses.ts
Normal file
352
apps/frontend/src/shared/api/responses.ts
Normal 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;
|
||||||
|
}
|
||||||
1637
apps/frontend/src/shared/api/types.ts
Normal file
1637
apps/frontend/src/shared/api/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user