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

94 KiB
Raw Permalink Blame History

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 install at 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.yaml with 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"