Compare commits
10 Commits
f2be35d544
...
9d191763e1
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d191763e1 | |||
| d4a7ccf1c9 | |||
| 9ae3906971 | |||
| 4b753266b3 | |||
| 49437817a5 | |||
| 5d80c510eb | |||
| d850f3f4fd | |||
| 45042d9cc7 | |||
| fdb8b52e38 | |||
| f0a9a13d57 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -3,3 +3,6 @@ dist/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
vite.config.d.ts
|
||||
vite.config.js
|
||||
|
||||
67
AGENTS.md
Normal file
67
AGENTS.md
Normal 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
51
README.md
Normal 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
|
||||
```
|
||||
@ -15,7 +15,8 @@ async function bootstrap() {
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor());
|
||||
app.use(new RequestLoggingMiddleware().use);
|
||||
const reqLogMiddleware = new RequestLoggingMiddleware();
|
||||
app.use(reqLogMiddleware.use.bind(reqLogMiddleware));
|
||||
|
||||
app.enableCors();
|
||||
|
||||
|
||||
31
apps/backend/src/modules/bonds/bonds.controller.ts
Normal file
31
apps/backend/src/modules/bonds/bonds.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/backend/src/modules/bonds/bonds.module.ts
Normal file
10
apps/backend/src/modules/bonds/bonds.module.ts
Normal 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 {}
|
||||
37
apps/backend/src/modules/bonds/bonds.service.spec.ts
Normal file
37
apps/backend/src/modules/bonds/bonds.service.spec.ts
Normal 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);
|
||||
});
|
||||
124
apps/backend/src/modules/bonds/bonds.service.ts
Normal file
124
apps/backend/src/modules/bonds/bonds.service.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
101
apps/backend/src/modules/bonds/dto/bond-response.dto.ts
Normal file
101
apps/backend/src/modules/bonds/dto/bond-response.dto.ts
Normal 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;
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { CacheModule as NestCacheModule } from '@nestjs/cache-manager';
|
||||
import { CacheService } from './cache.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
NestCacheModule.register({
|
||||
|
||||
28
apps/backend/src/modules/candles/candles.controller.ts
Normal file
28
apps/backend/src/modules/candles/candles.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/backend/src/modules/candles/candles.module.ts
Normal file
10
apps/backend/src/modules/candles/candles.module.ts
Normal 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 {}
|
||||
44
apps/backend/src/modules/candles/candles.service.spec.ts
Normal file
44
apps/backend/src/modules/candles/candles.service.spec.ts
Normal 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);
|
||||
});
|
||||
47
apps/backend/src/modules/candles/candles.service.ts
Normal file
47
apps/backend/src/modules/candles/candles.service.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
21
apps/backend/src/modules/candles/dto/candles-query.dto.ts
Normal file
21
apps/backend/src/modules/candles/dto/candles-query.dto.ts
Normal 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;
|
||||
}
|
||||
@ -182,7 +182,7 @@ export class MoexClientService {
|
||||
{ boards: boardId },
|
||||
);
|
||||
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;
|
||||
|
||||
return {
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
import { StockMarketDataDto } from './share-response.dto';
|
||||
|
||||
export class ShareMarketDataResponseDto extends StockMarketDataDto {}
|
||||
68
apps/backend/src/modules/shares/dto/share-response.dto.ts
Normal file
68
apps/backend/src/modules/shares/dto/share-response.dto.ts
Normal 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;
|
||||
}
|
||||
38
apps/backend/src/modules/shares/shares.controller.ts
Normal file
38
apps/backend/src/modules/shares/shares.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/backend/src/modules/shares/shares.module.ts
Normal file
10
apps/backend/src/modules/shares/shares.module.ts
Normal 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 {}
|
||||
37
apps/backend/src/modules/shares/shares.service.spec.ts
Normal file
37
apps/backend/src/modules/shares/shares.service.spec.ts
Normal 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);
|
||||
});
|
||||
127
apps/backend/src/modules/shares/shares.service.ts
Normal file
127
apps/backend/src/modules/shares/shares.service.ts
Normal 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
12
apps/frontend/index.html
Normal 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>
|
||||
28
apps/frontend/package.json
Normal file
28
apps/frontend/package.json
Normal 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
10
apps/frontend/src/App.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AppRoutes } from './routes';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
103
apps/frontend/src/api/client.ts
Normal file
103
apps/frontend/src/api/client.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
124
apps/frontend/src/api/responses.ts
Normal file
124
apps/frontend/src/api/responses.ts
Normal 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;
|
||||
}
|
||||
430
apps/frontend/src/api/types.ts
Normal file
430
apps/frontend/src/api/types.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
74
apps/frontend/src/components/BondDetails.tsx
Normal file
74
apps/frontend/src/components/BondDetails.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/frontend/src/components/Layout.tsx
Normal file
27
apps/frontend/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
apps/frontend/src/components/PriceChart.tsx
Normal file
71
apps/frontend/src/components/PriceChart.tsx
Normal 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} />;
|
||||
}
|
||||
101
apps/frontend/src/components/SearchBar.tsx
Normal file
101
apps/frontend/src/components/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
apps/frontend/src/components/StockDetails.tsx
Normal file
72
apps/frontend/src/components/StockDetails.tsx
Normal 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} · {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>
|
||||
);
|
||||
}
|
||||
14
apps/frontend/src/hooks/useBond.ts
Normal file
14
apps/frontend/src/hooks/useBond.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
14
apps/frontend/src/hooks/useBondCandles.ts
Normal file
14
apps/frontend/src/hooks/useBondCandles.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
15
apps/frontend/src/hooks/useSearch.ts
Normal file
15
apps/frontend/src/hooks/useSearch.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
14
apps/frontend/src/hooks/useStock.ts
Normal file
14
apps/frontend/src/hooks/useStock.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
14
apps/frontend/src/hooks/useStockCandles.ts
Normal file
14
apps/frontend/src/hooks/useStockCandles.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
14
apps/frontend/src/hooks/useStockDividends.ts
Normal file
14
apps/frontend/src/hooks/useStockDividends.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
23
apps/frontend/src/main.tsx
Normal file
23
apps/frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
27
apps/frontend/src/pages/BondPage.tsx
Normal file
27
apps/frontend/src/pages/BondPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/frontend/src/pages/HomePage.tsx
Normal file
18
apps/frontend/src/pages/HomePage.tsx
Normal 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 минут · Бесплатный API MOEX ISS
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
apps/frontend/src/pages/StockPage.tsx
Normal file
53
apps/frontend/src/pages/StockPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/frontend/src/routes.tsx
Normal file
17
apps/frontend/src/routes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
apps/frontend/src/styles.css
Normal file
31
apps/frontend/src/styles.css
Normal 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
1
apps/frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
24
apps/frontend/tsconfig.json
Normal file
24
apps/frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
15
apps/frontend/tsconfig.node.json
Normal file
15
apps/frontend/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
21
apps/frontend/vite.config.ts
Normal file
21
apps/frontend/vite.config.ts
Normal 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
20
docker-compose.yml
Normal 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
14
docker/Dockerfile.backend
Normal 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"]
|
||||
12
docker/Dockerfile.frontend
Normal file
12
docker/Dockerfile.frontend
Normal 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
15
docker/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
24
docs/architecture/overview.md
Normal file
24
docs/architecture/overview.md
Normal 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
822
package-lock.json
generated
@ -48,6 +48,26 @@
|
||||
"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": {
|
||||
"version": "17.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz",
|
||||
@ -223,6 +243,173 @@
|
||||
"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": {
|
||||
"version": "7.29.7",
|
||||
"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_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": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
|
||||
@ -1027,6 +1334,10 @@
|
||||
"resolved": "apps/backend",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@moex-vibe/frontend": {
|
||||
"resolved": "apps/frontend",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nestjs/axios": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.3.tgz",
|
||||
@ -1589,6 +1900,118 @@
|
||||
"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": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
|
||||
@ -2251,6 +2674,32 @@
|
||||
"@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": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
|
||||
@ -2275,6 +2724,51 @@
|
||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||
"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": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
@ -2382,6 +2876,13 @@
|
||||
"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": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||
@ -2396,6 +2897,27 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
@ -2635,6 +3157,27 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
|
||||
@ -3529,6 +4072,13 @@
|
||||
"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": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
|
||||
@ -3681,6 +4231,13 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@ -3778,6 +4335,13 @@
|
||||
"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": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
@ -3859,6 +4423,13 @@
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@ -4690,6 +5261,12 @@
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
|
||||
@ -5428,6 +6015,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": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
@ -5661,11 +6261,20 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
@ -5680,6 +6289,19 @@
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||
@ -5771,6 +6393,15 @@
|
||||
"integrity": "sha512-NdB6O6QvlGMCoG003m0YIKG2+Xw7DjmCZhmc1RH+K6HncADUbRf8TZeLegxBBN1VFyPHcNpPTKpIhYLXzJVy1Q==",
|
||||
"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": {
|
||||
"version": "1.2.4",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.3.7",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
|
||||
@ -6331,6 +6974,86 @@
|
||||
"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": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
@ -6804,6 +7527,31 @@
|
||||
"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": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
@ -6811,6 +7559,48 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
@ -7124,6 +7914,15 @@
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"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": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
|
||||
@ -8153,6 +8952,13 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@ -8587,6 +9393,20 @@
|
||||
"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": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user