MVP реализации MoexVibe — NestJS бэкенд + React фронтенд + CI/CD #1
54
.gitea/workflows/ci.yml
Normal file
54
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,54 @@
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 'Setup dependencies'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npx prettier --check "**/*.{ts,tsx}"
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 'Setup dependencies'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run test:backend
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 'Setup dependencies'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run build:backend
|
||||
- run: npm run build:frontend
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
vite.config.d.ts
|
||||
vite.config.js
|
||||
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"semi": true
|
||||
}
|
||||
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
|
||||
```
|
||||
23
apps/backend/.eslintrc.js
Normal file
23
apps/backend/.eslintrc.js
Normal file
@ -0,0 +1,23 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
};
|
||||
4
apps/backend/nest-cli.json
Normal file
4
apps/backend/nest-cli.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
44
apps/backend/package.json
Normal file
44
apps/backend/package.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@moex-vibe/backend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,test}/**/*.ts\"",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/axios": "^3.0.0",
|
||||
"@nestjs/cache-manager": "^2.0.0",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/swagger": "^7.0.0",
|
||||
"axios": "^1.6.0",
|
||||
"cache-manager": "^5.0.0",
|
||||
"class-transformer": "^0.5.0",
|
||||
"class-validator": "^0.14.0",
|
||||
"p-queue": "^7.3.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.0",
|
||||
"swagger-ui-express": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@swc/core": "^1.15.41",
|
||||
"@types/express": "^4.17.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"eslint": "^8.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
}
|
||||
24
apps/backend/src/app.module.ts
Normal file
24
apps/backend/src/app.module.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { CacheModule } from './modules/cache/cache.module';
|
||||
import { MoexClientModule } from './modules/moex-client/moex-client.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { SecuritiesModule } from './modules/securities/securities.module';
|
||||
import { SharesModule } from './modules/shares/shares.module';
|
||||
import { BondsModule } from './modules/bonds/bonds.module';
|
||||
import { CandlesModule } from './modules/candles/candles.module';
|
||||
import configuration from './config/configuration';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ load: [configuration], isGlobal: true }),
|
||||
CacheModule,
|
||||
MoexClientModule,
|
||||
HealthModule,
|
||||
SecuritiesModule,
|
||||
SharesModule,
|
||||
BondsModule,
|
||||
CandlesModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
24
apps/backend/src/common/dto/api-response.dto.ts
Normal file
24
apps/backend/src/common/dto/api-response.dto.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ApiResponseMeta {
|
||||
@ApiProperty({ nullable: true })
|
||||
cachedAt: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
fromCache: boolean;
|
||||
|
||||
constructor(fromCache: boolean, cachedAt: string | null = null) {
|
||||
this.fromCache = fromCache;
|
||||
this.cachedAt = cachedAt;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiResponse<T> {
|
||||
data: T;
|
||||
meta: ApiResponseMeta;
|
||||
|
||||
constructor(data: T, fromCache = false, cachedAt: string | null = null) {
|
||||
this.data = data;
|
||||
this.meta = new ApiResponseMeta(fromCache, cachedAt);
|
||||
}
|
||||
}
|
||||
20
apps/backend/src/common/dto/pagination.dto.ts
Normal file
20
apps/backend/src/common/dto/pagination.dto.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsOptional, IsInt, Min, Max } from 'class-validator';
|
||||
|
||||
export class PaginationDto {
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 20;
|
||||
}
|
||||
38
apps/backend/src/common/filters/http-exception.filter.ts
Normal file
38
apps/backend/src/common/filters/http-exception.filter.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
let error = 'Internal Server Error';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const res = exception.getResponse();
|
||||
if (typeof res === 'string') {
|
||||
message = res;
|
||||
error = exception.name;
|
||||
} else if (typeof res === 'object') {
|
||||
const r = res as Record<string, unknown>;
|
||||
message = (r.message as string) || message;
|
||||
error = (r.error as string) || exception.name;
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message,
|
||||
error,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { ApiResponse } from '../dto/api-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
|
||||
return next.handle().pipe(
|
||||
map((data) => {
|
||||
if (data instanceof ApiResponse) return data;
|
||||
return new ApiResponse(data, false, null);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class RequestLoggingMiddleware implements NestMiddleware {
|
||||
private logger = new Logger('HTTP');
|
||||
|
||||
use(req: Request, res: Response, next: NextFunction): void {
|
||||
const { method, originalUrl } = req;
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const { statusCode } = res;
|
||||
const duration = Date.now() - start;
|
||||
this.logger.log(`${method} ${originalUrl} ${statusCode} ${duration}ms`);
|
||||
});
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
22
apps/backend/src/config/configuration.ts
Normal file
22
apps/backend/src/config/configuration.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { registerAs } from '@nestjs/config';
|
||||
|
||||
export default registerAs('app', () => ({
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
moex: {
|
||||
baseUrl: process.env.MOEX_BASE_URL || 'https://iss.moex.com/iss',
|
||||
rateLimit: parseInt(process.env.MOEX_RATE_LIMIT || '10', 10),
|
||||
circuitBreakerThreshold: parseInt(process.env.MOEX_CIRCUIT_BREAKER_THRESHOLD || '5', 10),
|
||||
circuitBreakerResetSeconds: parseInt(
|
||||
process.env.MOEX_CIRCUIT_BREAKER_RESET_SECONDS || '30',
|
||||
10,
|
||||
),
|
||||
},
|
||||
cache: {
|
||||
marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
|
||||
historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
|
||||
candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10),
|
||||
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
|
||||
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
|
||||
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
|
||||
},
|
||||
}));
|
||||
32
apps/backend/src/main.ts
Normal file
32
apps/backend/src/main.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.setGlobalPrefix('api/v1');
|
||||
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new TransformInterceptor());
|
||||
const reqLogMiddleware = new RequestLoggingMiddleware();
|
||||
app.use(reqLogMiddleware.use.bind(reqLogMiddleware));
|
||||
|
||||
app.enableCors();
|
||||
|
||||
const config = new DocumentBuilder().setTitle('MoexVibe API').setVersion('1.0.0').build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
|
||||
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||
}
|
||||
bootstrap();
|
||||
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 {}
|
||||
41
apps/backend/src/modules/bonds/bonds.service.spec.ts
Normal file
41
apps/backend/src/modules/bonds/bonds.service.spec.ts
Normal file
@ -0,0 +1,41 @@
|
||||
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);
|
||||
});
|
||||
132
apps/backend/src/modules/bonds/bonds.service.ts
Normal file
132
apps/backend/src/modules/bonds/bonds.service.ts
Normal file
@ -0,0 +1,132 @@
|
||||
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;
|
||||
}
|
||||
17
apps/backend/src/modules/cache/cache.module.ts
vendored
Normal file
17
apps/backend/src/modules/cache/cache.module.ts
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { CacheModule as NestCacheModule } from '@nestjs/cache-manager';
|
||||
import { CacheService } from './cache.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
NestCacheModule.register({
|
||||
ttl: 900,
|
||||
max: 1000,
|
||||
isGlobal: true,
|
||||
}),
|
||||
],
|
||||
providers: [CacheService],
|
||||
exports: [CacheService],
|
||||
})
|
||||
export class CacheModule {}
|
||||
44
apps/backend/src/modules/cache/cache.service.ts
vendored
Normal file
44
apps/backend/src/modules/cache/cache.service.ts
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Cache } from 'cache-manager';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class CacheService {
|
||||
constructor(
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async get<T>(key: string): Promise<T | undefined> {
|
||||
return this.cacheManager.get<T>(key);
|
||||
}
|
||||
|
||||
async set(key: string, value: unknown, ttl?: number): Promise<void> {
|
||||
await this.cacheManager.set(key, value, ttl);
|
||||
}
|
||||
|
||||
private buildKey(...parts: string[]): string {
|
||||
return parts.join(':');
|
||||
}
|
||||
|
||||
async getOrFetch<T>(
|
||||
keyPrefix: string,
|
||||
keyParts: string[],
|
||||
fetchFn: () => Promise<T>,
|
||||
ttlConfigKey: string,
|
||||
): Promise<{ data: T; fromCache: boolean; cachedAt: string | null }> {
|
||||
const key = this.buildKey(keyPrefix, ...keyParts);
|
||||
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
|
||||
|
||||
const cached = await this.get<T>(key);
|
||||
if (cached !== undefined) {
|
||||
return { data: cached, fromCache: true, cachedAt: null };
|
||||
}
|
||||
|
||||
const data = await fetchFn();
|
||||
await this.set(key, data, ttl);
|
||||
|
||||
return { data, fromCache: false, cachedAt: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
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 {}
|
||||
48
apps/backend/src/modules/candles/candles.service.spec.ts
Normal file
48
apps/backend/src/modules/candles/candles.service.spec.ts
Normal file
@ -0,0 +1,48 @@
|
||||
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);
|
||||
});
|
||||
46
apps/backend/src/modules/candles/candles.service.ts
Normal file
46
apps/backend/src/modules/candles/candles.service.ts
Normal file
@ -0,0 +1,46 @@
|
||||
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 { 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;
|
||||
}
|
||||
16
apps/backend/src/modules/health/health.controller.ts
Normal file
16
apps/backend/src/modules/health/health.controller.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('Health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Проверка состояния сервиса' })
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/backend/src/modules/health/health.module.ts
Normal file
7
apps/backend/src/modules/health/health.module.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { MoexClientService } from './moex-client.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [MoexClientService],
|
||||
exports: [MoexClientService],
|
||||
})
|
||||
export class MoexClientModule {}
|
||||
@ -0,0 +1,38 @@
|
||||
import 'reflect-metadata';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { MoexClientService } from './moex-client.service';
|
||||
import configuration from '../../config/configuration';
|
||||
|
||||
describe('MoexClientService', () => {
|
||||
let service: MoexClientService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [MoexClientService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<MoexClientService>(MoexClientService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('searchSecurities', () => {
|
||||
it('should return results for SBER query', async () => {
|
||||
const results = await service.searchSecurities('SBER');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].secid).toBeDefined();
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
describe('getShareMarketData', () => {
|
||||
it('should return market data for SBER', async () => {
|
||||
const data = await service.getShareMarketData('SBER');
|
||||
expect(data).toBeDefined();
|
||||
expect(data!.secid).toBe('SBER');
|
||||
}, 15000);
|
||||
});
|
||||
});
|
||||
314
apps/backend/src/modules/moex-client/moex-client.service.ts
Normal file
314
apps/backend/src/modules/moex-client/moex-client.service.ts
Normal file
@ -0,0 +1,314 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import PQueue from 'p-queue';
|
||||
import {
|
||||
MoexSecurityDescription,
|
||||
MoexShareMarketData,
|
||||
MoexBondData,
|
||||
MoexBondMarketData,
|
||||
MoexDividend,
|
||||
MoexCandle,
|
||||
MoexHistoryEntry,
|
||||
MoexBondHistoryEntry,
|
||||
} from './moex-client.types';
|
||||
|
||||
@Injectable()
|
||||
export class MoexClientService {
|
||||
private readonly logger = new Logger(MoexClientService.name);
|
||||
private readonly client: AxiosInstance;
|
||||
private readonly queue: PQueue;
|
||||
private circuitOpen = false;
|
||||
private circuitErrorCount = 0;
|
||||
private readonly threshold: number;
|
||||
private readonly resetMs: number;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const baseUrl = this.configService.get<string>('app.moex.baseUrl')!;
|
||||
this.threshold = this.configService.get<number>('app.moex.circuitBreakerThreshold', 5);
|
||||
this.resetMs = this.configService.get<number>('app.moex.circuitBreakerResetSeconds', 30) * 1000;
|
||||
const rateLimit = this.configService.get<number>('app.moex.rateLimit', 10);
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: baseUrl,
|
||||
timeout: 10000,
|
||||
paramsSerializer: { indexes: null },
|
||||
});
|
||||
|
||||
this.queue = new PQueue({
|
||||
interval: 1000,
|
||||
intervalCap: rateLimit,
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T>(path: string, params?: Record<string, string>): Promise<T> {
|
||||
if (this.circuitOpen) {
|
||||
throw new Error('Circuit breaker is open — MOEX requests paused');
|
||||
}
|
||||
|
||||
return this.queue.add(async () => {
|
||||
try {
|
||||
const jsonPath = path + '.json';
|
||||
const response = await this.client.get(jsonPath, {
|
||||
params: { ...params, 'iss.meta': 'off' },
|
||||
});
|
||||
this.circuitErrorCount = 0;
|
||||
return response.data as T;
|
||||
} catch (error) {
|
||||
this.circuitErrorCount++;
|
||||
if (this.circuitErrorCount >= this.threshold) {
|
||||
this.circuitOpen = true;
|
||||
this.logger.warn(`Circuit breaker opened after ${this.threshold} errors`);
|
||||
setTimeout(() => {
|
||||
this.circuitOpen = false;
|
||||
this.circuitErrorCount = 0;
|
||||
this.logger.log('Circuit breaker reset');
|
||||
}, this.resetMs);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
private extractTable(data: Record<string, unknown>, name: string): Record<string, unknown>[] {
|
||||
const table = data[name] as Record<string, unknown> | undefined;
|
||||
if (!table || !table.columns || !table.data) return [];
|
||||
const columns = table.columns as string[];
|
||||
const rows = table.data as unknown[][];
|
||||
return rows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.forEach((col, i) => {
|
||||
obj[col] = row[i];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
|
||||
async searchSecurities(query: string): Promise<MoexSecurityDescription[]> {
|
||||
const data = await this.request<Record<string, unknown>>('/securities', {
|
||||
q: query,
|
||||
});
|
||||
return this.extractTable(data, 'securities').map((s) => ({
|
||||
secid: s.secid as string,
|
||||
isin: s.isin as string,
|
||||
name: s.name as string,
|
||||
shortName: s.shortName as string,
|
||||
latName: (s.latName as string) || null,
|
||||
listLevel: parseInt(s.listLevel as string, 10) || 0,
|
||||
issueSize: parseInt(s.issuesize as string, 10) || 0,
|
||||
faceValue: parseFloat(s.facevalue as string) || 0,
|
||||
faceUnit: (s.faceunit as string) || '',
|
||||
issueDate: (s.issuedate as string) || '',
|
||||
typeName: (s.typename as string) || '',
|
||||
group: (s.group as string) || '',
|
||||
type: (s.type as string) || '',
|
||||
isQualifiedInvestors: (s.isqualifiedinvestors as string) === '1',
|
||||
morningSession: (s.morningsession as string) === '1',
|
||||
eveningSession: (s.eveningsession as string) === '1',
|
||||
}));
|
||||
}
|
||||
|
||||
async getSecurityDescription(secid: string): Promise<MoexSecurityDescription | null> {
|
||||
const data = await this.request<Record<string, unknown>>(`/securities/${secid}`);
|
||||
const rows = this.extractTable(data, 'description');
|
||||
if (rows.length === 0) return null;
|
||||
const map = new Map(rows.map((r) => [r.name, r.value]));
|
||||
return {
|
||||
secid,
|
||||
isin: (map.get('ISIN') as string) || '',
|
||||
name: (map.get('NAME') as string) || '',
|
||||
shortName: (map.get('SHORTNAME') as string) || '',
|
||||
latName: (map.get('LATNAME') as string) || null,
|
||||
listLevel: parseInt((map.get('LISTLEVEL') as string) || '0', 10),
|
||||
issueSize: parseInt((map.get('ISSUESIZE') as string) || '0', 10),
|
||||
faceValue: parseFloat((map.get('FACEVALUE') as string) || '0'),
|
||||
faceUnit: (map.get('FACEUNIT') as string) || '',
|
||||
issueDate: (map.get('ISSUEDATE') as string) || '',
|
||||
typeName: (map.get('TYPENAME') as string) || '',
|
||||
group: (map.get('GROUP') as string) || '',
|
||||
type: (map.get('TYPE') as string) || '',
|
||||
isQualifiedInvestors: (map.get('ISQUALIFIEDINVESTORS') as string) === '1',
|
||||
morningSession: (map.get('MORNINGSESSION') as string) === '1',
|
||||
eveningSession: (map.get('EVENINGSESSION') as string) === '1',
|
||||
};
|
||||
}
|
||||
|
||||
async getShareMarketData(secid: string, boardId = 'TQBR'): Promise<MoexShareMarketData | null> {
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/shares/securities/${secid}`,
|
||||
{ boards: boardId },
|
||||
);
|
||||
const rows = this.extractTable(data, 'securities');
|
||||
const share = rows.find((r) => r.BOARDID === boardId);
|
||||
if (!share) return null;
|
||||
|
||||
const mktRows = this.extractTable(data, 'marketdata');
|
||||
const mkt = mktRows.find((r) => r.BOARDID === boardId);
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: boardId,
|
||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
||||
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
||||
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
||||
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
|
||||
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
|
||||
last: mkt
|
||||
? parseFloat((mkt.LAST as string) || '')
|
||||
: parseFloat((share.PREVPRICE as string) || ''),
|
||||
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
|
||||
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
|
||||
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
|
||||
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
|
||||
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
|
||||
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
|
||||
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
|
||||
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
|
||||
updateTime: (mkt?.UPDATETIME as string) || '',
|
||||
};
|
||||
}
|
||||
|
||||
async getBondData(secid: string, boardId = 'TQCB'): Promise<MoexBondData | null> {
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
||||
{ boards: boardId },
|
||||
);
|
||||
const rows = this.extractTable(data, 'securities');
|
||||
const bond = rows.find((r) => r.BOARDID === boardId) || rows[0];
|
||||
if (!bond) return null;
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: boardId,
|
||||
shortName: (bond.SHORTNAME as string) || '',
|
||||
prevWaprice: parseFloat((bond.PREVWAPRICE as string) || '') || null,
|
||||
yieldAtPrevWaprice: parseFloat((bond.YIELDATPREVWAPRICE as string) || '') || null,
|
||||
couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
|
||||
nextCoupon: (bond.NEXTCOUPON as string) || null,
|
||||
accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
|
||||
prevPrice: parseFloat((bond.PREVPRICE as string) || '') || null,
|
||||
lotSize: parseInt((bond.LOTSIZE as string) || '1', 10),
|
||||
faceValue: parseFloat((bond.FACEVALUE as string) || '1000'),
|
||||
matDate: (bond.MATDATE as string) || '',
|
||||
couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10),
|
||||
issueSize: parseInt((bond.ISSUESIZE as string) || '0', 10),
|
||||
isin: (bond.ISIN as string) || '',
|
||||
couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
|
||||
offerDate: (bond.OFFERDATE as string) || null,
|
||||
buybackDate: (bond.BUYBACKDATE as string) || null,
|
||||
bondType: (bond.BONDTYPE as string) || '',
|
||||
bondSubType: (bond.BONDSUBTYPE as string) || '',
|
||||
listLevel: parseInt((bond.LISTLEVEL as string) || '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
async getBondMarketData(secid: string, boardId = 'TQCB'): Promise<MoexBondMarketData | null> {
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
||||
{ boards: boardId },
|
||||
);
|
||||
const mktRows = this.extractTable(data, 'marketdata');
|
||||
const mkt = mktRows.find((r) => r.SECID === secid);
|
||||
if (!mkt) return null;
|
||||
|
||||
return {
|
||||
secid,
|
||||
bid: mkt.BID != null ? parseFloat(mkt.BID as string) : null,
|
||||
offer: mkt.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
|
||||
open: mkt.OPEN != null ? parseFloat(mkt.OPEN as string) : null,
|
||||
low: mkt.LOW != null ? parseFloat(mkt.LOW as string) : null,
|
||||
high: mkt.HIGH != null ? parseFloat(mkt.HIGH as string) : null,
|
||||
last: mkt.LAST != null ? parseFloat(mkt.LAST as string) : null,
|
||||
yield: mkt.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
|
||||
waprice: mkt.WAPRICE != null ? parseFloat(mkt.WAPRICE as string) : null,
|
||||
yieldAtWaprice: mkt.YIELDATWAPRICE != null ? parseFloat(mkt.YIELDATWAPRICE as string) : null,
|
||||
duration: mkt.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
|
||||
volume: parseInt((mkt.VOLTODAY as string) || '0', 10),
|
||||
value: parseFloat((mkt.VALTODAY as string) || '0'),
|
||||
numtrades: parseInt((mkt.NUMTRADES as string) || '0', 10),
|
||||
tradingStatus: (mkt.TRADINGSTATUS as string) || '',
|
||||
updateTime: (mkt.UPDATETIME as string) || '',
|
||||
};
|
||||
}
|
||||
|
||||
async getDividends(secid: string): Promise<MoexDividend[]> {
|
||||
const data = await this.request<Record<string, unknown>>(`/securities/${secid}/dividends`);
|
||||
return this.extractTable(data, 'dividends').map((d) => ({
|
||||
secid: d.secid as string,
|
||||
isin: d.isin as string,
|
||||
registryCloseDate: d.registryclosedate as string,
|
||||
value: parseFloat(d.value as string),
|
||||
currencyId: (d.currencyid as string) || 'RUB',
|
||||
}));
|
||||
}
|
||||
|
||||
async getCandles(
|
||||
engine: 'stock',
|
||||
market: 'shares' | 'bonds',
|
||||
secid: string,
|
||||
interval: 1 | 10 | 60 | 24,
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<MoexCandle[]> {
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/${engine}/markets/${market}/securities/${secid}/candles`,
|
||||
{
|
||||
interval: String(interval),
|
||||
from,
|
||||
till,
|
||||
},
|
||||
);
|
||||
return this.extractTable(data, 'candles').map((c) => ({
|
||||
open: parseFloat(c.open as string),
|
||||
close: parseFloat(c.close as string),
|
||||
high: parseFloat(c.high as string),
|
||||
low: parseFloat(c.low as string),
|
||||
value: parseFloat(c.value as string),
|
||||
volume: parseInt(c.volume as string, 10),
|
||||
begin: c.begin as string,
|
||||
end: c.end as string,
|
||||
}));
|
||||
}
|
||||
|
||||
async getHistory(secid: string, from: string, till: string): Promise<MoexHistoryEntry[]> {
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/shares/securities/${secid}`,
|
||||
{ from, till },
|
||||
);
|
||||
const tableName = Object.keys(data).find(
|
||||
(k) => k.startsWith('history') && !k.includes('cursor'),
|
||||
);
|
||||
if (!tableName) return [];
|
||||
return this.extractTable(data, tableName).map((h) => ({
|
||||
tradeDate: h.TRADEDATE as string,
|
||||
open: h.OPEN != null ? parseFloat(h.OPEN as string) : null,
|
||||
low: h.LOW != null ? parseFloat(h.LOW as string) : null,
|
||||
high: h.HIGH != null ? parseFloat(h.HIGH as string) : null,
|
||||
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
|
||||
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
|
||||
volume: parseInt((h.VOLUME as string) || '0', 10),
|
||||
value: parseFloat((h.VALUE as string) || '0'),
|
||||
numtrades: parseInt((h.NUMTRADES as string) || '0', 10),
|
||||
}));
|
||||
}
|
||||
|
||||
async getBondHistory(secid: string, from: string, till: string): Promise<MoexBondHistoryEntry[]> {
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
||||
{ from, till },
|
||||
);
|
||||
const tableName = Object.keys(data).find(
|
||||
(k) => k.startsWith('history') && !k.includes('cursor'),
|
||||
);
|
||||
if (!tableName) return [];
|
||||
return this.extractTable(data, tableName).map((h) => ({
|
||||
tradeDate: h.TRADEDATE as string,
|
||||
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
|
||||
legalClosePrice: h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null,
|
||||
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
|
||||
yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null,
|
||||
duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null,
|
||||
accruedInt: h.ACCINT != null ? parseFloat(h.ACCINT as string) : null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
131
apps/backend/src/modules/moex-client/moex-client.types.ts
Normal file
131
apps/backend/src/modules/moex-client/moex-client.types.ts
Normal file
@ -0,0 +1,131 @@
|
||||
export interface MoexSecurityDescription {
|
||||
secid: string;
|
||||
isin: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
latName: string | null;
|
||||
listLevel: number;
|
||||
issueSize: number;
|
||||
faceValue: number;
|
||||
faceUnit: string;
|
||||
issueDate: string;
|
||||
typeName: string;
|
||||
group: string;
|
||||
type: string;
|
||||
isQualifiedInvestors: boolean;
|
||||
morningSession: boolean;
|
||||
eveningSession: boolean;
|
||||
}
|
||||
|
||||
export interface MoexShareMarketData {
|
||||
secid: string;
|
||||
boardid: string;
|
||||
bid: number | null;
|
||||
offer: number | null;
|
||||
open: number | null;
|
||||
low: number | null;
|
||||
high: number | null;
|
||||
last: number | null;
|
||||
lastChange: number | null;
|
||||
lastChangePrcnt: number | null;
|
||||
volume: number;
|
||||
value: number;
|
||||
waprice: number | null;
|
||||
numtrades: number;
|
||||
issueCapitalization: number | null;
|
||||
tradingStatus: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
export interface MoexBondData {
|
||||
secid: string;
|
||||
boardid: string;
|
||||
shortName: string;
|
||||
prevWaprice: number | null;
|
||||
yieldAtPrevWaprice: number | null;
|
||||
couponValue: number | null;
|
||||
nextCoupon: string | null;
|
||||
accruedInt: number | null;
|
||||
prevPrice: number | null;
|
||||
lotSize: number;
|
||||
faceValue: number;
|
||||
matDate: string;
|
||||
couponPeriod: number;
|
||||
issueSize: number;
|
||||
isin: string;
|
||||
couponPercent: number | null;
|
||||
offerDate: string | null;
|
||||
buybackDate: string | null;
|
||||
bondType: string;
|
||||
bondSubType: string;
|
||||
listLevel: number;
|
||||
}
|
||||
|
||||
export interface MoexBondMarketData {
|
||||
secid: string;
|
||||
bid: number | null;
|
||||
offer: number | null;
|
||||
open: number | null;
|
||||
low: number | null;
|
||||
high: number | null;
|
||||
last: number | null;
|
||||
yield: number | null;
|
||||
waprice: number | null;
|
||||
yieldAtWaprice: number | null;
|
||||
duration: number | null;
|
||||
volume: number;
|
||||
value: number;
|
||||
numtrades: number;
|
||||
tradingStatus: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
export interface MoexDividend {
|
||||
secid: string;
|
||||
isin: string;
|
||||
registryCloseDate: string;
|
||||
value: number;
|
||||
currencyId: string;
|
||||
}
|
||||
|
||||
export interface MoexCandle {
|
||||
open: number;
|
||||
close: number;
|
||||
high: number;
|
||||
low: number;
|
||||
value: number;
|
||||
volume: number;
|
||||
begin: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface MoexHistoryEntry {
|
||||
tradeDate: string;
|
||||
open: number | null;
|
||||
low: number | null;
|
||||
high: number | null;
|
||||
close: number | null;
|
||||
waprice: number | null;
|
||||
volume: number;
|
||||
value: number;
|
||||
numtrades: number;
|
||||
}
|
||||
|
||||
export interface MoexBondHistoryEntry {
|
||||
tradeDate: string;
|
||||
close: number | null;
|
||||
legalClosePrice: number | null;
|
||||
waprice: number | null;
|
||||
yieldClose: number | null;
|
||||
duration: number | null;
|
||||
accruedInt: number | null;
|
||||
}
|
||||
|
||||
export interface MoexBoard {
|
||||
secid: string;
|
||||
boardid: string;
|
||||
title: string;
|
||||
isPrimary: boolean;
|
||||
isTraded: boolean;
|
||||
currencyid: string;
|
||||
}
|
||||
25
apps/backend/src/modules/securities/dto/search-query.dto.ts
Normal file
25
apps/backend/src/modules/securities/dto/search-query.dto.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsString, IsOptional, IsEnum, MinLength, MaxLength } from 'class-validator';
|
||||
|
||||
export enum SecurityType {
|
||||
ALL = 'all',
|
||||
SHARE = 'share',
|
||||
BOND = 'bond',
|
||||
}
|
||||
|
||||
export class SearchQueryDto {
|
||||
@ApiProperty({ description: 'Поисковый запрос (тикер, название, ISIN)' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
q!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: SecurityType, default: SecurityType.ALL })
|
||||
@IsOptional()
|
||||
@IsEnum(SecurityType)
|
||||
type?: SecurityType = SecurityType.ALL;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
limit?: number = 20;
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SecuritiesController } from './securities.controller';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
import { SecurityType } from './dto/search-query.dto';
|
||||
|
||||
describe('SecuritiesController', () => {
|
||||
let controller: SecuritiesController;
|
||||
let service: SecuritiesService;
|
||||
|
||||
const mockResults = [
|
||||
{
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
shortName: 'Сбербанк',
|
||||
type: 'share',
|
||||
listLevel: 1,
|
||||
currency: 'RUB',
|
||||
price: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockService = {
|
||||
search: vi.fn().mockResolvedValue(mockResults),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [SecuritiesController],
|
||||
providers: [{ provide: SecuritiesService, useValue: mockService }],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<SecuritiesController>(SecuritiesController);
|
||||
service = module.get<SecuritiesService>(SecuritiesService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return search results', async () => {
|
||||
const result = await controller.search({ q: 'SBER', type: SecurityType.ALL, limit: 5 });
|
||||
expect(result.data).toEqual(mockResults);
|
||||
expect(service.search).toHaveBeenCalledWith('SBER', SecurityType.ALL, 5);
|
||||
});
|
||||
});
|
||||
21
apps/backend/src/modules/securities/securities.controller.ts
Normal file
21
apps/backend/src/modules/securities/securities.controller.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
|
||||
|
||||
@ApiTags('Securities')
|
||||
@Controller('securities')
|
||||
export class SecuritiesController {
|
||||
constructor(private readonly securitiesService: SecuritiesService) {}
|
||||
|
||||
@Get('search')
|
||||
@ApiOperation({ summary: 'Поиск по инструментам' })
|
||||
async search(@Query(ValidationPipe) query: SearchQueryDto) {
|
||||
const results = await this.securitiesService.search(
|
||||
query.q,
|
||||
query.type || SecurityType.ALL,
|
||||
query.limit || 20,
|
||||
);
|
||||
return { data: results, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
}
|
||||
12
apps/backend/src/modules/securities/securities.module.ts
Normal file
12
apps/backend/src/modules/securities/securities.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CacheModule } from '../cache/cache.module';
|
||||
import { SecuritiesController } from './securities.controller';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
|
||||
@Module({
|
||||
imports: [CacheModule],
|
||||
controllers: [SecuritiesController],
|
||||
providers: [SecuritiesService],
|
||||
exports: [SecuritiesService],
|
||||
})
|
||||
export class SecuritiesModule {}
|
||||
@ -0,0 +1,38 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import configuration from '../../config/configuration';
|
||||
import { SecurityType } from './dto/search-query.dto';
|
||||
|
||||
describe('SecuritiesService', () => {
|
||||
let service: SecuritiesService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [
|
||||
SecuritiesService,
|
||||
MoexClientService,
|
||||
{
|
||||
provide: 'CACHE_MANAGER',
|
||||
useValue: {
|
||||
get: () => undefined,
|
||||
set: () => Promise.resolve(),
|
||||
del: () => Promise.resolve(),
|
||||
},
|
||||
},
|
||||
CacheService,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SecuritiesService>(SecuritiesService);
|
||||
});
|
||||
|
||||
it('should return search results for SBER', async () => {
|
||||
const results = await service.search('SBER', SecurityType.ALL, 5);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].secid).toBeDefined();
|
||||
}, 15000);
|
||||
});
|
||||
82
apps/backend/src/modules/securities/securities.service.ts
Normal file
82
apps/backend/src/modules/securities/securities.service.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { SecurityType } from './dto/search-query.dto';
|
||||
|
||||
export interface SearchResultItem {
|
||||
secid: string;
|
||||
isin: string;
|
||||
shortName: string;
|
||||
type: 'share' | 'bond';
|
||||
listLevel: number;
|
||||
currency: string | null;
|
||||
price: number | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SecuritiesService {
|
||||
constructor(
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async search(query: string, type: SecurityType, limit: number): Promise<SearchResultItem[]> {
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'search',
|
||||
[query.toLowerCase()],
|
||||
async () => {
|
||||
const results = await this.moexClient.searchSecurities(query);
|
||||
return results
|
||||
.map((s): SearchResultItem | null => {
|
||||
const type =
|
||||
s.group === 'stock_shares' ||
|
||||
s.type === 'common_share' ||
|
||||
s.type === 'preferred_share'
|
||||
? ('share' as const)
|
||||
: s.group === 'stock_bonds'
|
||||
? ('bond' as const)
|
||||
: null;
|
||||
if (!type) return null;
|
||||
return {
|
||||
secid: s.secid,
|
||||
isin: s.isin,
|
||||
shortName: s.shortName,
|
||||
type,
|
||||
listLevel: s.listLevel,
|
||||
currency: s.faceUnit === 'SUR' ? 'RUB' : s.faceUnit || null,
|
||||
price: null,
|
||||
};
|
||||
})
|
||||
.filter((r): r is SearchResultItem => r !== null);
|
||||
},
|
||||
'searchTtl',
|
||||
);
|
||||
|
||||
let filtered = data;
|
||||
if (type === SecurityType.SHARE) {
|
||||
filtered = data.filter((r) => r.type === 'share');
|
||||
} else if (type === SecurityType.BOND) {
|
||||
filtered = data.filter((r) => r.type === 'bond');
|
||||
}
|
||||
|
||||
return filtered.slice(0, limit);
|
||||
}
|
||||
|
||||
async getShareBrief(secid: string): Promise<SearchResultItem | null> {
|
||||
try {
|
||||
const desc = await this.moexClient.getSecurityDescription(secid);
|
||||
if (!desc) return null;
|
||||
return {
|
||||
secid: desc.secid,
|
||||
isin: desc.isin,
|
||||
shortName: desc.shortName,
|
||||
type: 'share',
|
||||
listLevel: desc.listLevel,
|
||||
currency: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
|
||||
price: null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
41
apps/backend/src/modules/shares/shares.service.spec.ts
Normal file
41
apps/backend/src/modules/shares/shares.service.spec.ts
Normal file
@ -0,0 +1,41 @@
|
||||
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);
|
||||
});
|
||||
138
apps/backend/src/modules/shares/shares.service.ts
Normal file
138
apps/backend/src/modules/shares/shares.service.ts
Normal file
@ -0,0 +1,138 @@
|
||||
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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
17
apps/backend/tsconfig.json
Normal file
17
apps/backend/tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"moduleResolution": "node",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
22
apps/backend/vitest.config.ts
Normal file
22
apps/backend/vitest.config.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import swc from 'unplugin-swc';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
plugins: [
|
||||
swc.vite({
|
||||
jsc: {
|
||||
target: 'es2022',
|
||||
parser: {
|
||||
syntax: 'typescript',
|
||||
decorators: true,
|
||||
},
|
||||
transform: {
|
||||
decoratorMetadata: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
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>
|
||||
);
|
||||
}
|
||||
128
apps/frontend/src/api/client.ts
Normal file
128
apps/frontend/src/api/client.ts
Normal file
@ -0,0 +1,128 @@
|
||||
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
81
apps/frontend/src/components/BondDetails.tsx
Normal file
81
apps/frontend/src/components/BondDetails.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
35
apps/frontend/src/components/Layout.tsx
Normal file
35
apps/frontend/src/components/Layout.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
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} />;
|
||||
}
|
||||
108
apps/frontend/src/components/SearchBar.tsx
Normal file
108
apps/frontend/src/components/SearchBar.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
83
apps/frontend/src/components/StockDetails.tsx
Normal file
83
apps/frontend/src/components/StockDetails.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
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>,
|
||||
);
|
||||
34
apps/frontend/src/pages/BondPage.tsx
Normal file
34
apps/frontend/src/pages/BondPage.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
14
apps/frontend/src/pages/HomePage.tsx
Normal file
14
apps/frontend/src/pages/HomePage.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
67
apps/frontend/src/pages/StockPage.tsx
Normal file
67
apps/frontend/src/pages/StockPage.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
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
|
||||
```
|
||||
110
docs/superpowers/plans/2026-06-13-cicd-implementation.md
Normal file
110
docs/superpowers/plans/2026-06-13-cicd-implementation.md
Normal file
@ -0,0 +1,110 @@
|
||||
# CI/CD Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add Gitea Actions CI pipeline with lint, test, and build for the MoexVibe monorepo.
|
||||
|
||||
**Architecture:** Single `.gitea/workflows/ci.yml` file with three parallel jobs (lint, test, build) triggered on push/PR to main. One script addition to root `package.json` for format checking.
|
||||
|
||||
**Tech Stack:** Gitea Actions (GitHub Actions-compatible YAML), Node.js 20, npm workspaces
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `format:check` script to root package.json
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json` (root)
|
||||
|
||||
- [ ] **Step 1: Read root package.json**
|
||||
|
||||
- [ ] **Step 2: Add format:check script**
|
||||
|
||||
Edit `package.json`: add `"format:check": "prettier --check \"**/*.{ts,tsx}\""` to the `scripts` section, after `format`.
|
||||
|
||||
- [ ] **Step 3: Verify the script runs**
|
||||
|
||||
Run: `npm run format:check`
|
||||
Expected: exits 0 (all files already formatted) or lists formatting errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json
|
||||
git commit -m "ci: add format:check script for CI pipeline"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create Gitea Actions workflow
|
||||
|
||||
**Files:**
|
||||
- Create: `.gitea/workflows/ci.yml`
|
||||
|
||||
- [ ] **Step 1: Create workflow directory**
|
||||
|
||||
Run: `mkdir -p .gitea/workflows`
|
||||
|
||||
- [ ] **Step 2: Create ci.yml with full pipeline**
|
||||
|
||||
Create `.gitea/workflows/ci.yml`:
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
NODE_VERSION: 20
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npx prettier --check "**/*.{ts,tsx}"
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run test:backend
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run build:backend
|
||||
- run: npm run build:frontend
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add .gitea/workflows/ci.yml
|
||||
git commit -m "ci: add Gitea Actions pipeline with lint, test, build"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Verification
|
||||
|
||||
После пуша в `main` (или создания PR) проверить на https://git.ksv741.keenetic.pro/moex/moex-vibe/actions что pipeline запустился и все 3 job'а зелёные.
|
||||
46
docs/superpowers/specs/2026-06-13-cicd-design.md
Normal file
46
docs/superpowers/specs/2026-06-13-cicd-design.md
Normal file
@ -0,0 +1,46 @@
|
||||
# CI/CD Pipeline for MoexVibe
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Status:** Approved
|
||||
|
||||
## Overview
|
||||
|
||||
Continuous Integration pipeline using Gitea Actions (GitHub Actions-compatible format) for the MoexVibe monorepo.
|
||||
|
||||
## Triggers
|
||||
|
||||
- `push` to `main` branch
|
||||
- `pull_request` into `main` branch
|
||||
|
||||
## Pipeline Structure
|
||||
|
||||
Three parallel jobs with no interdependencies:
|
||||
|
||||
| Job | Commands | Cache |
|
||||
|---|---|---|
|
||||
| `lint` | `npm ci` → `npm run lint` → `npx prettier --check "**/*.{ts,tsx}"` | npm |
|
||||
| `test` | `npm ci` → `npm run test:backend` | npm |
|
||||
| `build` | `npm ci` → `npm run build:backend` → `npm run build:frontend` | npm |
|
||||
|
||||
## Runtime
|
||||
|
||||
- `runs-on: ubuntu-latest` (Gitea Actions runner)
|
||||
- Node.js 20
|
||||
- npm cache via `actions/setup-node` with `cache: 'npm'`
|
||||
|
||||
## Configuration File
|
||||
|
||||
`.gitea/workflows/ci.yml`
|
||||
|
||||
## Project Changes
|
||||
|
||||
1. Create `.gitea/workflows/ci.yml`
|
||||
2. Add `"format:check": "prettier --check \"**/*.{ts,tsx}\""` script to root `package.json`
|
||||
|
||||
## Explicitly Excluded
|
||||
|
||||
- Deployment (not required)
|
||||
- Docker image building
|
||||
- Artifact publishing
|
||||
- Frontend tests (none exist in project)
|
||||
- Frontend lint (no config exists)
|
||||
9434
package-lock.json
generated
Normal file
9434
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "moex-vibe",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/backend",
|
||||
"apps/frontend"
|
||||
],
|
||||
"scripts": {
|
||||
"dev:backend": "npm run start:dev -w apps/backend",
|
||||
"dev:frontend": "npm run dev -w apps/frontend",
|
||||
"build:backend": "npm run build -w apps/backend",
|
||||
"build:frontend": "npm run build -w apps/frontend",
|
||||
"test:backend": "npm run test -w apps/backend",
|
||||
"lint": "npm run lint -w apps/backend",
|
||||
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx}\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier": "^3.0.0"
|
||||
}
|
||||
}
|
||||
15
tsconfig.base.json
Normal file
15
tsconfig.base.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user