MVP реализации MoexVibe — NestJS бэкенд + React фронтенд + CI/CD #1

Merged
ksv741 merged 21 commits from feat/mvp-implementation into main 2026-06-13 21:07:34 +03:00
24 changed files with 584 additions and 505 deletions
Showing only changes of commit b79c8210dc - Show all commits

View File

@ -1,10 +1,4 @@
import { import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
@Catch() @Catch()

View File

@ -1,21 +1,11 @@
import { import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { ApiResponse } from '../dto/api-response.dto'; import { ApiResponse } from '../dto/api-response.dto';
@Injectable() @Injectable()
export class TransformInterceptor<T> export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
implements NestInterceptor<T, ApiResponse<T>> intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
{
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<ApiResponse<T>> {
return next.handle().pipe( return next.handle().pipe(
map((data) => { map((data) => {
if (data instanceof ApiResponse) return data; if (data instanceof ApiResponse) return data;

View File

@ -5,10 +5,7 @@ export default registerAs('app', () => ({
moex: { moex: {
baseUrl: process.env.MOEX_BASE_URL || 'https://iss.moex.com/iss', baseUrl: process.env.MOEX_BASE_URL || 'https://iss.moex.com/iss',
rateLimit: parseInt(process.env.MOEX_RATE_LIMIT || '10', 10), rateLimit: parseInt(process.env.MOEX_RATE_LIMIT || '10', 10),
circuitBreakerThreshold: parseInt( circuitBreakerThreshold: parseInt(process.env.MOEX_CIRCUIT_BREAKER_THRESHOLD || '5', 10),
process.env.MOEX_CIRCUIT_BREAKER_THRESHOLD || '5',
10,
),
circuitBreakerResetSeconds: parseInt( circuitBreakerResetSeconds: parseInt(
process.env.MOEX_CIRCUIT_BREAKER_RESET_SECONDS || '30', process.env.MOEX_CIRCUIT_BREAKER_RESET_SECONDS || '30',
10, 10,

View File

@ -20,10 +20,7 @@ async function bootstrap() {
app.enableCors(); app.enableCors();
const config = new DocumentBuilder() const config = new DocumentBuilder().setTitle('MoexVibe API').setVersion('1.0.0').build();
.setTitle('MoexVibe API')
.setVersion('1.0.0')
.build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);

View File

@ -16,7 +16,11 @@ describe('BondsService', () => {
MoexClientService, MoexClientService,
{ {
provide: 'CACHE_MANAGER', provide: 'CACHE_MANAGER',
useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() }, useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
}, },
CacheService, CacheService,
], ],

View File

@ -10,7 +10,11 @@ export class BondsService {
) {} ) {}
async getBond(secid: string) { async getBond(secid: string) {
const { data: bond, fromCache, cachedAt } = await this.cache.getOrFetch( const {
data: bond,
fromCache,
cachedAt,
} = await this.cache.getOrFetch(
'bond', 'bond',
[secid], [secid],
() => this.moexClient.getBondData(secid), () => this.moexClient.getBondData(secid),
@ -71,7 +75,11 @@ export class BondsService {
} }
async getMarketData(secid: string) { async getMarketData(secid: string) {
const { data: mkt, fromCache, cachedAt } = await this.cache.getOrFetch( const {
data: mkt,
fromCache,
cachedAt,
} = await this.cache.getOrFetch(
'marketdata', 'marketdata',
['bonds', secid], ['bonds', secid],
() => this.moexClient.getBondMarketData(secid), () => this.moexClient.getBondMarketData(secid),

View File

@ -17,7 +17,11 @@ describe('CandlesService', () => {
MoexClientService, MoexClientService,
{ {
provide: 'CACHE_MANAGER', provide: 'CACHE_MANAGER',
useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() }, useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
}, },
CacheService, CacheService,
], ],

View File

@ -25,8 +25,7 @@ export class CandlesService {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch( const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'candles', 'candles',
[market, secid, String(moexInterval), from, till], [market, secid, String(moexInterval), from, till],
() => () => this.moexClient.getCandles('stock', market, secid, moexInterval, from, till),
this.moexClient.getCandles('stock', market, secid, moexInterval, from, till),
'candlesTtl', 'candlesTtl',
); );

View File

@ -9,9 +9,7 @@ describe('MoexClientService', () => {
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [ imports: [ConfigModule.forRoot({ load: [configuration] })],
ConfigModule.forRoot({ load: [configuration] }),
],
providers: [MoexClientService], providers: [MoexClientService],
}).compile(); }).compile();

View File

@ -25,19 +25,9 @@ export class MoexClientService {
constructor(private configService: ConfigService) { constructor(private configService: ConfigService) {
const baseUrl = this.configService.get<string>('app.moex.baseUrl')!; const baseUrl = this.configService.get<string>('app.moex.baseUrl')!;
this.threshold = this.configService.get<number>( this.threshold = this.configService.get<number>('app.moex.circuitBreakerThreshold', 5);
'app.moex.circuitBreakerThreshold', this.resetMs = this.configService.get<number>('app.moex.circuitBreakerResetSeconds', 30) * 1000;
5, const rateLimit = this.configService.get<number>('app.moex.rateLimit', 10);
);
this.resetMs =
this.configService.get<number>(
'app.moex.circuitBreakerResetSeconds',
30,
) * 1000;
const rateLimit = this.configService.get<number>(
'app.moex.rateLimit',
10,
);
this.client = axios.create({ this.client = axios.create({
baseURL: baseUrl, baseURL: baseUrl,
@ -163,7 +153,9 @@ export class MoexClientService {
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null, open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
low: mkt ? parseFloat((mkt.LOW as string) || '') : null, low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null, high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
last: mkt ? parseFloat((mkt.LAST as string) || '') : parseFloat((share.PREVPRICE as string) || ''), last: mkt
? parseFloat((mkt.LAST as string) || '')
: parseFloat((share.PREVPRICE as string) || ''),
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null, lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null, lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0, volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
@ -278,11 +270,7 @@ export class MoexClientService {
})); }));
} }
async getHistory( async getHistory(secid: string, from: string, till: string): Promise<MoexHistoryEntry[]> {
secid: string,
from: string,
till: string,
): Promise<MoexHistoryEntry[]> {
const data = await this.request<Record<string, unknown>>( const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities/${secid}`, `/engines/stock/markets/shares/securities/${secid}`,
{ from, till }, { from, till },
@ -304,11 +292,7 @@ export class MoexClientService {
})); }));
} }
async getBondHistory( async getBondHistory(secid: string, from: string, till: string): Promise<MoexBondHistoryEntry[]> {
secid: string,
from: string,
till: string,
): Promise<MoexBondHistoryEntry[]> {
const data = await this.request<Record<string, unknown>>( const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities/${secid}`, `/engines/stock/markets/bonds/securities/${secid}`,
{ from, till }, { from, till },
@ -320,8 +304,7 @@ export class MoexClientService {
return this.extractTable(data, tableName).map((h) => ({ return this.extractTable(data, tableName).map((h) => ({
tradeDate: h.TRADEDATE as string, tradeDate: h.TRADEDATE as string,
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null, close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
legalClosePrice: legalClosePrice: h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null,
h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null,
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null, waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null, yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null,
duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null, duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null,

View File

@ -8,7 +8,15 @@ describe('SecuritiesController', () => {
let service: SecuritiesService; let service: SecuritiesService;
const mockResults = [ const mockResults = [
{ secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбербанк', type: 'share', listLevel: 1, currency: 'RUB', price: null }, {
secid: 'SBER',
isin: 'RU0009029540',
shortName: 'Сбербанк',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: null,
},
]; ];
const mockService = { const mockService = {
@ -18,9 +26,7 @@ describe('SecuritiesController', () => {
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [SecuritiesController], controllers: [SecuritiesController],
providers: [ providers: [{ provide: SecuritiesService, useValue: mockService }],
{ provide: SecuritiesService, useValue: mockService },
],
}).compile(); }).compile();
controller = module.get<SecuritiesController>(SecuritiesController); controller = module.get<SecuritiesController>(SecuritiesController);

View File

@ -17,7 +17,11 @@ describe('SecuritiesService', () => {
MoexClientService, MoexClientService,
{ {
provide: 'CACHE_MANAGER', provide: 'CACHE_MANAGER',
useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() }, useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
}, },
CacheService, CacheService,
], ],

View File

@ -28,9 +28,14 @@ export class SecuritiesService {
const results = await this.moexClient.searchSecurities(query); const results = await this.moexClient.searchSecurities(query);
return results return results
.map((s): SearchResultItem | null => { .map((s): SearchResultItem | null => {
const type = (s.group === 'stock_shares' || s.type === 'common_share' || s.type === 'preferred_share') const type =
? 'share' as const s.group === 'stock_shares' ||
: (s.group === 'stock_bonds' ? 'bond' as const : null); s.type === 'common_share' ||
s.type === 'preferred_share'
? ('share' as const)
: s.group === 'stock_bonds'
? ('bond' as const)
: null;
if (!type) return null; if (!type) return null;
return { return {
secid: s.secid, secid: s.secid,

View File

@ -16,7 +16,11 @@ describe('SharesService', () => {
MoexClientService, MoexClientService,
{ {
provide: 'CACHE_MANAGER', provide: 'CACHE_MANAGER',
useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() }, useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
}, },
CacheService, CacheService,
], ],

View File

@ -11,7 +11,14 @@ export class SharesService {
async getShare(secid: string) { async getShare(secid: string) {
const desc = await this.moexClient.getSecurityDescription(secid); const desc = await this.moexClient.getSecurityDescription(secid);
if (!desc || !(desc.group === 'stock_shares' || desc.type === 'common_share' || desc.type === 'preferred_share')) { if (
!desc ||
!(
desc.group === 'stock_shares' ||
desc.type === 'common_share' ||
desc.type === 'preferred_share'
)
) {
throw new NotFoundException(`Share ${secid} not found`); throw new NotFoundException(`Share ${secid} not found`);
} }
@ -55,7 +62,11 @@ export class SharesService {
} }
async getMarketData(secid: string) { async getMarketData(secid: string) {
const { data: marketData, fromCache, cachedAt } = await this.cache.getOrFetch( const {
data: marketData,
fromCache,
cachedAt,
} = await this.cache.getOrFetch(
'marketdata', 'marketdata',
['shares', secid], ['shares', secid],
() => this.moexClient.getShareMarketData(secid), () => this.moexClient.getShareMarketData(secid),

View File

@ -15,7 +15,10 @@ import type {
const BASE = ''; const BASE = '';
async function request<T>(path: string, params?: Record<string, string>): Promise<{ data: T; meta: ApiResponseMeta }> { async function request<T>(
path: string,
params?: Record<string, string>,
): Promise<{ data: T; meta: ApiResponseMeta }> {
const url = new URL(`${BASE}${path}`, window.location.origin); const url = new URL(`${BASE}${path}`, window.location.origin);
if (params) { if (params) {
for (const [k, v] of Object.entries(params)) { for (const [k, v] of Object.entries(params)) {
@ -37,19 +40,31 @@ export function searchSecurities(
type: 'all' | 'share' | 'bond' = 'all', type: 'all' | 'share' | 'bond' = 'all',
limit = 20, limit = 20,
): Promise<{ data: SearchResultItem[]; meta: ApiResponseMeta }> { ): Promise<{ data: SearchResultItem[]; meta: ApiResponseMeta }> {
return request<SearchResultItem[]>('/api/v1/securities/search', { q, type, limit: String(limit) }); return request<SearchResultItem[]>('/api/v1/securities/search', {
q,
type,
limit: String(limit),
});
} }
export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> { export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> {
return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`); return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`);
} }
export function getShareMarketData(secid: string): Promise<{ data: StockMarketData; meta: ApiResponseMeta }> { export function getShareMarketData(
return request<StockMarketData>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/marketdata`); 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 }> { export function getShareDividends(
return request<DividendItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`); secid: string,
): Promise<{ data: DividendItem[]; meta: ApiResponseMeta }> {
return request<DividendItem[]>(
`/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`,
);
} }
export function getShareHistory( export function getShareHistory(
@ -57,15 +72,22 @@ export function getShareHistory(
from: string, from: string,
till: string, till: string,
): Promise<{ data: ShareHistoryItem[]; meta: ApiResponseMeta }> { ): Promise<{ data: ShareHistoryItem[]; meta: ApiResponseMeta }> {
return request<ShareHistoryItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/history`, { from, till }); return request<ShareHistoryItem[]>(
`/api/v1/securities/shares/${encodeURIComponent(secid)}/history`,
{ from, till },
);
} }
export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> { export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> {
return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`); return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`);
} }
export function getBondMarketData(secid: string): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> { export function getBondMarketData(
return request<BondMarketData>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`); secid: string,
): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> {
return request<BondMarketData>(
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`,
);
} }
export function getBondHistory( export function getBondHistory(
@ -73,7 +95,10 @@ export function getBondHistory(
from: string, from: string,
till: string, till: string,
): Promise<{ data: BondHistoryItem[]; meta: ApiResponseMeta }> { ): Promise<{ data: BondHistoryItem[]; meta: ApiResponseMeta }> {
return request<BondHistoryItem[]>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`, { from, till }); return request<BondHistoryItem[]>(
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`,
{ from, till },
);
} }
export function getShareCandles( export function getShareCandles(

View File

@ -4,427 +4,427 @@
*/ */
export interface paths { export interface paths {
"/api/v1/health": { '/api/v1/health': {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path?: never; path?: never;
cookie?: never; cookie?: never;
};
/** Проверка состояния сервиса */
get: operations["HealthController_check"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
}; };
"/api/v1/securities/search": { /** Проверка состояния сервиса */
parameters: { get: operations['HealthController_check'];
query?: never; put?: never;
header?: never; post?: never;
path?: never; delete?: never;
cookie?: never; options?: never;
}; head?: never;
/** Поиск по инструментам */ patch?: never;
get: operations["SecuritiesController_search"]; trace?: never;
put?: never; };
post?: never; '/api/v1/securities/search': {
delete?: never; parameters: {
options?: never; query?: never;
head?: never; header?: never;
patch?: never; path?: never;
trace?: never; cookie?: never;
}; };
"/api/v1/securities/shares/{secid}": { /** Поиск по инструментам */
parameters: { get: operations['SecuritiesController_search'];
query?: never; put?: never;
header?: never; post?: never;
path?: never; delete?: never;
cookie?: never; options?: never;
}; head?: never;
/** Получить спецификацию акции */ patch?: never;
get: operations["SharesController_getShare"]; trace?: never;
put?: never; };
post?: never; '/api/v1/securities/shares/{secid}': {
delete?: never; parameters: {
options?: never; query?: never;
head?: never; header?: never;
patch?: never; path?: never;
trace?: never; cookie?: never;
}; };
"/api/v1/securities/shares/{secid}/marketdata": { /** Получить спецификацию акции */
parameters: { get: operations['SharesController_getShare'];
query?: never; put?: never;
header?: never; post?: never;
path?: never; delete?: never;
cookie?: never; options?: never;
}; head?: never;
/** Получить рыночные данные акции */ patch?: never;
get: operations["SharesController_getMarketData"]; trace?: never;
put?: never; };
post?: never; '/api/v1/securities/shares/{secid}/marketdata': {
delete?: never; parameters: {
options?: never; query?: never;
head?: never; header?: never;
patch?: never; path?: never;
trace?: never; cookie?: never;
}; };
"/api/v1/securities/shares/{secid}/dividends": { /** Получить рыночные данные акции */
parameters: { get: operations['SharesController_getMarketData'];
query?: never; put?: never;
header?: never; post?: never;
path?: never; delete?: never;
cookie?: never; options?: never;
}; head?: never;
/** Получить дивиденды */ patch?: never;
get: operations["SharesController_getDividends"]; trace?: never;
put?: never; };
post?: never; '/api/v1/securities/shares/{secid}/dividends': {
delete?: never; parameters: {
options?: never; query?: never;
head?: never; header?: never;
patch?: never; path?: never;
trace?: never; cookie?: never;
}; };
"/api/v1/securities/shares/{secid}/history": { /** Получить дивиденды */
parameters: { get: operations['SharesController_getDividends'];
query?: never; put?: never;
header?: never; post?: never;
path?: never; delete?: never;
cookie?: never; options?: never;
}; head?: never;
/** Получить дневную историю торгов акции */ patch?: never;
get: operations["SharesController_getHistory"]; trace?: never;
put?: never; };
post?: never; '/api/v1/securities/shares/{secid}/history': {
delete?: never; parameters: {
options?: never; query?: never;
head?: never; header?: never;
patch?: never; path?: never;
trace?: never; cookie?: never;
}; };
"/api/v1/securities/bonds/{secid}": { /** Получить дневную историю торгов акции */
parameters: { get: operations['SharesController_getHistory'];
query?: never; put?: never;
header?: never; post?: never;
path?: never; delete?: never;
cookie?: never; options?: never;
}; head?: never;
/** Получить спецификацию облигации */ patch?: never;
get: operations["BondsController_getBond"]; trace?: never;
put?: never; };
post?: never; '/api/v1/securities/bonds/{secid}': {
delete?: never; parameters: {
options?: never; query?: never;
head?: never; header?: never;
patch?: never; path?: never;
trace?: never; cookie?: never;
}; };
"/api/v1/securities/bonds/{secid}/marketdata": { /** Получить спецификацию облигации */
parameters: { get: operations['BondsController_getBond'];
query?: never; put?: never;
header?: never; post?: never;
path?: never; delete?: never;
cookie?: never; options?: never;
}; head?: never;
/** Получить рыночные данные облигации */ patch?: never;
get: operations["BondsController_getMarketData"]; trace?: never;
put?: never; };
post?: never; '/api/v1/securities/bonds/{secid}/marketdata': {
delete?: never; parameters: {
options?: never; query?: never;
head?: never; header?: never;
patch?: never; path?: never;
trace?: never; cookie?: never;
}; };
"/api/v1/securities/bonds/{secid}/history": { /** Получить рыночные данные облигации */
parameters: { get: operations['BondsController_getMarketData'];
query?: never; put?: never;
header?: never; post?: never;
path?: never; delete?: never;
cookie?: never; options?: never;
}; head?: never;
/** Получить дневную историю торгов облигации */ patch?: never;
get: operations["BondsController_getHistory"]; trace?: never;
put?: never; };
post?: never; '/api/v1/securities/bonds/{secid}/history': {
delete?: never; parameters: {
options?: never; query?: never;
head?: never; header?: never;
patch?: never; path?: never;
trace?: never; cookie?: never;
}; };
"/api/v1/securities/shares/{secid}/candles": { /** Получить дневную историю торгов облигации */
parameters: { get: operations['BondsController_getHistory'];
query?: never; put?: never;
header?: never; post?: never;
path?: never; delete?: never;
cookie?: never; options?: never;
}; head?: never;
/** Получить свечи акции */ patch?: never;
get: operations["CandlesController_getShareCandles"]; trace?: never;
put?: never; };
post?: never; '/api/v1/securities/shares/{secid}/candles': {
delete?: never; parameters: {
options?: never; query?: never;
head?: never; header?: never;
patch?: never; path?: never;
trace?: never; cookie?: never;
}; };
"/api/v1/securities/bonds/{secid}/candles": { /** Получить свечи акции */
parameters: { get: operations['CandlesController_getShareCandles'];
query?: never; put?: never;
header?: never; post?: never;
path?: never; delete?: never;
cookie?: never; options?: never;
}; head?: never;
/** Получить свечи облигации */ patch?: never;
get: operations["CandlesController_getBondCandles"]; trace?: never;
put?: never; };
post?: never; '/api/v1/securities/bonds/{secid}/candles': {
delete?: never; parameters: {
options?: never; query?: never;
head?: never; header?: never;
patch?: never; path?: never;
trace?: never; cookie?: never;
}; };
/** Получить свечи облигации */
get: operations['CandlesController_getBondCandles'];
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 {
schemas: never; schemas: never;
responses: never; responses: never;
parameters: never; parameters: never;
requestBodies: never; requestBodies: never;
headers: never; headers: never;
pathItems: never; pathItems: never;
} }
export type $defs = Record<string, never>; export type $defs = Record<string, never>;
export interface operations { export interface operations {
HealthController_check: { HealthController_check: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
path?: never; path?: never;
cookie?: never; cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
}; };
SecuritiesController_search: { requestBody?: never;
parameters: { responses: {
query: { 200: {
/** @description Поисковый запрос (тикер, название, ISIN) */ headers: {
q: string; [name: string]: unknown;
type?: "all" | "share" | "bond";
limit?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
}; };
content?: never;
};
}; };
SharesController_getShare: { };
parameters: { SecuritiesController_search: {
query?: never; parameters: {
header?: never; query: {
path: { /** @description Поисковый запрос (тикер, название, ISIN) */
secid: string; q: string;
}; type?: 'all' | 'share' | 'bond';
cookie?: never; limit?: number;
}; };
requestBody?: never; header?: never;
responses: { path?: never;
200: { cookie?: never;
headers: {
[name: string]: unknown;
};
content?: never;
};
};
}; };
SharesController_getMarketData: { requestBody?: never;
parameters: { responses: {
query?: never; 200: {
header?: never; headers: {
path: { [name: string]: unknown;
secid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
}; };
content?: never;
};
}; };
SharesController_getDividends: { };
parameters: { SharesController_getShare: {
query?: never; parameters: {
header?: never; query?: never;
path: { header?: never;
secid: string; path: {
}; secid: string;
cookie?: never; };
}; cookie?: never;
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
}; };
SharesController_getHistory: { requestBody?: never;
parameters: { responses: {
query: { 200: {
from: string; headers: {
till: string; [name: string]: unknown;
};
header?: never;
path: {
secid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
}; };
content?: never;
};
}; };
BondsController_getBond: { };
parameters: { SharesController_getMarketData: {
query?: never; parameters: {
header?: never; query?: never;
path: { header?: never;
secid: string; path: {
}; secid: string;
cookie?: never; };
}; cookie?: never;
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
}; };
BondsController_getMarketData: { requestBody?: never;
parameters: { responses: {
query?: never; 200: {
header?: never; headers: {
path: { [name: string]: unknown;
secid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
}; };
content?: never;
};
}; };
BondsController_getHistory: { };
parameters: { SharesController_getDividends: {
query: { parameters: {
from: string; query?: never;
till: string; header?: never;
}; path: {
header?: never; secid: string;
path: { };
secid: string; cookie?: never;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
}; };
CandlesController_getShareCandles: { requestBody?: never;
parameters: { responses: {
query: { 200: {
interval: "1h" | "24h"; headers: {
from: string; [name: string]: unknown;
till: string;
};
header?: never;
path: {
secid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
}; };
content?: never;
};
}; };
CandlesController_getBondCandles: { };
parameters: { SharesController_getHistory: {
query: { parameters: {
interval: "1h" | "24h"; query: {
from: string; from: string;
till: string; till: string;
}; };
header?: never; header?: never;
path: { path: {
secid: string; secid: string;
}; };
cookie?: never; cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
}; };
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
BondsController_getBond: {
parameters: {
query?: never;
header?: never;
path: {
secid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
BondsController_getMarketData: {
parameters: {
query?: never;
header?: never;
path: {
secid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
BondsController_getHistory: {
parameters: {
query: {
from: string;
till: string;
};
header?: never;
path: {
secid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CandlesController_getShareCandles: {
parameters: {
query: {
interval: '1h' | '24h';
from: string;
till: string;
};
header?: never;
path: {
secid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
CandlesController_getBondCandles: {
parameters: {
query: {
interval: '1h' | '24h';
from: string;
till: string;
};
header?: never;
path: {
secid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
} }

View File

@ -15,22 +15,27 @@ export function BondDetails({ bond }: BondDetailsProps) {
const md = bond.marketData; const md = bond.marketData;
return ( return (
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}> <div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<h2 style={{ fontSize: 28, fontWeight: 700 }}>{bond.shortName}</h2> <h2 style={{ fontSize: 28, fontWeight: 700 }}>{bond.shortName}</h2>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}> <div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>{bond.isin}</div>
{bond.isin}
</div>
</div> </div>
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}> <div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>{md.price.toFixed(2)}%</div>
{md.price.toFixed(2)}%
</div>
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<div style={rowStyle}> <div style={rowStyle}>
<span>Номинал</span> <span>Номинал</span>
<span>{bond.faceValue.toLocaleString('ru-RU')} {bond.faceUnit}</span> <span>
{bond.faceValue.toLocaleString('ru-RU')} {bond.faceUnit}
</span>
</div> </div>
<div style={rowStyle}> <div style={rowStyle}>
<span>Дата погашения</span> <span>Дата погашения</span>
@ -38,7 +43,9 @@ export function BondDetails({ bond }: BondDetailsProps) {
</div> </div>
<div style={rowStyle}> <div style={rowStyle}>
<span>Купон</span> <span>Купон</span>
<span>{md.couponValue} {md.couponPercent != null ? `(${md.couponPercent}%)` : ''}</span> <span>
{md.couponValue} {md.couponPercent != null ? `(${md.couponPercent}%)` : ''}
</span>
</div> </div>
<div style={rowStyle}> <div style={rowStyle}>
<span>Период купона</span> <span>Период купона</span>

View File

@ -14,7 +14,15 @@ export function Layout() {
gap: 24, gap: 24,
}} }}
> >
<Link to="/" style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', textDecoration: 'none' }}> <Link
to="/"
style={{
fontSize: 20,
fontWeight: 700,
color: 'var(--color-text)',
textDecoration: 'none',
}}
>
MoexVibe MoexVibe
</Link> </Link>
<SearchBar /> <SearchBar />

View File

@ -32,7 +32,10 @@ export function SearchBar() {
type="text" type="text"
placeholder="Поиск акций и облигаций..." placeholder="Поиск акций и облигаций..."
value={query} value={query}
onChange={e => { setQuery(e.target.value); setOpen(true); }} onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
style={{ style={{
width: '100%', width: '100%',
@ -66,13 +69,15 @@ export function SearchBar() {
<li style={{ padding: 12, color: '#888' }}>Ничего не найдено</li> <li style={{ padding: 12, color: '#888' }}>Ничего не найдено</li>
)} )}
{!isLoading && {!isLoading &&
results?.map(item => ( results?.map((item) => (
<li <li
key={item.secid} key={item.secid}
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);
setQuery(''); setQuery('');
navigate(item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`); navigate(
item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`,
);
}} }}
style={{ style={{
padding: '10px 12px', padding: '10px 12px',
@ -82,14 +87,16 @@ export function SearchBar() {
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
}} }}
onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')} onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')}
onMouseLeave={e => (e.currentTarget.style.background = '')} onMouseLeave={(e) => (e.currentTarget.style.background = '')}
> >
<span> <span>
<strong>{item.shortName}</strong> <strong>{item.shortName}</strong>
<span style={{ marginLeft: 8, color: '#888', fontSize: 12 }}>{item.secid}</span> <span style={{ marginLeft: 8, color: '#888', fontSize: 12 }}>{item.secid}</span>
</span> </span>
<span style={{ fontSize: 12, color: item.type === 'share' ? '#1976d2' : '#2e7d32' }}> <span
style={{ fontSize: 12, color: item.type === 'share' ? '#1976d2' : '#2e7d32' }}
>
{item.type === 'share' ? 'Акция' : 'Облигация'} {item.type === 'share' ? 'Акция' : 'Облигация'}
</span> </span>
</li> </li>

View File

@ -16,7 +16,14 @@ export function StockDetails({ stock }: StockDetailsProps) {
const isPositive = md.change >= 0; const isPositive = md.change >= 0;
return ( return (
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}> <div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<h2 style={{ fontSize: 28, fontWeight: 700 }}> <h2 style={{ fontSize: 28, fontWeight: 700 }}>
{stock.shortName} ({stock.secid}) {stock.shortName} ({stock.secid})
@ -28,8 +35,14 @@ export function StockDetails({ stock }: StockDetailsProps) {
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}> <div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>
{md.price.toLocaleString('ru-RU', { minimumFractionDigits: 2 })}{' '} {md.price.toLocaleString('ru-RU', { minimumFractionDigits: 2 })}{' '}
<span style={{ fontSize: 18, color: isPositive ? 'var(--color-positive)' : 'var(--color-negative)' }}> <span
{isPositive ? '+' : ''}{md.change.toFixed(2)} ({md.changePercent.toFixed(2)}%) style={{
fontSize: 18,
color: isPositive ? 'var(--color-positive)' : 'var(--color-negative)',
}}
>
{isPositive ? '+' : ''}
{md.change.toFixed(2)} ({md.changePercent.toFixed(2)}%)
</span> </span>
</div> </div>
@ -53,9 +66,7 @@ export function StockDetails({ stock }: StockDetailsProps) {
<div style={rowStyle}> <div style={rowStyle}>
<span>Капитализация</span> <span>Капитализация</span>
<span> <span>
{md.issueCapitalization {md.issueCapitalization ? (md.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽' : '—'}
? (md.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽'
: '—'}
</span> </span>
</div> </div>
<div style={rowStyle}> <div style={rowStyle}>

View File

@ -18,7 +18,14 @@ export function BondPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<BondDetails bond={bond} /> <BondDetails bond={bond} />
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}> <div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<h3 style={{ marginBottom: 16 }}>График цены</h3> <h3 style={{ marginBottom: 16 }}>График цены</h3>
<PriceChart data={candles ?? []} /> <PriceChart data={candles ?? []} />
</div> </div>

View File

@ -1,15 +1,11 @@
export function HomePage() { export function HomePage() {
return ( return (
<div style={{ textAlign: 'center', paddingTop: 120 }}> <div style={{ textAlign: 'center', paddingTop: 120 }}>
<h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 12 }}> <h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 12 }}>MoexVibe</h1>
MoexVibe
</h1>
<p style={{ color: 'var(--color-text-secondary)', fontSize: 16, marginBottom: 32 }}> <p style={{ color: 'var(--color-text-secondary)', fontSize: 16, marginBottom: 32 }}>
Анализ акций и облигаций Московской биржи Анализ акций и облигаций Московской биржи
</p> </p>
<p style={{ color: '#888', fontSize: 13 }}> <p style={{ color: '#888', fontSize: 13 }}>Введите название или тикер в строку поиска выше</p>
Введите название или тикер в строку поиска выше
</p>
<p style={{ color: '#aaa', fontSize: 12, marginTop: 8 }}> <p style={{ color: '#aaa', fontSize: 12, marginTop: 8 }}>
Данные задерживаются на 15 минут &middot; Бесплатный API MOEX ISS Данные задерживаются на 15 минут &middot; Бесплатный API MOEX ISS
</p> </p>

View File

@ -20,13 +20,27 @@ export function StockPage() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<StockDetails stock={stock} /> <StockDetails stock={stock} />
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}> <div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<h3 style={{ marginBottom: 16 }}>График цены</h3> <h3 style={{ marginBottom: 16 }}>График цены</h3>
<PriceChart data={candles ?? []} /> <PriceChart data={candles ?? []} />
</div> </div>
{dividends && dividends.length > 0 && ( {dividends && dividends.length > 0 && (
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}> <div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<h3 style={{ marginBottom: 16 }}>Дивиденды</h3> <h3 style={{ marginBottom: 16 }}>Дивиденды</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead> <thead>