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

Merged
ksv741 merged 21 commits from feat/mvp-implementation into main 2026-06-13 21:07:34 +03:00
85 changed files with 13210 additions and 0 deletions

54
.gitea/workflows/ci.yml Normal file
View 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
View File

@ -0,0 +1,8 @@
node_modules/
dist/
.env
*.log
.DS_Store
*.tsbuildinfo
vite.config.d.ts
vite.config.js

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": true
}

67
AGENTS.md Normal file
View File

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

51
README.md Normal file
View File

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

23
apps/backend/.eslintrc.js Normal file
View 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',
},
};

View File

@ -0,0 +1,4 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

44
apps/backend/package.json Normal file
View 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"
}
}

View 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 {}

View 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);
}
}

View 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;
}

View 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,
});
}
}

View File

@ -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);
}),
);
}
}

View File

@ -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();
}
}

View 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
View 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();

View File

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

View File

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

View File

@ -0,0 +1,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);
});

View 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 },
};
}
}

View File

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

View File

@ -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 {}

View 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() };
}
}

View File

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

View File

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

View File

@ -0,0 +1,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);
});

View 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 },
};
}
}

View 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;
}

View 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(),
};
}
}

View File

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@ -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 {}

View File

@ -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);
});
});

View 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,
}));
}
}

View 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;
}

View 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;
}

View File

@ -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);
});
});

View 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 } };
}
}

View 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 {}

View File

@ -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);
});

View 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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);
});

View 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 },
};
}
}

View 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/**/*"]
}

View 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
View File

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

View File

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

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

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

View File

@ -0,0 +1,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,
});
}

View File

@ -0,0 +1,124 @@
export interface ApiResponseMeta {
cachedAt: string | null;
fromCache: boolean;
}
export interface ApiEnvelope<T> {
data: T;
meta: ApiResponseMeta;
}
export interface StockMarketData {
price: number;
change: number;
changePercent: number;
open: number;
high: number | null;
low: number | null;
volume: number;
value: number;
issueCapitalization: number | null;
updatedAt: string;
}
export interface ShareResponse {
secid: string;
isin: string;
name: string;
shortName: string;
latName: string | null;
listLevel: number;
issueSize: number;
faceValue: number;
faceUnit: string;
type: string;
marketData: StockMarketData;
}
export interface DividendItem {
registryCloseDate: string;
value: number;
currency: string;
}
export interface ShareHistoryItem {
date: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
value: number;
}
export interface BondMarketData {
price: number;
yieldToMaturity: number | null;
duration: number | null;
accruedInt: number;
couponValue: number;
couponPercent: number | null;
nextCouponDate: string | null;
open: number;
high: number | null;
low: number | null;
volume: number;
updatedAt: string;
}
export interface BondResponse {
secid: string;
isin: string;
name: string;
shortName: string;
latName: string | null;
listLevel: number;
issueSize: number;
faceValue: number;
faceUnit: string;
matDate: string;
couponValue: number;
couponPercent: number | null;
couponPeriod: number;
nextCoupon: string | null;
accruedInt: number;
bondType: string;
bondSubType: string;
offerDate: string | null;
buybackDate: string | null;
marketData: BondMarketData;
}
export interface BondHistoryItem {
date: string;
closePrice: number;
yieldClose: number | null;
duration: number | null;
}
export interface CandleItem {
open: number;
high: number;
low: number;
close: number;
volume: number;
value: number;
begin: string;
end: string;
}
export interface SearchResultItem {
secid: string;
isin: string;
shortName: string;
type: 'share' | 'bond';
listLevel: number;
currency: string | null;
price: number | null;
}
export interface HealthResponse {
status: string;
timestamp: string;
uptime: number;
}

View File

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

View File

@ -0,0 +1,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>
);
}

View 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>
);
}

View File

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

View File

@ -0,0 +1,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>
);
}

View 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} &middot; {stock.isin}
</div>
</div>
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>
{md.price.toLocaleString('ru-RU', { minimumFractionDigits: 2 })}{' '}
<span
style={{
fontSize: 18,
color: isPositive ? 'var(--color-positive)' : 'var(--color-negative)',
}}
>
{isPositive ? '+' : ''}
{md.change.toFixed(2)} ({md.changePercent.toFixed(2)}%)
</span>
</div>
<div style={{ marginTop: 16 }}>
<div style={rowStyle}>
<span>Открытие</span>
<span>{md.open.toFixed(2)}</span>
</div>
<div style={rowStyle}>
<span>Максимум</span>
<span>{md.high?.toFixed(2) ?? '—'}</span>
</div>
<div style={rowStyle}>
<span>Минимум</span>
<span>{md.low?.toFixed(2) ?? '—'}</span>
</div>
<div style={rowStyle}>
<span>Объём</span>
<span>{md.volume.toLocaleString('ru-RU')}</span>
</div>
<div style={rowStyle}>
<span>Капитализация</span>
<span>
{md.issueCapitalization ? (md.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽' : '—'}
</span>
</div>
<div style={rowStyle}>
<span>ISIN</span>
<span>{stock.isin}</span>
</div>
<div style={rowStyle}>
<span>Уровень листинга</span>
<span>{stock.listLevel}</span>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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>
);
}

View 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 минут &middot; Бесплатный API MOEX ISS
</p>
</div>
);
}

View 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>
);
}

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

20
docker-compose.yml Normal file
View File

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

14
docker/Dockerfile.backend Normal file
View File

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

View File

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

15
docker/nginx.conf Normal file
View File

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

View File

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

View 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'а зелёные.

View 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

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View 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
View 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
}
}