Compare commits

..

10 Commits

Author SHA1 Message Date
9d191763e1 docs: add AGENTS.md with dev instructions and SDD workflow 2026-06-13 19:36:05 +03:00
d4a7ccf1c9 feat: Sprint 6 - documentation and infrastructure
- Docker setup (Dockerfile.backend, Dockerfile.frontend, nginx, docker-compose)
- Architecture overview with Mermaid sequence diagram
- README with quick start and Docker instructions
- Verify OpenAPI spec and ADR docs
2026-06-13 19:27:52 +03:00
9ae3906971 feat: Sprint 5 - frontend detail pages with charts
- Add useStock, useStockCandles, useStockDividends, useBond, useBondCandles hooks
- Add StockDetails component with full share info
- Add BondDetails component with full bond info (NKD, YTM, duration, coupon)
- Add PriceChart component using lightweight-charts (candlestick chart)
- Update StockPage and BondPage with details, charts, and dividends table
2026-06-13 19:21:03 +03:00
4b753266b3 feat: frontend API layer, Layout, SearchBar, HomePage
- Add typed API response types and fetch client (responses.ts + client.ts)
- Add useSearch hook with TanStack Query
- Add Layout with header and search bar
- Add SearchBar with debounced search and dropdown
- Implement HomePage, StockPage, BondPage with data loading
2026-06-13 19:16:10 +03:00
49437817a5 fix: bind middleware logger context; generate OpenAPI types for frontend
- Fix RequestLoggingMiddleware this.logger crash by binding the use() method to the instance
- Generate src/api/types.ts from live backend via openapi-typescript
2026-06-13 19:13:08 +03:00
5d80c510eb feat: scaffold React + Vite frontend with routing 2026-06-13 19:10:42 +03:00
d850f3f4fd feat: add candles module and share history endpoint 2026-06-13 19:08:33 +03:00
45042d9cc7 feat: add bonds endpoint with market data and history 2026-06-13 19:07:18 +03:00
fdb8b52e38 fix: make CacheModule global for DI availability across all modules 2026-06-13 19:05:09 +03:00
f0a9a13d57 feat: add shares endpoint with market data and dividends 2026-06-13 19:04:30 +03:00
55 changed files with 3135 additions and 4 deletions

3
.gitignore vendored
View File

@ -3,3 +3,6 @@ dist/
.env .env
*.log *.log
.DS_Store .DS_Store
*.tsbuildinfo
vite.config.d.ts
vite.config.js

67
AGENTS.md Normal file
View File

