moex-vibe/docs/superpowers/plans/2026-06-13-moex-vibe-implementation.md

3457 lines
94 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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**
```json
{
"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**
```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**
```json
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": true
}
```
- [ ] **Run `npm install` at root** to create lockfile and workspace links.
- [ ] **Commit**
```bash
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**
```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**
```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**
```json
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}
```
- [ ] **Create apps/backend/src/main.ts**
```typescript
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**
```typescript
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**
```bash
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**
```typescript
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**
```bash
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**
```typescript
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**
```typescript
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**
```typescript
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**
```typescript
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**
```typescript
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**
```bash
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**
```typescript
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**
```bash
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**
```typescript
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**
```typescript
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**
```typescript
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**
```typescript
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**
```bash
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**
```typescript
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**
```typescript
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**
```bash
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**
```typescript
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**
```typescript
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**
```typescript
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**
```typescript
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**
```typescript
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**
```bash
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**
```typescript
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**
```typescript
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { StockMarketDataDto } from './share-response.dto';
export class ShareMarketDataResponseDto extends StockMarketDataDto {}
```
- [ ] **Create shares.service.ts**
```typescript
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**
```typescript
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**
```typescript
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**
```bash
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**
```typescript
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**
```typescript
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**
```typescript
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**
```typescript
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**
```bash
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**
```typescript
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**
```typescript
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**
```typescript
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**
```typescript
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`:
```typescript
@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**:
```typescript
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**
```bash
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**
```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**
```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**
```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**
```typescript
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**
```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**
```typescript
/// <reference types="vite/client" />
```
- [ ] **Create apps/frontend/src/main.tsx**
```typescript
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**
```typescript
import { BrowserRouter } from 'react-router-dom';
import { AppRoutes } from './routes';
export default function App() {
return (
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
);
}
```
- [ ] **Create apps/frontend/src/routes.tsx**
```typescript
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:
```css
*,
*::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**
```bash
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:
```bash
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:
```typescript
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**
```bash
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**
```typescript
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**
```bash
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**
```typescript
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**
```typescript
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**
```typescript
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**
```typescript
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**
```bash
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**
```typescript
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**
```typescript
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**
```typescript
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**
```bash
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**
```typescript
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**
```bash
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**
```typescript
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**
```typescript
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**
```bash
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**
```typescript
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**
```bash
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**
```typescript
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**
```bash
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**
```typescript
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**
```typescript
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**
```bash
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.yaml`** with the generated spec if any changes were made during development.
- [ ] **Commit**
```bash
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):
```markdown
# 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**
```dockerfile
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**
```dockerfile
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**
```nginx
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**
```yaml
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**
```bash
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**:
```bash
npm run test:backend
```
- [ ] **Verify type generation**:
```bash
cd apps/frontend && npx openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts
```
- [ ] **Final commit**
```bash
git add README.md
git commit -m "chore: add README with quick start instructions"
```
---