94 KiB
MoexVibe MVP 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: Build a working MVP of MoexVibe — web application for analyzing MOEX stocks and bonds.
Architecture: NestJS monolith serving normalized REST API (OpenAPI 3.0), in-memory cache, rate-limited MOEX ISS client. React SPA with TanStack Query and openapi-typescript codegen. Dev-only single server, production via Docker.
Tech Stack: NestJS, React, Vite, TypeScript, TanStack Query, React Router, @nestjs/cache-manager, node-fetch/axios, lightweight-charts, vitest, Docusaurus
File Structure
moex-vibe/
├── package.json # Root npm workspaces
├── tsconfig.base.json
├── .gitignore
├── .prettierrc
├── .eslintrc.cjs
├── apps/
│ ├── backend/
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ ├── nest-cli.json
│ │ └── src/
│ │ ├── main.ts
│ │ ├── app.module.ts
│ │ ├── config/
│ │ │ └── configuration.ts
│ │ ├── common/
│ │ │ ├── dto/
│ │ │ │ ├── api-response.dto.ts
│ │ │ │ └── pagination.dto.ts
│ │ │ ├── filters/
│ │ │ │ └── http-exception.filter.ts
│ │ │ ├── interceptors/
│ │ │ │ ├── logging.interceptor.ts
│ │ │ │ └── transform.interceptor.ts
│ │ │ └── middleware/
│ │ │ └── request-logging.middleware.ts
│ │ └── modules/
│ │ ├── moex-client/
│ │ │ ├── moex-client.module.ts
│ │ │ ├── moex-client.service.ts
│ │ │ ├── moex-client.service.spec.ts
│ │ │ └── moex-client.types.ts
│ │ ├── cache/
│ │ │ ├── cache.module.ts
│ │ │ └── cache.service.ts
│ │ ├── securities/
│ │ │ ├── securities.module.ts
│ │ │ ├── securities.controller.ts
│ │ │ ├── securities.controller.spec.ts
│ │ │ ├── securities.service.ts
│ │ │ ├── securities.service.spec.ts
│ │ │ └── dto/
│ │ │ └── search-query.dto.ts
│ │ ├── shares/
│ │ │ ├── shares.module.ts
│ │ │ ├── shares.controller.ts
│ │ │ ├── shares.controller.spec.ts
│ │ │ ├── shares.service.ts
│ │ │ ├── shares.service.spec.ts
│ │ │ └── dto/
│ │ │ ├── share-response.dto.ts
│ │ │ ├── share-marketdata-response.dto.ts
│ │ │ ├── dividends-response.dto.ts
│ │ │ └── history-query.dto.ts
│ │ ├── bonds/
│ │ │ ├── bonds.module.ts
│ │ │ ├── bonds.controller.ts
│ │ │ ├── bonds.controller.spec.ts
│ │ │ ├── bonds.service.ts
│ │ │ ├── bonds.service.spec.ts
│ │ │ └── dto/
│ │ │ ├── bond-response.dto.ts
│ │ │ ├── bond-marketdata-response.dto.ts
│ │ │ └── bond-history.dto.ts
│ │ ├── candles/
│ │ │ ├── candles.module.ts
│ │ │ ├── candles.controller.ts
│ │ │ ├── candles.controller.spec.ts
│ │ │ ├── candles.service.ts
│ │ │ ├── candles.service.spec.ts
│ │ │ └── dto/
│ │ │ └── candles-query.dto.ts
│ │ └── health/
│ │ └── health.controller.ts
│ └── frontend/
│ ├── package.json
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ ├── vite.config.ts
│ ├── index.html
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── routes.tsx
│ ├── styles.css
│ ├── api/
│ │ └── (generated by openapi-typescript)
│ ├── hooks/
│ │ ├── useSearch.ts
│ │ ├── useStock.ts
│ │ ├── useStockCandles.ts
│ │ ├── useStockDividends.ts
│ │ ├── useBond.ts
│ │ └── useBondCandles.ts
│ ├── pages/
│ │ ├── HomePage.tsx
│ │ ├── StockPage.tsx
│ │ └── BondPage.tsx
│ ├── components/
│ │ ├── Layout.tsx
│ │ ├── SearchBar.tsx
│ │ ├── SecurityCard.tsx
│ │ ├── PriceChart.tsx
│ │ ├── StockDetails.tsx
│ │ └── BondDetails.tsx
│ ├── types/
│ │ └── (generated, re-exported)
│ └── vite-env.d.ts
├── docker/
│ ├── Dockerfile.backend
│ ├── Dockerfile.frontend
│ └── nginx.conf
├── docker-compose.yml
└── docs/
├── architecture/adr/
├── openapi/openapi.yaml
└── superpowers/specs/2026-06-13-moex-vibe-design.md
SPRINT 1: Backend Foundation
Task 1.1: Initialize project root with npm workspaces
Files:
-
Create:
package.json -
Create:
tsconfig.base.json -
Create:
.gitignore -
Create:
.prettierrc -
Create root package.json with workspaces
{
"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\""
},
"devDependencies": {
"prettier": "^3.0.0"
}
}
- Create tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
- Create .gitignore
node_modules/
dist/
.env
*.log
.DS_Store
- Create .prettierrc
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": true
}
-
Run
npm installat root to create lockfile and workspace links. -
Commit
git add package.json tsconfig.base.json .gitignore .prettierrc
git commit -m "chore: initialize monorepo with npm workspaces"
Task 1.2: Scaffold NestJS backend
Files:
-
Create:
apps/backend/package.json -
Create:
apps/backend/tsconfig.json -
Create:
apps/backend/nest-cli.json -
Create:
apps/backend/src/main.ts -
Create:
apps/backend/src/app.module.ts -
Create apps/backend/package.json
{
"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/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/config": "^3.0.0",
"@nestjs/swagger": "^7.0.0",
"@nestjs/axios": "^3.0.0",
"@nestjs/cache-manager": "^2.0.0",
"cache-manager": "^5.0.0",
"axios": "^1.6.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0",
"class-validator": "^0.14.0",
"class-transformer": "^0.5.0",
"p-queue": "^7.3.0",
"swagger-ui-express": "^5.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.0",
"@types/node": "^20.0.0",
"typescript": "^5.3.0",
"vitest": "^1.0.0",
"eslint": "^8.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0"
}
}
- Create apps/backend/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"outDir": "./dist",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"]
}
- Create apps/backend/nest-cli.json
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}
- Create apps/backend/src/main.ts
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());
app.use(new RequestLoggingMiddleware().use);
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();
- Create apps/backend/src/app.module.ts
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 {}
- Commit
git add apps/backend/
git commit -m "feat: scaffold NestJS backend with Swagger, validation, CORS"
Task 1.3: Configuration module
Files:
-
Create:
apps/backend/src/config/configuration.ts -
Create configuration.ts
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),
},
}));
- Commit
git add apps/backend/src/config/
git commit -m "feat: add configuration module with env vars"
Task 1.4: Common DTOs, filters, interceptors, middleware
Files:
-
Create:
apps/backend/src/common/dto/api-response.dto.ts -
Create:
apps/backend/src/common/dto/pagination.dto.ts -
Create:
apps/backend/src/common/filters/http-exception.filter.ts -
Create:
apps/backend/src/common/interceptors/transform.interceptor.ts -
Create:
apps/backend/src/common/middleware/request-logging.middleware.ts -
Create common/dto/api-response.dto.ts
import { ApiProperty } from '@nestjs/swagger';
export class ApiResponseMeta {
@ApiProperty({ nullable: true })
cachedAt: string | null;
@ApiProperty()
fromCache: boolean;
}
export class ApiResponse<T> {
data: T;
meta: ApiResponseMeta;
constructor(data: T, fromCache = false, cachedAt: string | null = null) {
this.data = data;
this.meta = { cachedAt, fromCache };
}
}
- Create common/dto/pagination.dto.ts
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;
}
- Create common/filters/http-exception.filter.ts
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,
});
}
}
- Create common/interceptors/transform.interceptor.ts
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);
}),
);
}
}
- Create common/middleware/request-logging.middleware.ts
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();
}
}
- Commit
git add apps/backend/src/common/
git commit -m "feat: add common DTOs, exception filter, transform interceptor, logging middleware"
Task 1.5: Health check endpoint
Files:
-
Create:
apps/backend/src/modules/health/health.controller.ts -
Create health.controller.ts
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(),
};
}
}
- Commit
git add apps/backend/src/modules/health/
git commit -m "feat: add health check endpoint"
Task 1.6: MoexClient module with rate limiting
Files:
-
Create:
apps/backend/src/modules/moex-client/moex-client.module.ts -
Create:
apps/backend/src/modules/moex-client/moex-client.service.ts -
Create:
apps/backend/src/modules/moex-client/moex-client.types.ts -
Create:
apps/backend/src/modules/moex-client/moex-client.service.spec.ts -
Create moex-client.types.ts
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;
}
- Create moex-client.service.ts
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 response = await this.client.get(path, {
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);
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 intervalMap: Record<number, string> = {
1: '1min',
10: '10min',
60: '1hour',
24: '24hours',
};
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,
}));
}
}
- Create moex-client.module.ts
import { Global, Module } from '@nestjs/common';
import { MoexClientService } from './moex-client.service';
@Global()
@Module({
providers: [MoexClientService],
exports: [MoexClientService],
})
export class MoexClientModule {}
- Create moex-client.service.spec.ts
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(Array.isArray(results)).toBe(true);
if (results.length > 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);
});
});
- Commit
git add apps/backend/src/modules/moex-client/
git commit -m "feat: add MoexClient with rate-limited HTTP client, circuit breaker, and MOEX ISS data methods"
Task 1.7: Cache module
Files:
-
Create:
apps/backend/src/modules/cache/cache.module.ts -
Create:
apps/backend/src/modules/cache/cache.service.ts -
Create cache.module.ts
import { Module, CacheModule as NestCacheModule } from '@nestjs/cache-manager';
import { CacheService } from './cache.service';
@Module({
imports: [
NestCacheModule.register({
ttl: 900,
max: 1000,
isGlobal: true,
}),
],
providers: [CacheService],
exports: [CacheService],
})
export class CacheModule {}
- Create cache.service.ts
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() };
}
}
- Commit
git add apps/backend/src/modules/cache/
git commit -m "feat: add cache module with getOrFetch pattern and configurable TTL"
SPRINT 2: Securities API
Task 2.1: Securities search module
Files:
-
Create:
apps/backend/src/modules/securities/dto/search-query.dto.ts -
Create:
apps/backend/src/modules/securities/securities.controller.ts -
Create:
apps/backend/src/modules/securities/securities.service.ts -
Create:
apps/backend/src/modules/securities/securities.module.ts -
Create:
apps/backend/src/modules/securities/securities.controller.spec.ts -
Create:
apps/backend/src/modules/securities/securities.service.spec.ts -
Create search-query.dto.ts
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;
}
-
Create search result DTO inline or reuse — add to controller response via transform interceptor.
-
Create securities.service.ts
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) => ({
secid: s.secid,
isin: s.isin,
shortName: s.shortName,
type: (s.group === 'stock_shares' || s.type === 'common_share' || s.type === 'preferred_share')
? 'share' as const
: (s.group === 'stock_bonds' ? 'bond' as const : null),
listLevel: s.listLevel,
currency: s.faceUnit === 'SUR' ? 'RUB' : s.faceUnit || null,
price: null,
})).filter((r): r is SearchResultItem => r.type !== 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;
}
}
}
- Create securities.controller.ts
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery } 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 } };
}
}
- Create securities.module.ts
import { Module } from '@nestjs/common';
import { SecuritiesController } from './securities.controller';
import { SecuritiesService } from './securities.service';
@Module({
controllers: [SecuritiesController],
providers: [SecuritiesService],
exports: [SecuritiesService],
})
export class SecuritiesModule {}
- Create securities.service.spec.ts
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);
});
-
Create securities.controller.spec.ts — similar pattern with mocked service.
-
Commit
git add apps/backend/src/modules/securities/
git commit -m "feat: add securities search endpoint"
Task 2.2: Shares module — spec + marketdata
Files:
-
Create:
apps/backend/src/modules/shares/dto/share-response.dto.ts -
Create:
apps/backend/src/modules/shares/dto/share-marketdata-response.dto.ts -
Create:
apps/backend/src/modules/shares/shares.service.ts -
Create:
apps/backend/src/modules/shares/shares.controller.ts -
Create:
apps/backend/src/modules/shares/shares.module.ts -
Create share-response.dto.ts
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;
}
- Create share-marketdata-response.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { StockMarketDataDto } from './share-response.dto';
export class ShareMarketDataResponseDto extends StockMarketDataDto {}
- Create shares.service.ts
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 prevPrice = 0; // not stored separately, but available from securities table
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 },
};
}
}
- Create shares.controller.ts
import { Controller, Get, Param } 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);
}
}
- Create shares.module.ts
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 {}
- Commit
git add apps/backend/src/modules/shares/
git commit -m "feat: add shares endpoint with market data and dividends"
SPRINT 3: Bonds + History + Candles
Task 3.1: Bonds module
Files:
-
Create:
apps/backend/src/modules/bonds/dto/bond-response.dto.ts -
Create:
apps/backend/src/modules/bonds/dto/bond-marketdata-response.dto.ts -
Create:
apps/backend/src/modules/bonds/bonds.service.ts -
Create:
apps/backend/src/modules/bonds/bonds.controller.ts -
Create:
apps/backend/src/modules/bonds/bonds.module.ts -
Create bond-response.dto.ts
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;
}
- Create bonds.service.ts
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 },
};
}
}
- Create bonds.controller.ts
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);
}
}
- Create bonds.module.ts
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 {}
- Commit
git add apps/backend/src/modules/bonds/
git commit -m "feat: add bonds endpoint with market data and history"
Task 3.2: Candles module (shared by shares + bonds)
Files:
-
Create:
apps/backend/src/modules/candles/dto/candles-query.dto.ts -
Create:
apps/backend/src/modules/candles/candles.service.ts -
Create:
apps/backend/src/modules/candles/candles.controller.ts -
Create:
apps/backend/src/modules/candles/candles.module.ts -
Create candles-query.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsEnum, IsDateString } from 'class-validator';
export enum CandleInterval {
HOUR = '1h',
DAY = '24h',
}
export class CandlesQueryDto {
@ApiProperty({ enum: CandleInterval })
@IsEnum(CandleInterval)
interval: CandleInterval;
@ApiProperty({ format: 'date', example: '2025-06-13' })
@IsDateString()
from: string;
@ApiProperty({ format: 'date', example: '2026-06-13' })
@IsDateString()
till: string;
}
- Create candles.service.ts
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 },
};
}
}
- Create candles.controller.ts
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);
}
}
- Create candles.module.ts
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 {}
- Add OpenAPI decorators to share history endpoint in
shares.controller.ts:
@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);
}
- Add getHistory method to SharesService:
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 },
};
}
- Commit
git add apps/backend/src/modules/candles/
git commit -m "feat: add candles module with 1h/24h intervals for shares and bonds"
SPRINT 4: Frontend Foundation
Task 4.1: Scaffold React + Vite frontend
Files:
-
Create:
apps/frontend/package.json -
Create:
apps/frontend/tsconfig.json -
Create:
apps/frontend/tsconfig.node.json -
Create:
apps/frontend/vite.config.ts -
Create:
apps/frontend/index.html -
Create:
apps/frontend/src/vite-env.d.ts -
Create:
apps/frontend/src/main.tsx -
Create:
apps/frontend/src/App.tsx -
Create:
apps/frontend/src/routes.tsx -
Create:
apps/frontend/src/styles.css -
Create apps/frontend/package.json
{
"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"
}
}
- Create apps/frontend/tsconfig.json
{
"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" }]
}
- Create apps/frontend/tsconfig.node.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
- Create apps/frontend/vite.config.ts
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,
},
},
},
});
- Create apps/frontend/index.html
<!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>
- Create apps/frontend/src/vite-env.d.ts
/// <reference types="vite/client" />
- Create apps/frontend/src/main.tsx
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>,
);
- Create apps/frontend/src/App.tsx
import { BrowserRouter } from 'react-router-dom';
import { AppRoutes } from './routes';
export default function App() {
return (
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
);
}
- Create apps/frontend/src/routes.tsx
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>
);
}
- Create apps/frontend/src/styles.css — minimal reset:
*,
*::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;
}
- Commit
git add apps/frontend/
git commit -m "feat: scaffold React + Vite frontend with routing"
Task 4.2: Generate API client from OpenAPI schema
- Start backend, then run:
npm run codegen -w apps/frontend
This creates apps/frontend/src/api/types.ts with all typed DTOs.
- Create apps/frontend/src/api/client.ts — typed fetch wrapper:
import createClient from 'openapi-fetch';
import type { paths } from './types';
export const apiClient = createClient<paths>({
baseUrl: '/api/v1',
});
export type ApiResponse<T> = {
data: T;
meta: {
cachedAt: string | null;
fromCache: boolean;
};
};
- Commit
git add apps/frontend/src/api/
git commit -m "feat: add openapi-typescript generated types and API client"
Task 4.3: Layout component
Files:
-
Create:
apps/frontend/src/components/Layout.tsx -
Create Layout.tsx
import { Outlet, Link } from 'react-router-dom';
const headerStyle: React.CSSProperties = {
background: 'var(--color-surface)',
borderBottom: '1px solid #e0e0e0',
padding: '12px 24px',
display: 'flex',
alignItems: 'center',
gap: 24,
position: 'sticky',
top: 0,
zIndex: 100,
};
const mainStyle: React.CSSProperties = {
maxWidth: 1200,
margin: '0 auto',
padding: '24px 16px',
};
export function Layout() {
return (
<div>
<header style={headerStyle}>
<Link to="/" style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)' }}>
MoexVibe
</Link>
</header>
<main style={mainStyle}>
<Outlet />
</main>
</div>
);
}
- Commit
git add apps/frontend/src/components/Layout.tsx
git commit -m "feat: add Layout component with header"
Task 4.4: Search hook + HomePage
Files:
-
Create:
apps/frontend/src/hooks/useSearch.ts -
Create:
apps/frontend/src/components/SearchBar.tsx -
Create:
apps/frontend/src/components/SecurityCard.tsx -
Create:
apps/frontend/src/pages/HomePage.tsx -
Create useSearch.ts
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../api/client';
export function useSearch(query: string) {
return useQuery({
queryKey: ['search', query],
queryFn: async () => {
const { data } = await apiClient.GET('/securities/search', {
params: { query: { q: query, limit: 20 } },
});
return data?.data ?? [];
},
enabled: query.length >= 1,
staleTime: 60_000,
});
}
- Create SearchBar.tsx
import { useState, useCallback } from 'react';
interface SearchBarProps {
onSearch: (query: string) => void;
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '12px 16px',
fontSize: 16,
border: '1px solid #ddd',
borderRadius: 'var(--border-radius)',
outline: 'none',
};
export function SearchBar({ onSearch }: SearchBarProps) {
const [value, setValue] = useState('');
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setValue(v);
onSearch(v);
},
[onSearch],
);
return (
<input
style={inputStyle}
type="text"
placeholder="Поиск по тикеру, названию или ISIN..."
value={value}
onChange={handleChange}
autoFocus
/>
);
}
- Create SecurityCard.tsx
import { Link } from 'react-router-dom';
interface SecurityCardProps {
secid: string;
shortName: string;
type: 'share' | 'bond';
isin: string;
listLevel: number;
currency: string | null;
price: number | null;
}
const cardStyle: React.CSSProperties = {
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
};
const badgeStyle: React.CSSProperties = {
fontSize: 12,
padding: '2px 8px',
borderRadius: 4,
fontWeight: 600,
};
export function SecurityCard({ secid, shortName, type, isin, currency, price }: SecurityCardProps) {
const linkTo = type === 'share' ? `/stocks/${secid}` : `/bonds/${secid}`;
return (
<Link to={linkTo} style={{ textDecoration: 'none', color: 'inherit' }}>
<div style={cardStyle}>
<div>
<div style={{ fontSize: 18, fontWeight: 600 }}>{secid}</div>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>
{shortName} · {isin}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<span
style={{
...badgeStyle,
background: type === 'share' ? '#e3f2fd' : '#f3e5f5',
color: type === 'share' ? '#1565c0' : '#7b1fa2',
}}
>
{type === 'share' ? 'Акция' : 'Облигация'}
</span>
{price != null && (
<div style={{ marginTop: 4, fontWeight: 600 }}>
{price.toLocaleString('ru-RU')} {currency || ''}
</div>
)}
</div>
</div>
</Link>
);
}
- Create HomePage.tsx
import { useState } from 'react';
import { SearchBar } from '../components/SearchBar';
import { SecurityCard } from '../components/SecurityCard';
import { useSearch } from '../hooks/useSearch';
export function HomePage() {
const [query, setQuery] = useState('');
const { data: results, isLoading } = useSearch(query);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<h1 style={{ fontSize: 24, fontWeight: 700 }}>Поиск инструментов</h1>
<SearchBar onSearch={setQuery} />
{isLoading && <div>Загрузка...</div>}
{results && results.length === 0 && query.length > 0 && (
<div style={{ color: 'var(--color-text-secondary)' }}>
Ничего не найдено
</div>
)}
{results && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{results.map((item) => (
<SecurityCard
key={item.secid}
secid={item.secid}
shortName={item.shortName}
type={item.type as 'share' | 'bond'}
isin={item.isin}
listLevel={item.listLevel}
currency={item.currency}
price={item.price}
/>
))}
</div>
)}
</div>
);
}
- Commit
git add apps/frontend/src/hooks/useSearch.ts apps/frontend/src/components/SearchBar.tsx apps/frontend/src/components/SecurityCard.tsx apps/frontend/src/pages/HomePage.tsx
git commit -m "feat: add search page with SecurityCard and SearchBar"
SPRINT 5: Frontend Details
Task 5.1: Stock page hook
Files:
-
Create:
apps/frontend/src/hooks/useStock.ts -
Create:
apps/frontend/src/hooks/useStockCandles.ts -
Create:
apps/frontend/src/hooks/useStockDividends.ts -
Create useStock.ts
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../api/client';
export function useStock(secid: string) {
return useQuery({
queryKey: ['stock', secid],
queryFn: async () => {
const { data } = await apiClient.GET('/securities/shares/{secid}', {
params: { path: { secid } },
});
return data?.data ?? null;
},
staleTime: 900_000,
});
}
export function useStockMarketData(secid: string) {
return useQuery({
queryKey: ['stockMarketData', secid],
queryFn: async () => {
const { data } = await apiClient.GET('/securities/shares/{secid}/marketdata', {
params: { path: { secid } },
});
return data?.data ?? null;
},
staleTime: 900_000,
});
}
- Create useStockCandles.ts
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../api/client';
export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
return useQuery({
queryKey: ['stockCandles', secid, interval, from, till],
queryFn: async () => {
const { data } = await apiClient.GET('/securities/shares/{secid}/candles', {
params: {
path: { secid },
query: { interval, from, till },
},
});
return data?.data ?? [];
},
staleTime: 3600_000,
});
}
- Create useStockDividends.ts
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../api/client';
export function useStockDividends(secid: string) {
return useQuery({
queryKey: ['stockDividends', secid],
queryFn: async () => {
const { data } = await apiClient.GET('/securities/shares/{secid}/dividends', {
params: { path: { secid } },
});
return data?.data ?? [];
},
staleTime: 86400_000,
});
}
- Commit
git add apps/frontend/src/hooks/
git commit -m "feat: add React Query hooks for stock, candles, dividends"
Task 5.2: StockDetails component
Files:
-
Create:
apps/frontend/src/components/StockDetails.tsx -
Create StockDetails.tsx
import type { components } from '../api/types';
type Stock = components['schemas']['StockResponse']['data'];
interface StockDetailsProps {
stock: NonNullable<Stock>;
}
const rowStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: '1px solid #eee',
};
export function StockDetails({ stock }: StockDetailsProps) {
const { marketData } = stock;
const isPositive = marketData.change >= 0;
return (
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}>
<div style={{ marginBottom: 16 }}>
<h2 style={{ fontSize: 28, fontWeight: 700 }}>
{stock.shortName} ({stock.secid})
</h2>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>
{stock.name} · {stock.isin}
</div>
</div>
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>
{marketData.price.toLocaleString('ru-RU', { minimumFractionDigits: 2 })}{' '}
<span style={{ fontSize: 18, color: isPositive ? 'var(--color-positive)' : 'var(--color-negative)' }}>
{isPositive ? '+' : ''}{marketData.change.toFixed(2)} ({marketData.changePercent.toFixed(2)}%)
</span>
</div>
<div style={{ marginTop: 16 }}>
<div style={rowStyle}>
<span>Открытие</span>
<span>{marketData.open.toFixed(2)}</span>
</div>
<div style={rowStyle}>
<span>Максимум</span>
<span>{marketData.high?.toFixed(2) ?? '—'}</span>
</div>
<div style={rowStyle}>
<span>Минимум</span>
<span>{marketData.low?.toFixed(2) ?? '—'}</span>
</div>
<div style={rowStyle}>
<span>Объём</span>
<span>{marketData.volume.toLocaleString('ru-RU')}</span>
</div>
<div style={rowStyle}>
<span>Капитализация</span>
<span>
{marketData.issueCapitalization
? (marketData.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>
);
}
- Commit
git add apps/frontend/src/components/StockDetails.tsx
git commit -m "feat: add StockDetails component"
Task 5.3: Bond page hooks
Files:
-
Create:
apps/frontend/src/hooks/useBond.ts -
Create:
apps/frontend/src/hooks/useBondCandles.ts -
Create useBond.ts
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../api/client';
export function useBond(secid: string) {
return useQuery({
queryKey: ['bond', secid],
queryFn: async () => {
const { data, error } = await apiClient.GET('/securities/bonds/{secid}', {
params: { path: { secid } },
});
if (error) throw new Error(error.message);
return data?.data ?? null;
},
staleTime: 900_000,
});
}
- Create useBondCandles.ts
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '../api/client';
export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
return useQuery({
queryKey: ['bondCandles', secid, interval, from, till],
queryFn: async () => {
const { data } = await apiClient.GET('/securities/bonds/{secid}/candles', {
params: {
path: { secid },
query: { interval, from, till },
},
});
return data?.data ?? [];
},
staleTime: 3600_000,
});
}
- Commit
git add apps/frontend/src/hooks/useBond.ts apps/frontend/src/hooks/useBondCandles.ts
git commit -m "feat: add React Query hooks for bond and bond candles"
Task 5.4: BondDetails component
Files:
-
Create:
apps/frontend/src/components/BondDetails.tsx -
Create BondDetails.tsx
interface BondDetailsProps {
bond: any; // Тип генерируется openapi-typescript из схемы 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>
);
}
- Commit
git add apps/frontend/src/components/BondDetails.tsx
git commit -m "feat: add BondDetails component"
Task 5.5: PriceChart component
Files:
-
Create:
apps/frontend/src/components/PriceChart.tsx -
Create PriceChart.tsx
import { useEffect, useRef } from 'react';
import { createChart, ColorType, IChartApi, 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);
const chartRef = useRef<IChartApi | null>(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();
chartRef.current = chart;
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} />;
}
- Commit
git add apps/frontend/src/components/PriceChart.tsx
git commit -m "feat: add PriceChart component using lightweight-charts"
Task 5.6: StockPage and BondPage
Files:
-
Modify:
apps/frontend/src/pages/StockPage.tsx -
Modify:
apps/frontend/src/pages/BondPage.tsx -
Create StockPage.tsx
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>
);
}
- Create BondPage.tsx
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>
);
}
- Commit
git add apps/frontend/src/pages/
git commit -m "feat: add StockPage and BondPage with charts and details"
SPRINT 6: Documentation + Infrastructure
Task 6.1: OpenAPI spec finalization
-
Verify OpenAPI spec — start backend, check Swagger UI at
/api/docs. Ensure all endpoints, schemas, and examples are present. -
Sync
docs/openapi/openapi.yamlwith the generated spec if any changes were made during development. -
Commit
git add docs/openapi/openapi.yaml
git commit -m "docs: finalize OpenAPI specification"
Task 6.2: ADR documentation
-
Ensure all ADR files exist in
docs/architecture/adr/(created during design phase, adjust if needed). -
Add architecture overview diagram (ASCII sequence diagram or Mermaid):
# 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
- [ ] **Commit**
```bash
git add docs/
git commit -m "docs: add ADR documents and architecture diagrams"
Task 6.3: Docker setup
Files:
-
Create:
docker/Dockerfile.backend -
Create:
docker/Dockerfile.frontend -
Create:
docker/nginx.conf -
Create:
docker-compose.yml -
Create Dockerfile.backend
FROM node:20-alpine AS build
WORKDIR /app
COPY apps/backend/package.json ./
RUN npm install
COPY apps/backend/ ./
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY apps/backend/package.json ./
EXPOSE 3000
CMD ["node", "dist/main.js"]
- Create Dockerfile.frontend
FROM node:20-alpine AS build
WORKDIR /app
COPY apps/frontend/package.json ./
RUN npm install
COPY apps/frontend/ ./
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
- Create nginx.conf
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;
}
}
- Create docker-compose.yml
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
- Commit
git add docker/ docker-compose.yml
git commit -m "infra: add Docker setup with docker-compose"
Task 6.4: README and final checks
-
Create README.md with:
- Project overview
- Tech stack
- Quick start (npm install, npm run dev:backend, npm run dev:frontend)
- Docker instructions
- Links to docs
-
Run full test suite:
npm run test:backend
- Verify type generation:
cd apps/frontend && npx openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts
- Final commit
git add README.md
git commit -m "chore: add README with quick start instructions"