@ -0,0 +1,67 @@
# MoexVibe — Инструкция для агента
## Репозиторий
npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/frontend` (React + Vite).
## Обязательный подход к разработке
- **SDD (Specification-Driven Development)**: перед написанием кода сначала сформировать спецификацию — PRD, доменную модель, ADR, OpenAPI-контракт, архитектуру фронтенда и бэкенда, план реализации по этапам.
- **Superpowers**: обязательно использовать скиллы (Skills) при старте любой задачи — brainstorming, frontend-design, test-driven-development, writing-plans, executing-plans, requesting-code-review.
- **MCP-инструменты**: использовать MCP для анализа и генерации дизайна, работы с API, генерации кода.
## Команды
| Команда | Что делает |
|---|---|
| `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 |
| `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 |
| `npm run build:backend` | `nest build` |
| `npm run build:frontend` | `tsc -b && vite build` (в две фазы) |
| `npm run test:backend` | `vitest run` (SWC, не ts-jest) |
| `npm run lint` | ESLint только для бэкенда |
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
| `npm run codegen -w apps/frontend` | `openapi-typescript` из локального Swagger → `src/api/types.ts` |
Один тест: `npx vitest run path/to/test.spec.ts -w apps/backend`
## Переменные окружения
| Переменная | По умолчанию | Описание |
|---|---|---|
| `PORT` | 3000 | Порт бэкенда |
| `MOEX_BASE_URL` | `https://iss.moex.com/iss` | Endpoint MOEX ISS |
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
| `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |
| `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) |
| `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) |
## Архитектура
- **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую.
- Feature-модули: `MoexClientModule` (глобальный), `CacheModule` (глобальный), `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `HealthModule`.
- `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт).
- In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002).
- Глобальный префикс NestJS: `/api/v1`. Swagger: `/api/docs`.
- Глобальный ValidationPipe (`transform: true, whitelist: true`), `HttpExceptionFilter`, `TransformInterceptor`, middleware логирования запросов.
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
- Алиасы: `@/*``src/*` в обоих пакетах.
## Фронтенд
- React 18 + react-router-dom v6 + TanStack Query v5.
- `lightweight-charts` v4 для графиков цен.
- `openapi-fetch` + рукописные типы `responses.ts` (не полностью codegen'овые).
- TanStack Query по умолчанию: `staleTime: 900s`, `retry: 2`, `refetchOnWindowFocus: false`.
- Конвенция ключей запросов: `['stock', secid]`, `['securities', 'search', query]`, и т.д.
- CSS через `styles.css` (CSS custom properties, без CSS-in-JS или Tailwind).
## Стиль кода
- Prettier: одинарные кавычки, trailing commas, printWidth 100, точки с запятой.
- Бэкенд: `const`, PascalCase для модулей/контроллеров/сервисов, DTO в `dto/` внутри каждого модуля.
- Бэкенд использует SWC через `unplugin-swc` (vitest config).
- Тесты фронтенда отсутствуют.
- CI/CD в репозитории нет.

51
README.md Normal file
View File

@ -0,0 +1,51 @@
# MoexVibe
Веб-приложение для анализа ценных бумаг Московской биржи (MOEX).
## Tech Stack
- **Backend:** NestJS, TypeScript, OpenAPI (Swagger)
- **Frontend:** React, TypeScript, Vite, TanStack Query, lightweight-charts
- **Infrastructure:** Docker, docker-compose
## Quick Start
```bash
# Install dependencies
npm install
# Start backend (http://localhost:3000)
npm run dev:backend
# Start frontend (http://localhost:5173)
npm run dev:frontend
```
Swagger UI: http://localhost:3000/api/docs
## Docker
```bash
docker compose up --build
```
- Frontend: http://localhost:80
- Backend: http://localhost:3000
## Tests
```bash
npm run test:backend
```
## Project Structure
```
apps/
backend/ — NestJS API (single point of access to MOEX ISS)
frontend/ — React SPA with Vite
docs/
architecture/ — ADR documents and diagrams
openapi/ — OpenAPI specification
superpowers/ — Design specs and implementation plans
```

View File

@ -15,7 +15,8 @@ async function bootstrap() {
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor()); app.useGlobalInterceptors(new TransformInterceptor());
app.use(new RequestLoggingMiddleware().use); const reqLogMiddleware = new RequestLoggingMiddleware();
app.use(reqLogMiddleware.use.bind(reqLogMiddleware));
app.enableCors(); app.enableCors();

View File

@ -0,0 +1,31 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { BondsService } from './bonds.service';
@ApiTags('Bonds')
@Controller('securities/bonds')
export class BondsController {
constructor(private readonly bondsService: BondsService) {}
@Get(':secid')
@ApiOperation({ summary: 'Получить спецификацию облигации' })
async getBond(@Param('secid') secid: string) {
return this.bondsService.getBond(secid);
}
@Get(':secid/marketdata')
@ApiOperation({ summary: 'Получить рыночные данные облигации' })
async getMarketData(@Param('secid') secid: string) {
return this.bondsService.getMarketData(secid);
}
@Get(':secid/history')
@ApiOperation({ summary: 'Получить дневную историю торгов облигации' })
async getHistory(
@Param('secid') secid: string,
@Query('from') from: string,
@Query('till') till: string,
) {
return this.bondsService.getHistory(secid, from, till);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BondsController } from './bonds.controller';
import { BondsService } from './bonds.service';
@Module({
controllers: [BondsController],
providers: [BondsService],
exports: [BondsService],
})
export class BondsModule {}

View File

@ -0,0 +1,37 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { BondsService } from './bonds.service';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
describe('BondsService', () => {
let service: BondsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [
BondsService,
MoexClientService,
{
provide: 'CACHE_MANAGER',
useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() },
},
CacheService,
],
}).compile();
service = module.get<BondsService>(BondsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should return OFZ bond data for SU26207RMFS9', async () => {
const result = await service.getBond('SU26207RMFS9');
expect(result.data.secid).toBe('SU26207RMFS9');
expect(result.data.marketData).toBeDefined();
}, 15000);
});

View File

@ -0,0 +1,124 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
@Injectable()
export class BondsService {
constructor(
private readonly moexClient: MoexClientService,
private readonly cache: CacheService,
) {}
async getBond(secid: string) {
const { data: bond, fromCache, cachedAt } = await this.cache.getOrFetch(
'bond',
[secid],
() => this.moexClient.getBondData(secid),
'securityTtl',
);
if (!bond) {
throw new NotFoundException(`Bond ${secid} not found`);
}
const { data: mkt } = await this.cache.getOrFetch(
'marketdata',
['bonds', secid],
() => this.moexClient.getBondMarketData(secid),
'marketDataTtl',
);
return {
data: {
secid: bond.secid,
isin: bond.isin,
name: bond.shortName,
shortName: bond.shortName,
latName: null,
listLevel: bond.listLevel,
issueSize: bond.issueSize,
faceValue: bond.faceValue,
faceUnit: bond.isin.startsWith('XS') ? 'USD' : 'RUB',
matDate: bond.matDate,
couponValue: bond.couponValue ?? 0,
couponPercent: bond.couponPercent,
couponPeriod: bond.couponPeriod,
nextCoupon: bond.nextCoupon,
accruedInt: bond.accruedInt ?? 0,
bondType: bond.bondType,
bondSubType: bond.bondSubType,
offerDate: bond.offerDate,
buybackDate: bond.buybackDate,
marketData: {
price: mkt?.last ?? bond.prevPrice ?? 0,
yieldToMaturity: mkt?.yield ?? bond.yieldAtPrevWaprice ?? null,
duration: mkt?.duration ?? null,
accruedInt: bond.accruedInt ?? 0,
couponValue: bond.couponValue ?? 0,
couponPercent: bond.couponPercent,
nextCouponDate: bond.nextCoupon,
open: mkt?.open ?? 0,
high: mkt?.high ?? null,
low: mkt?.low ?? null,
volume: mkt?.volume ?? 0,
updatedAt: mkt?.updateTime
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
: new Date().toISOString(),
},
},
meta: { fromCache, cachedAt },
};
}
async getMarketData(secid: string) {
const { data: mkt, fromCache, cachedAt } = await this.cache.getOrFetch(
'marketdata',
['bonds', secid],
() => this.moexClient.getBondMarketData(secid),
'marketDataTtl',
);
if (!mkt) {
throw new NotFoundException(`Market data for bond ${secid} not found`);
}
return {
data: {
price: mkt.last ?? 0,
yieldToMaturity: mkt.yield ?? null,
duration: mkt.duration ?? null,
accruedInt: 0,
couponValue: 0,
couponPercent: null,
nextCouponDate: null,
open: mkt.open ?? 0,
high: mkt.high ?? null,
low: mkt.low ?? null,
volume: mkt.volume ?? 0,
updatedAt: mkt.updateTime
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
: new Date().toISOString(),
},
meta: { fromCache, cachedAt },
};
}
async getHistory(secid: string, from: string, till: string) {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'history',
['bonds', secid, from, till],
() => this.moexClient.getBondHistory(secid, from, till),
'historyTtl',
);
return {
data: data.map((h) => ({
date: h.tradeDate,
closePrice: h.legalClosePrice ?? h.close ?? 0,
yieldClose: h.yieldClose ?? null,
duration: h.duration ?? null,
})),
meta: { fromCache, cachedAt },
};
}
}

View File

@ -0,0 +1,101 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class BondMarketDataDto {
@ApiProperty({ description: 'Цена в % от номинала', example: 100.45 })
price!: number;
@ApiPropertyOptional({ example: 12.71 })
yieldToMaturity!: number | null;
@ApiPropertyOptional()
duration!: number | null;
@ApiProperty({ example: 29.48 })
accruedInt!: number;
@ApiProperty({ example: 40.64 })
couponValue!: number;
@ApiPropertyOptional({ example: 8.15 })
couponPercent!: number | null;
@ApiPropertyOptional({ example: '2026-08-05' })
nextCouponDate!: string | null;
@ApiProperty()
open!: number;
@ApiPropertyOptional()
high!: number | null;
@ApiPropertyOptional()
low!: number | null;
@ApiProperty()
volume!: number;
@ApiProperty()
updatedAt!: string;
}
export class BondResponseDto {
@ApiProperty({ example: 'SU26207RMFS9' })
secid!: string;
@ApiProperty({ example: 'RU000A0JS3W6' })
isin!: string;
@ApiProperty({ example: 'ОФЗ-ПД 26207 03/02/27' })
name!: string;
@ApiProperty({ example: 'ОФЗ 26207' })
shortName!: string;
@ApiPropertyOptional()
latName!: string | null;
@ApiProperty({ example: 1 })
listLevel!: number;
@ApiProperty({ example: 370200604 })
issueSize!: number;
@ApiProperty({ example: 1000 })
faceValue!: number;
@ApiProperty({ example: 'RUB' })
faceUnit!: string;
@ApiProperty({ example: '2027-02-03' })
matDate!: string;
@ApiProperty({ example: 40.64 })
couponValue!: number;
@ApiPropertyOptional({ example: 8.15 })
couponPercent!: number | null;
@ApiProperty({ example: 182 })
couponPeriod!: number;
@ApiProperty({ example: '2026-08-05' })
nextCoupon!: string | null;
@ApiProperty({ example: 29.48 })
accruedInt!: number;
@ApiProperty({ example: 'Фикс с известным купоном' })
bondType!: string;
@ApiProperty({ example: 'До погашения' })
bondSubType!: string;
@ApiPropertyOptional()
offerDate!: string | null;
@ApiPropertyOptional()
buybackDate!: string | null;
@ApiProperty()
marketData!: BondMarketDataDto;
}

View File

@ -1,7 +1,8 @@
import { Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { CacheModule as NestCacheModule } from '@nestjs/cache-manager'; import { CacheModule as NestCacheModule } from '@nestjs/cache-manager';
import { CacheService } from './cache.service'; import { CacheService } from './cache.service';
@Global()
@Module({ @Module({
imports: [ imports: [
NestCacheModule.register({ NestCacheModule.register({

View File

@ -0,0 +1,28 @@
import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { CandlesService } from './candles.service';
import { CandlesQueryDto } from './dto/candles-query.dto';
@ApiTags('Candles')
@Controller('securities')
export class CandlesController {
constructor(private readonly candlesService: CandlesService) {}
@Get('shares/:secid/candles')
@ApiOperation({ summary: 'Получить свечи акции' })
async getShareCandles(
@Param('secid') secid: string,
@Query(ValidationPipe) query: CandlesQueryDto,
) {
return this.candlesService.getCandles('shares', secid, query.interval, query.from, query.till);
}
@Get('bonds/:secid/candles')
@ApiOperation({ summary: 'Получить свечи облигации' })
async getBondCandles(
@Param('secid') secid: string,
@Query(ValidationPipe) query: CandlesQueryDto,
) {
return this.candlesService.getCandles('bonds', secid, query.interval, query.from, query.till);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CandlesController } from './candles.controller';
import { CandlesService } from './candles.service';
@Module({
controllers: [CandlesController],
providers: [CandlesService],
exports: [CandlesService],
})
export class CandlesModule {}

View File

@ -0,0 +1,44 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { CandlesService } from './candles.service';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
import { CandleInterval } from './dto/candles-query.dto';
describe('CandlesService', () => {
let service: CandlesService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [
CandlesService,
MoexClientService,
{
provide: 'CACHE_MANAGER',
useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() },
},
CacheService,
],
}).compile();
service = module.get<CandlesService>(CandlesService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should return daily candles for SBER', async () => {
const result = await service.getCandles(
'shares',
'SBER',
CandleInterval.DAY,
'2026-05-01',
'2026-06-01',
);
expect(result.data.length).toBeGreaterThan(0);
expect(result.data[0].open).toBeDefined();
}, 15000);
});

View File

@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import { CandleInterval } from './dto/candles-query.dto';
@Injectable()
export class CandlesService {
constructor(
private readonly moexClient: MoexClientService,
private readonly cache: CacheService,
) {}
private mapInterval(interval: CandleInterval): 60 | 24 {
return interval === CandleInterval.HOUR ? 60 : 24;
}
async getCandles(
market: 'shares' | 'bonds',
secid: string,
interval: CandleInterval,
from: string,
till: string,
) {
const moexInterval = this.mapInterval(interval);
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'candles',
[market, secid, String(moexInterval), from, till],
() =>
this.moexClient.getCandles('stock', market, secid, moexInterval, from, till),
'candlesTtl',
);
return {
data: data.map((c) => ({
open: c.open,
high: c.high,
low: c.low,
close: c.close,
volume: c.volume,
value: c.value,
begin: c.begin,
end: c.end,
})),
meta: { fromCache, cachedAt },
};
}
}

View File

@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsEnum, IsDateString } from 'class-validator';
export enum CandleInterval {
HOUR = '1h',
DAY = '24h',
}
export class CandlesQueryDto {
@ApiProperty({ enum: CandleInterval })
@IsEnum(CandleInterval)
interval!: CandleInterval;
@ApiProperty({ format: 'date', example: '2025-06-13' })
@IsDateString()
from!: string;
@ApiProperty({ format: 'date', example: '2026-06-13' })
@IsDateString()
till!: string;
}

View File

@ -182,7 +182,7 @@ export class MoexClientService {
{ boards: boardId }, { boards: boardId },
); );
const rows = this.extractTable(data, 'securities'); const rows = this.extractTable(data, 'securities');
const bond = rows.find((r) => r.BOARDID === boardId); const bond = rows.find((r) => r.BOARDID === boardId) || rows[0];
if (!bond) return null; if (!bond) return null;
return { return {

View File

@ -0,0 +1,3 @@
import { StockMarketDataDto } from './share-response.dto';
export class ShareMarketDataResponseDto extends StockMarketDataDto {}

View File

@ -0,0 +1,68 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class StockMarketDataDto {
@ApiProperty({ example: 322.35 })
price!: number;
@ApiProperty({ example: 1.15 })
change!: number;
@ApiProperty({ example: 0.36 })
changePercent!: number;
@ApiProperty({ example: 321.3 })
open!: number;
@ApiPropertyOptional({ example: 322.66 })
high!: number | null;
@ApiPropertyOptional({ example: 321.2 })
low!: number | null;
@ApiProperty({ example: 1925163 })
volume!: number;
@ApiProperty({ example: 620184479 })
value!: number;
@ApiPropertyOptional({ example: 6958336818320 })
issueCapitalization!: number | null;
@ApiProperty()
updatedAt!: string;
}
export class ShareResponseDto {
@ApiProperty({ example: 'SBER' })
secid!: string;
@ApiProperty({ example: 'RU0009029540' })
isin!: string;
@ApiProperty({ example: 'Сбербанк России ПАО ао' })
name!: string;
@ApiProperty({ example: 'Сбербанк' })
shortName!: string;
@ApiPropertyOptional()
latName!: string | null;
@ApiProperty({ example: 1 })
listLevel!: number;
@ApiProperty({ example: 21586948000 })
issueSize!: number;
@ApiProperty({ example: 3 })
faceValue!: number;
@ApiProperty({ example: 'RUB' })
faceUnit!: string;
@ApiProperty({ example: 'common_share' })
type!: string;
@ApiProperty()
marketData!: StockMarketDataDto;
}

View File

@ -0,0 +1,38 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { SharesService } from './shares.service';
@ApiTags('Shares')
@Controller('securities/shares')
export class SharesController {
constructor(private readonly sharesService: SharesService) {}
@Get(':secid')
@ApiOperation({ summary: 'Получить спецификацию акции' })
async getShare(@Param('secid') secid: string) {
const share = await this.sharesService.getShare(secid);
return { data: share, meta: { cachedAt: null, fromCache: false } };
}
@Get(':secid/marketdata')
@ApiOperation({ summary: 'Получить рыночные данные акции' })
async getMarketData(@Param('secid') secid: string) {
return this.sharesService.getMarketData(secid);
}
@Get(':secid/dividends')
@ApiOperation({ summary: 'Получить дивиденды' })
async getDividends(@Param('secid') secid: string) {
return this.sharesService.getDividends(secid);
}
@Get(':secid/history')
@ApiOperation({ summary: 'Получить дневную историю торгов акции' })
async getHistory(
@Param('secid') secid: string,
@Query('from') from: string,
@Query('till') till: string,
) {
return this.sharesService.getHistory(secid, from, till);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SharesController } from './shares.controller';
import { SharesService } from './shares.service';
@Module({
controllers: [SharesController],
providers: [SharesService],
exports: [SharesService],
})
export class SharesModule {}

View File

@ -0,0 +1,37 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { SharesService } from './shares.service';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
describe('SharesService', () => {
let service: SharesService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [
SharesService,
MoexClientService,
{
provide: 'CACHE_MANAGER',
useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() },
},
CacheService,
],
}).compile();
service = module.get<SharesService>(SharesService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should return SBER share data', async () => {
const share = await service.getShare('SBER');
expect(share.secid).toBe('SBER');
expect(share.marketData).toBeDefined();
}, 15000);
});

View File

@ -0,0 +1,127 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
@Injectable()
export class SharesService {
constructor(
private readonly moexClient: MoexClientService,
private readonly cache: CacheService,
) {}
async getShare(secid: string) {
const desc = await this.moexClient.getSecurityDescription(secid);
if (!desc || !(desc.group === 'stock_shares' || desc.type === 'common_share' || desc.type === 'preferred_share')) {
throw new NotFoundException(`Share ${secid} not found`);
}
const { data: marketData } = await this.cache.getOrFetch(
'marketdata',
['shares', secid],
() => this.moexClient.getShareMarketData(secid),
'marketDataTtl',
);
const price = marketData?.last ?? (marketData ? null : 0);
const change = marketData?.lastChange ?? 0;
const changePercent = marketData?.lastChangePrcnt ?? 0;
return {
secid: desc.secid,
isin: desc.isin,
name: desc.name,
shortName: desc.shortName,
latName: desc.latName,
listLevel: desc.listLevel,
issueSize: desc.issueSize,
faceValue: desc.faceValue,
faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
type: desc.type,
marketData: {
price: price ?? 0,
change,
changePercent,
open: marketData?.open ?? 0,
high: marketData?.high ?? null,
low: marketData?.low ?? null,
volume: marketData?.volume ?? 0,
value: marketData?.value ?? 0,
issueCapitalization: marketData?.issueCapitalization ?? null,
updatedAt: marketData?.updateTime
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
: new Date().toISOString(),
},
};
}
async getMarketData(secid: string) {
const { data: marketData, fromCache, cachedAt } = await this.cache.getOrFetch(
'marketdata',
['shares', secid],
() => this.moexClient.getShareMarketData(secid),
'marketDataTtl',
);
if (!marketData) {
throw new NotFoundException(`Market data for ${secid} not found`);
}
return {
data: {
price: marketData.last ?? 0,
change: marketData.lastChange ?? 0,
changePercent: marketData.lastChangePrcnt ?? 0,
open: marketData.open ?? 0,
high: marketData.high ?? null,
low: marketData.low ?? null,
volume: marketData.volume ?? 0,
value: marketData.value ?? 0,
issueCapitalization: marketData.issueCapitalization ?? null,
updatedAt: marketData.updateTime
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
: new Date().toISOString(),
},
meta: { fromCache, cachedAt },
};
}
async getDividends(secid: string) {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'dividends',
[secid],
() => this.moexClient.getDividends(secid),
'dividendsTtl',
);
return {
data: data.map((d) => ({
registryCloseDate: d.registryCloseDate,
value: d.value,
currency: d.currencyId,
})),
meta: { fromCache, cachedAt },
};
}
async getHistory(secid: string, from: string, till: string) {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'history',
['shares', secid, from, till],
() => this.moexClient.getHistory(secid, from, till),
'historyTtl',
);
return {
data: data.map((h) => ({
date: h.tradeDate,
open: h.open ?? 0,
high: h.high ?? 0,
low: h.low ?? 0,
close: h.close ?? 0,
volume: h.volume,
value: h.value,
})),
meta: { fromCache, cachedAt },
};
}
}

12
apps/frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MoexVibe</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,28 @@
{
"name": "@moex-vibe/frontend",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts"
},
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.20.0",
"@tanstack/react-query": "^5.20.0",
"openapi-fetch": "^0.9.0",
"lightweight-charts": "^4.1.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.3.0",
"vite": "^5.4.0",
"openapi-typescript": "^7.0.0"
}
}

10
apps/frontend/src/App.tsx Normal file
View File

@ -0,0 +1,10 @@
import { BrowserRouter } from 'react-router-dom';
import { AppRoutes } from './routes';
export default function App() {
return (
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
);
}

View File

@ -0,0 +1,103 @@
import type {
ApiEnvelope,
ApiResponseMeta,
ShareResponse,
StockMarketData,
DividendItem,
ShareHistoryItem,
BondResponse,
BondMarketData,
BondHistoryItem,
CandleItem,
SearchResultItem,
HealthResponse,
} from './responses';
const BASE = '';
async function request<T>(path: string, params?: Record<string, string>): 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 res = await fetch(url.toString());
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json();
return json.data;
}
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,124 @@
export interface ApiResponseMeta {
cachedAt: string | null;
fromCache: boolean;
}
export interface ApiEnvelope<T> {
data: T;
meta: ApiResponseMeta;
}
export interface StockMarketData {
price: number;
change: number;
changePercent: number;
open: number;
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;
yieldToMaturity: number | null;
duration: number | null;
accruedInt: number;
couponValue: number;
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;
}

View File

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

@ -0,0 +1,74 @@
import type { BondResponse } from '../api/responses';
interface BondDetailsProps {
bond: BondResponse;
}
const rowStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: '1px solid #eee',
};
export function BondDetails({ bond }: BondDetailsProps) {
const md = bond.marketData;
return (
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}>
<div style={{ marginBottom: 16 }}>
<h2 style={{ fontSize: 28, fontWeight: 700 }}>{bond.shortName}</h2>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>
{bond.isin}
</div>
</div>
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>
{md.price.toFixed(2)}%
</div>
<div style={{ marginTop: 16 }}>
<div style={rowStyle}>
<span>Номинал</span>
<span>{bond.faceValue.toLocaleString('ru-RU')} {bond.faceUnit}</span>
</div>
<div style={rowStyle}>
<span>Дата погашения</span>
<span>{bond.matDate}</span>
</div>
<div style={rowStyle}>
<span>Купон</span>
<span>{md.couponValue} {md.couponPercent != null ? `(${md.couponPercent}%)` : ''}</span>
</div>
<div style={rowStyle}>
<span>Период купона</span>
<span>{bond.couponPeriod} дней</span>
</div>
<div style={rowStyle}>
<span>Следующий купон</span>
<span>{md.nextCouponDate ?? '—'}</span>
</div>
<div style={rowStyle}>
<span>НКД</span>
<span>{md.accruedInt.toFixed(2)} </span>
</div>
<div style={rowStyle}>
<span>Доходность к погашению</span>
<span>{md.yieldToMaturity != null ? md.yieldToMaturity.toFixed(2) + '%' : '—'}</span>
</div>
<div style={rowStyle}>
<span>Дюрация</span>
<span>{md.duration != null ? md.duration.toFixed(2) : '—'}</span>
</div>
<div style={rowStyle}>
<span>Тип</span>
<span>{bond.bondType}</span>
</div>
<div style={rowStyle}>
<span>ISIN</span>
<span>{bond.isin}</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,27 @@
import { Outlet, Link } from 'react-router-dom';
import { SearchBar } from './SearchBar';
export function Layout() {
return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
<header
style={{
background: 'var(--color-surface)',
borderBottom: '1px solid #e0e0e0',
padding: '12px 24px',
display: 'flex',
alignItems: 'center',
gap: 24,
}}
>
<Link to="/" style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', textDecoration: 'none' }}>
MoexVibe
</Link>
<SearchBar />
</header>
<main style={{ flex: 1, padding: 24, maxWidth: 1200, width: '100%', margin: '0 auto' }}>
<Outlet />
</main>
</div>
);
}

View File

@ -0,0 +1,71 @@
import { useEffect, useRef } from 'react';
import { createChart, ColorType, CandlestickData, Time } from 'lightweight-charts';
interface PriceChartProps {
data: Array<{
open: number;
high: number;
low: number;
close: number;
begin: string;
}>;
height?: number;
}
export function PriceChart({ data, height = 400 }: PriceChartProps) {
const chartContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!chartContainerRef.current) return;
const chart = createChart(chartContainerRef.current, {
layout: {
background: { type: ColorType.Solid, color: '#ffffff' },
textColor: '#333',
},
width: chartContainerRef.current.clientWidth,
height,
grid: {
vertLines: { color: '#f0f0f0' },
horzLines: { color: '#f0f0f0' },
},
timeScale: {
timeVisible: false,
},
});
const candleSeries = chart.addCandlestickSeries({
upColor: '#2e7d32',
downColor: '#c62828',
borderDownColor: '#c62828',
borderUpColor: '#2e7d32',
wickDownColor: '#c62828',
wickUpColor: '#2e7d32',
});
const chartData: CandlestickData[] = data.map((candle) => ({
time: (new Date(candle.begin).getTime() / 1000) as Time,
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
}));
candleSeries.setData(chartData);
chart.timeScale().fitContent();
const handleResize = () => {
if (chartContainerRef.current) {
chart.applyOptions({ width: chartContainerRef.current.clientWidth });
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.remove();
};
}, [data, height]);
return <div ref={chartContainerRef} />;
}

View File

@ -0,0 +1,101 @@
import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSearch } from '../hooks/useSearch';
export function SearchBar() {
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const id = setTimeout(() => setDebounced(query), 300);
return () => clearTimeout(id);
}, [query]);
const { data: results, isLoading } = useSearch(debounced);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
const showResults = open && debounced.length >= 2;
return (
<div ref={ref} style={{ position: 'relative', width: 400, maxWidth: '100%' }}>
<input
type="text"
placeholder="Поиск акций и облигаций..."
value={query}
onChange={e => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 6,
fontSize: 14,
}}
/>
{showResults && (
<ul
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
background: '#fff',
border: '1px solid #ddd',
borderRadius: 6,
marginTop: 4,
padding: 0,
listStyle: 'none',
zIndex: 100,
maxHeight: 360,
overflowY: 'auto',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}}
>
{isLoading && <li style={{ padding: 12, color: '#888' }}>Загрузка...</li>}
{!isLoading && results && results.length === 0 && (
<li style={{ padding: 12, color: '#888' }}>Ничего не найдено</li>
)}
{!isLoading &&
results?.map(item => (
<li
key={item.secid}
onClick={() => {
setOpen(false);
setQuery('');
navigate(item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`);
}}
style={{
padding: '10px 12px',
cursor: 'pointer',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')}
onMouseLeave={e => (e.currentTarget.style.background = '')}
>
<span>
<strong>{item.shortName}</strong>
<span style={{ marginLeft: 8, color: '#888', fontSize: 12 }}>{item.secid}</span>
</span>
<span style={{ fontSize: 12, color: item.type === 'share' ? '#1976d2' : '#2e7d32' }}>
{item.type === 'share' ? 'Акция' : 'Облигация'}
</span>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -0,0 +1,72 @@
import type { ShareResponse } from '../api/responses';
interface StockDetailsProps {
stock: ShareResponse;
}
const rowStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: '1px solid #eee',
};
export function StockDetails({ stock }: StockDetailsProps) {
const md = stock.marketData;
const isPositive = md.change >= 0;
return (
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}>
<div style={{ marginBottom: 16 }}>
<h2 style={{ fontSize: 28, fontWeight: 700 }}>
{stock.shortName} ({stock.secid})
</h2>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>
{stock.name} &middot; {stock.isin}
</div>
</div>
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>
{md.price.toLocaleString('ru-RU', { minimumFractionDigits: 2 })}{' '}
<span style={{ fontSize: 18, color: isPositive ? 'var(--color-positive)' : 'var(--color-negative)' }}>
{isPositive ? '+' : ''}{md.change.toFixed(2)} ({md.changePercent.toFixed(2)}%)
</span>
</div>
<div style={{ marginTop: 16 }}>
<div style={rowStyle}>
<span>Открытие</span>
<span>{md.open.toFixed(2)}</span>
</div>
<div style={rowStyle}>
<span>Максимум</span>
<span>{md.high?.toFixed(2) ?? '—'}</span>
</div>
<div style={rowStyle}>
<span>Минимум</span>
<span>{md.low?.toFixed(2) ?? '—'}</span>
</div>
<div style={rowStyle}>
<span>Объём</span>
<span>{md.volume.toLocaleString('ru-RU')}</span>
</div>
<div style={rowStyle}>
<span>Капитализация</span>
<span>
{md.issueCapitalization
? (md.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽'
: '—'}
</span>
</div>
<div style={rowStyle}>
<span>ISIN</span>
<span>{stock.isin}</span>
</div>
<div style={rowStyle}>
<span>Уровень листинга</span>
<span>{stock.listLevel}</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { getBond } from '../api/client';
import type { BondResponse } from '../api/responses';
export function useBond(secid: string) {
return useQuery<BondResponse>({
queryKey: ['bond', secid],
queryFn: async () => {
const res = await getBond(secid);
return res.data;
},
staleTime: 900_000,
});
}

View File

@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { getBondCandles } from '../api/client';
import type { CandleItem } from '../api/responses';
export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
return useQuery<CandleItem[]>({
queryKey: ['bondCandles', secid, interval, from, till],
queryFn: async () => {
const res = await getBondCandles(secid, interval, from, till);
return res.data;
},
staleTime: 3600_000,
});
}

View File

@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { searchSecurities } from '../api/client';
import type { SearchResultItem } from '../api/responses';
export function useSearch(query: string) {
return useQuery<SearchResultItem[]>({
queryKey: ['securities', 'search', query],
queryFn: async () => {
const res = await searchSecurities(query);
return res.data;
},
enabled: query.length >= 2,
staleTime: 60_000,
});
}

View File

@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { getShare } from '../api/client';
import type { ShareResponse } from '../api/responses';
export function useStock(secid: string) {
return useQuery<ShareResponse>({
queryKey: ['stock', secid],
queryFn: async () => {
const res = await getShare(secid);
return res.data;
},
staleTime: 900_000,
});
}

View File

@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { getShareCandles } from '../api/client';
import type { CandleItem } from '../api/responses';
export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
return useQuery<CandleItem[]>({
queryKey: ['stockCandles', secid, interval, from, till],
queryFn: async () => {
const res = await getShareCandles(secid, interval, from, till);
return res.data;
},
staleTime: 3600_000,
});
}

View File

@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { getShareDividends } from '../api/client';
import type { DividendItem } from '../api/responses';
export function useStockDividends(secid: string) {
return useQuery<DividendItem[]>({
queryKey: ['stockDividends', secid],
queryFn: async () => {
const res = await getShareDividends(secid);
return res.data;
},
staleTime: 86400_000,
});
}

View File

@ -0,0 +1,23 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './styles.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 900_000,
refetchOnWindowFocus: false,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
);

View File

@ -0,0 +1,27 @@
import { useParams } from 'react-router-dom';
import { useBond } from '../hooks/useBond';
import { useBondCandles } from '../hooks/useBondCandles';
import { BondDetails } from '../components/BondDetails';
import { PriceChart } from '../components/PriceChart';
export function BondPage() {
const { secid } = useParams<{ secid: string }>();
const { data: bond, isLoading, error } = useBond(secid!);
const till = new Date().toISOString().split('T')[0];
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const { data: candles } = useBondCandles(secid!, '24h', from, till);
if (isLoading) return <div>Загрузка...</div>;
if (error || !bond) return <div>Инструмент не найден</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<BondDetails bond={bond} />
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}>
<h3 style={{ marginBottom: 16 }}>График цены</h3>
<PriceChart data={candles ?? []} />
</div>
</div>
);
}

View File

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

View File

@ -0,0 +1,53 @@
import { useParams } from 'react-router-dom';
import { useStock } from '../hooks/useStock';
import { useStockCandles } from '../hooks/useStockCandles';
import { useStockDividends } from '../hooks/useStockDividends';
import { StockDetails } from '../components/StockDetails';
import { PriceChart } from '../components/PriceChart';
export function StockPage() {
const { secid } = useParams<{ secid: string }>();
const { data: stock, isLoading, error } = useStock(secid!);
const till = new Date().toISOString().split('T')[0];
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const { data: candles } = useStockCandles(secid!, '24h', from, till);
const { data: dividends } = useStockDividends(secid!);
if (isLoading) return <div>Загрузка...</div>;
if (error || !stock) return <div>Инструмент не найден</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<StockDetails stock={stock} />
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}>
<h3 style={{ marginBottom: 16 }}>График цены</h3>
<PriceChart data={candles ?? []} />
</div>
{dividends && dividends.length > 0 && (
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}>
<h3 style={{ marginBottom: 16 }}>Дивиденды</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #eee' }}>
<th style={{ textAlign: 'left', padding: 8 }}>Дата закрытия реестра</th>
<th style={{ textAlign: 'right', padding: 8 }}>Сумма</th>
</tr>
</thead>
<tbody>
{dividends.map((d, i) => (
<tr key={i} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: 8 }}>{d.registryCloseDate}</td>
<td style={{ textAlign: 'right', padding: 8 }}>
{d.value.toFixed(2)} {d.currency}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,17 @@
import { Routes, Route } from 'react-router-dom';
import { Layout } from './components/Layout';
import { HomePage } from './pages/HomePage';
import { StockPage } from './pages/StockPage';
import { BondPage } from './pages/BondPage';
export function AppRoutes() {
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<HomePage />} />
<Route path="/stocks/:secid" element={<StockPage />} />
<Route path="/bonds/:secid" element={<BondPage />} />
</Route>
</Routes>
);
}

View File

@ -0,0 +1,31 @@
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--color-bg: #f5f5f5;
--color-surface: #ffffff;
--color-text: #1a1a1a;
--color-text-secondary: #666;
--color-primary: #1976d2;
--color-positive: #2e7d32;
--color-negative: #c62828;
--border-radius: 8px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
}
a {
color: var(--color-primary);
text-decoration: none;
}

1
apps/frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"strict": true,
"composite": true,
"outDir": "./dist/tsconfig-node"
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});

20
docker-compose.yml Normal file
View File

@ -0,0 +1,20 @@
services:
backend:
build:
context: .
dockerfile: docker/Dockerfile.backend
ports:
- "3000:3000"
environment:
- PORT=3000
- MOEX_BASE_URL=https://iss.moex.com/iss
- MOEX_RATE_LIMIT=10
frontend:
build:
context: .
dockerfile: docker/Dockerfile.frontend
ports:
- "80:80"
depends_on:
- backend

14
docker/Dockerfile.backend Normal file
View File

@ -0,0 +1,14 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json tsconfig.base.json ./
COPY apps/backend/ apps/backend/
RUN npm install
RUN npm run build -w apps/backend
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=build /app/apps/backend/dist ./dist
COPY --from=build /app/apps/backend/node_modules ./node_modules
COPY --from=build /app/apps/backend/package.json ./
EXPOSE 3000
CMD ["node", "dist/main.js"]

View File

@ -0,0 +1,12 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json tsconfig.base.json ./
COPY apps/frontend/ apps/frontend/
RUN npm install
RUN npm run build -w apps/frontend
FROM nginx:alpine
COPY --from=build /app/apps/frontend/dist /usr/share/nginx/html
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

15
docker/nginx.conf Normal file
View File

@ -0,0 +1,15 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@ -0,0 +1,24 @@
# Architecture Overview
```mermaid
sequenceDiagram
participant User
participant Frontend as React SPA
participant Backend as NestJS API
participant Cache as In-Memory Cache
participant MOEX as MOEX ISS
User->>Frontend: Search / View instrument
Frontend->>Backend: GET /api/v1/securities/search?q=SBER
Backend->>Cache: getOrFetch('search:sber')
alt Cache miss
Cache->>Backend: null
Backend->>MOEX: GET /iss/securities?q=SBER
MOEX-->>Backend: raw data
Backend->>Cache: set('search:sber', normalized, TTL=3600)
else Cache hit
Cache-->>Backend: cached data
end
Backend-->>Frontend: normalized response
Frontend-->>User: rendered UI
```

822
package-lock.json generated
View File

@ -48,6 +48,26 @@
"vitest": "^1.0.0" "vitest": "^1.0.0"
} }
}, },
"apps/frontend": {
"name": "@moex-vibe/frontend",
"version": "0.0.1",
"dependencies": {
"@tanstack/react-query": "^5.20.0",
"lightweight-charts": "^4.1.0",
"openapi-fetch": "^0.9.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.20.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.0",
"openapi-typescript": "^7.0.0",
"typescript": "^5.3.0",
"vite": "^5.4.0"
}
},
"node_modules/@angular-devkit/core": { "node_modules/@angular-devkit/core": {
"version": "17.3.11", "version": "17.3.11",
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz", "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz",
@ -223,6 +243,173 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/compat-data": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
"json5": "^2.2.3",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/babel"
}
},
"node_modules/@babel/core/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/generator": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^3.0.2"
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/@babel/helper-globals": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0"
}
},
"node_modules/@babel/helper-plugin-utils": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
"integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
@ -233,6 +420,126 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/helper-validator-option": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helpers": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/plugin-transform-react-jsx-self": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
"integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-transform-react-jsx-source": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
"integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@borewit/text-codec": { "node_modules/@borewit/text-codec": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
@ -1027,6 +1334,10 @@
"resolved": "apps/backend", "resolved": "apps/backend",
"link": true "link": true
}, },
"node_modules/@moex-vibe/frontend": {
"resolved": "apps/frontend",
"link": true
},
"node_modules/@nestjs/axios": { "node_modules/@nestjs/axios": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.3.tgz", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.3.tgz",
@ -1589,6 +1900,118 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
"integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js-replace": "^1.0.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@redocly/config": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz",
"integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@redocly/openapi-core": {
"version": "1.34.15",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz",
"integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/ajv": "8.11.2",
"@redocly/config": "0.22.0",
"colorette": "1.4.0",
"https-proxy-agent": "7.0.6",
"js-levenshtein": "1.1.6",
"js-yaml": "4.1.1",
"minimatch": "5.1.9",
"pluralize": "8.0.0",
"yaml-ast-parser": "0.0.43"
},
"engines": {
"node": ">=18.17.0",
"npm": ">=9.5.0"
}
},
"node_modules/@redocly/openapi-core/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/@redocly/openapi-core/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/@redocly/openapi-core/node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@redocly/openapi-core/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.3",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/pluginutils": { "node_modules/@rollup/pluginutils": {
"version": "5.4.0", "version": "5.4.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
@ -2251,6 +2674,32 @@
"@swc/counter": "^0.1.3" "@swc/counter": "^0.1.3"
} }
}, },
"node_modules/@tanstack/query-core": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.101.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tokenizer/inflate": { "node_modules/@tokenizer/inflate": {
"version": "0.2.7", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
@ -2275,6 +2724,51 @@
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7",
"@types/babel__generator": "*",
"@types/babel__template": "*",
"@types/babel__traverse": "*"
}
},
"node_modules/@types/babel__generator": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__template": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.1.0",
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__traverse": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/body-parser": { "node_modules/@types/body-parser": {
"version": "1.19.6", "version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@ -2382,6 +2876,13 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.15.1", "version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
@ -2396,6 +2897,27 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/@types/send": { "node_modules/@types/send": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
@ -2635,6 +3157,27 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.28.0",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@rolldown/pluginutils": "1.0.0-beta.27",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.17.0"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"peerDependencies": {
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "1.6.1", "version": "1.6.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
@ -3529,6 +4072,13 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
"dev": true,
"license": "MIT"
},
"node_modules/chardet": { "node_modules/chardet": {
"version": "0.7.0", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
@ -3681,6 +4231,13 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/colorette": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@ -3778,6 +4335,13 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
@ -3859,6 +4423,13 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -4690,6 +5261,12 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/fancy-canvas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fancy-canvas/-/fancy-canvas-2.1.0.tgz",
"integrity": "sha512-nifxXJ95JNLFR2NgRV4/MxVP45G9909wJTEKz5fg/TZS20JJZA6hfgRVh/bC9bwl2zBtBNcYPjiBE4njQHVBwQ==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -5080,6 +5657,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/get-func-name": { "node_modules/get-func-name": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
@ -5428,6 +6015,19 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/index-to-position": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
"integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/inflight": { "node_modules/inflight": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@ -5661,11 +6261,20 @@
"url": "https://github.com/chalk/supports-color?sponsor=1" "url": "https://github.com/chalk/supports-color?sponsor=1"
} }
}, },
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
"integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@ -5680,6 +6289,19 @@
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
} }
}, },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
"dev": true,
"license": "MIT",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=6"
}
},
"node_modules/json-buffer": { "node_modules/json-buffer": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@ -5771,6 +6393,15 @@
"integrity": "sha512-NdB6O6QvlGMCoG003m0YIKG2+Xw7DjmCZhmc1RH+K6HncADUbRf8TZeLegxBBN1VFyPHcNpPTKpIhYLXzJVy1Q==", "integrity": "sha512-NdB6O6QvlGMCoG003m0YIKG2+Xw7DjmCZhmc1RH+K6HncADUbRf8TZeLegxBBN1VFyPHcNpPTKpIhYLXzJVy1Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lightweight-charts": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-4.2.3.tgz",
"integrity": "sha512-5kS/2hY3wNYNzhnS8Gb+GAS07DX8GPF2YVDnd2NMC85gJVQ6RLU6YrXNgNJ6eg0AnWPwCnvaGtYmGky3HiLQEw==",
"license": "Apache-2.0",
"dependencies": {
"fancy-canvas": "2.1.0"
}
},
"node_modules/lines-and-columns": { "node_modules/lines-and-columns": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@ -5871,6 +6502,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/loupe": { "node_modules/loupe": {
"version": "2.3.7", "version": "2.3.7",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
@ -6331,6 +6974,86 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/openapi-fetch": {
"version": "0.9.8",
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.9.8.tgz",
"integrity": "sha512-zM6elH0EZStD/gSiNlcPrzXcVQ/pZo3BDvC6CDwRDUt1dDzxlshpmQnpD6cZaJ39THaSmwVCxxRrPKNM1hHrDg==",
"license": "MIT",
"dependencies": {
"openapi-typescript-helpers": "^0.0.8"
}
},
"node_modules/openapi-typescript": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz",
"integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@redocly/openapi-core": "^1.34.6",
"ansi-colors": "^4.1.3",
"change-case": "^5.4.4",
"parse-json": "^8.3.0",
"supports-color": "^10.2.2",
"yargs-parser": "^21.1.1"
},
"bin": {
"openapi-typescript": "bin/cli.js"
},
"peerDependencies": {
"typescript": "^5.x"
}
},
"node_modules/openapi-typescript-helpers": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.8.tgz",
"integrity": "sha512-1eNjQtbfNi5Z/kFhagDIaIRj6qqDzhjNJKz8cmMW0CVdGwT6e1GLbAfgI0d28VTJa1A8jz82jm/4dG8qNoNS8g==",
"license": "MIT"
},
"node_modules/openapi-typescript/node_modules/parse-json": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
"integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.26.2",
"index-to-position": "^1.1.0",
"type-fest": "^4.39.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openapi-typescript/node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/openapi-typescript/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -6804,6 +7527,31 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@ -6811,6 +7559,48 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.3"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/react-router-dom": {
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.3",
"react-router": "6.30.4"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "3.6.2", "version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@ -7124,6 +7914,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/schema-utils": { "node_modules/schema-utils": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
@ -8153,6 +8952,13 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/uri-js-replace": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -8587,6 +9393,20 @@
"node": ">=0.4" "node": ">=0.4"
} }
}, },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
},
"node_modules/yaml-ast-parser": {
"version": "0.0.43",
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/yargs-parser": { "node_modules/yargs-parser": {
"version": "21.1.1", "version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",