# MoexVibe MVP Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a working MVP of MoexVibe — web application for analyzing MOEX stocks and bonds. **Architecture:** NestJS monolith serving normalized REST API (OpenAPI 3.0), in-memory cache, rate-limited MOEX ISS client. React SPA with TanStack Query and openapi-typescript codegen. Dev-only single server, production via Docker. **Tech Stack:** NestJS, React, Vite, TypeScript, TanStack Query, React Router, @nestjs/cache-manager, node-fetch/axios, lightweight-charts, vitest, Docusaurus --- ## File Structure ``` moex-vibe/ ├── package.json # Root npm workspaces ├── tsconfig.base.json ├── .gitignore ├── .prettierrc ├── .eslintrc.cjs ├── apps/ │ ├── backend/ │ │ ├── package.json │ │ ├── tsconfig.json │ │ ├── nest-cli.json │ │ └── src/ │ │ ├── main.ts │ │ ├── app.module.ts │ │ ├── config/ │ │ │ └── configuration.ts │ │ ├── common/ │ │ │ ├── dto/ │ │ │ │ ├── api-response.dto.ts │ │ │ │ └── pagination.dto.ts │ │ │ ├── filters/ │ │ │ │ └── http-exception.filter.ts │ │ │ ├── interceptors/ │ │ │ │ ├── logging.interceptor.ts │ │ │ │ └── transform.interceptor.ts │ │ │ └── middleware/ │ │ │ └── request-logging.middleware.ts │ │ └── modules/ │ │ ├── moex-client/ │ │ │ ├── moex-client.module.ts │ │ │ ├── moex-client.service.ts │ │ │ ├── moex-client.service.spec.ts │ │ │ └── moex-client.types.ts │ │ ├── cache/ │ │ │ ├── cache.module.ts │ │ │ └── cache.service.ts │ │ ├── securities/ │ │ │ ├── securities.module.ts │ │ │ ├── securities.controller.ts │ │ │ ├── securities.controller.spec.ts │ │ │ ├── securities.service.ts │ │ │ ├── securities.service.spec.ts │ │ │ └── dto/ │ │ │ └── search-query.dto.ts │ │ ├── shares/ │ │ │ ├── shares.module.ts │ │ │ ├── shares.controller.ts │ │ │ ├── shares.controller.spec.ts │ │ │ ├── shares.service.ts │ │ │ ├── shares.service.spec.ts │ │ │ └── dto/ │ │ │ ├── share-response.dto.ts │ │ │ ├── share-marketdata-response.dto.ts │ │ │ ├── dividends-response.dto.ts │ │ │ └── history-query.dto.ts │ │ ├── bonds/ │ │ │ ├── bonds.module.ts │ │ │ ├── bonds.controller.ts │ │ │ ├── bonds.controller.spec.ts │ │ │ ├── bonds.service.ts │ │ │ ├── bonds.service.spec.ts │ │ │ └── dto/ │ │ │ ├── bond-response.dto.ts │ │ │ ├── bond-marketdata-response.dto.ts │ │ │ └── bond-history.dto.ts │ │ ├── candles/ │ │ │ ├── candles.module.ts │ │ │ ├── candles.controller.ts │ │ │ ├── candles.controller.spec.ts │ │ │ ├── candles.service.ts │ │ │ ├── candles.service.spec.ts │ │ │ └── dto/ │ │ │ └── candles-query.dto.ts │ │ └── health/ │ │ └── health.controller.ts │ └── frontend/ │ ├── package.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ ├── index.html │ └── src/ │ ├── main.tsx │ ├── App.tsx │ ├── routes.tsx │ ├── styles.css │ ├── api/ │ │ └── (generated by openapi-typescript) │ ├── hooks/ │ │ ├── useSearch.ts │ │ ├── useStock.ts │ │ ├── useStockCandles.ts │ │ ├── useStockDividends.ts │ │ ├── useBond.ts │ │ └── useBondCandles.ts │ ├── pages/ │ │ ├── HomePage.tsx │ │ ├── StockPage.tsx │ │ └── BondPage.tsx │ ├── components/ │ │ ├── Layout.tsx │ │ ├── SearchBar.tsx │ │ ├── SecurityCard.tsx │ │ ├── PriceChart.tsx │ │ ├── StockDetails.tsx │ │ └── BondDetails.tsx │ ├── types/ │ │ └── (generated, re-exported) │ └── vite-env.d.ts ├── docker/ │ ├── Dockerfile.backend │ ├── Dockerfile.frontend │ └── nginx.conf ├── docker-compose.yml └── docs/ ├── architecture/adr/ ├── openapi/openapi.yaml └── superpowers/specs/2026-06-13-moex-vibe-design.md ``` --- ## SPRINT 1: Backend Foundation ### Task 1.1: Initialize project root with npm workspaces **Files:** - Create: `package.json` - Create: `tsconfig.base.json` - Create: `.gitignore` - Create: `.prettierrc` - [ ] **Create root package.json with workspaces** ```json { "name": "moex-vibe", "private": true, "workspaces": [ "apps/backend", "apps/frontend" ], "scripts": { "dev:backend": "npm run start:dev -w apps/backend", "dev:frontend": "npm run dev -w apps/frontend", "build:backend": "npm run build -w apps/backend", "build:frontend": "npm run build -w apps/frontend", "test:backend": "npm run test -w apps/backend", "lint": "npm run lint -w apps/backend", "format": "prettier --write \"**/*.ts\"" }, "devDependencies": { "prettier": "^3.0.0" } } ``` - [ ] **Create tsconfig.base.json** ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "declaration": true, "declarationMap": true, "sourceMap": true } } ``` - [ ] **Create .gitignore** ``` node_modules/ dist/ .env *.log .DS_Store ``` - [ ] **Create .prettierrc** ```json { "singleQuote": true, "trailingComma": "all", "printWidth": 100, "semi": true } ``` - [ ] **Run `npm install` at root** to create lockfile and workspace links. - [ ] **Commit** ```bash git add package.json tsconfig.base.json .gitignore .prettierrc git commit -m "chore: initialize monorepo with npm workspaces" ``` ### Task 1.2: Scaffold NestJS backend **Files:** - Create: `apps/backend/package.json` - Create: `apps/backend/tsconfig.json` - Create: `apps/backend/nest-cli.json` - Create: `apps/backend/src/main.ts` - Create: `apps/backend/src/app.module.ts` - [ ] **Create apps/backend/package.json** ```json { "name": "@moex-vibe/backend", "version": "0.0.1", "private": true, "scripts": { "build": "nest build", "start:dev": "nest start --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,test}/**/*.ts\"", "test": "vitest run", "test:watch": "vitest" }, "dependencies": { "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/config": "^3.0.0", "@nestjs/swagger": "^7.0.0", "@nestjs/axios": "^3.0.0", "@nestjs/cache-manager": "^2.0.0", "cache-manager": "^5.0.0", "axios": "^1.6.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.0", "class-validator": "^0.14.0", "class-transformer": "^0.5.0", "p-queue": "^7.3.0", "swagger-ui-express": "^5.0.0" }, "devDependencies": { "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.0", "@types/node": "^20.0.0", "typescript": "^5.3.0", "vitest": "^1.0.0", "eslint": "^8.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0" } } ``` - [ ] **Create apps/backend/tsconfig.json** ```json { "extends": "../../tsconfig.base.json", "compilerOptions": { "target": "ES2022", "module": "commonjs", "outDir": "./dist", "emitDecoratorMetadata": true, "experimentalDecorators": true, "baseUrl": "./", "paths": { "@/*": ["src/*"] } }, "include": ["src/**/*"] } ``` - [ ] **Create apps/backend/nest-cli.json** ```json { "collection": "@nestjs/schematics", "sourceRoot": "src" } ``` - [ ] **Create apps/backend/src/main.ts** ```typescript import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { TransformInterceptor } from './common/interceptors/transform.interceptor'; import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware'; import { ValidationPipe } from '@nestjs/common'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.setGlobalPrefix('api/v1'); app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new TransformInterceptor()); app.use(new RequestLoggingMiddleware().use); app.enableCors(); const config = new DocumentBuilder() .setTitle('MoexVibe API') .setVersion('1.0.0') .build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api/docs', app, document); const port = process.env.PORT || 3000; await app.listen(port); console.log(`MoexVibe API running on http://localhost:${port}/api/v1`); console.log(`Swagger docs: http://localhost:${port}/api/docs`); } bootstrap(); ``` - [ ] **Create apps/backend/src/app.module.ts** ```typescript import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { CacheModule } from './modules/cache/cache.module'; import { MoexClientModule } from './modules/moex-client/moex-client.module'; import { HealthModule } from './modules/health/health.module'; import { SecuritiesModule } from './modules/securities/securities.module'; import { SharesModule } from './modules/shares/shares.module'; import { BondsModule } from './modules/bonds/bonds.module'; import { CandlesModule } from './modules/candles/candles.module'; import configuration from './config/configuration'; @Module({ imports: [ ConfigModule.forRoot({ load: [configuration], isGlobal: true }), CacheModule, MoexClientModule, HealthModule, SecuritiesModule, SharesModule, BondsModule, CandlesModule, ], }) export class AppModule {} ``` - [ ] **Commit** ```bash git add apps/backend/ git commit -m "feat: scaffold NestJS backend with Swagger, validation, CORS" ``` ### Task 1.3: Configuration module **Files:** - Create: `apps/backend/src/config/configuration.ts` - [ ] **Create configuration.ts** ```typescript import { registerAs } from '@nestjs/config'; export default registerAs('app', () => ({ port: parseInt(process.env.PORT || '3000', 10), moex: { baseUrl: process.env.MOEX_BASE_URL || 'https://iss.moex.com/iss', rateLimit: parseInt(process.env.MOEX_RATE_LIMIT || '10', 10), circuitBreakerThreshold: parseInt( process.env.MOEX_CIRCUIT_BREAKER_THRESHOLD || '5', 10, ), circuitBreakerResetSeconds: parseInt( process.env.MOEX_CIRCUIT_BREAKER_RESET_SECONDS || '30', 10, ), }, cache: { marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10), historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10), candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10), securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10), searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10), dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10), }, })); ``` - [ ] **Commit** ```bash git add apps/backend/src/config/ git commit -m "feat: add configuration module with env vars" ``` ### Task 1.4: Common DTOs, filters, interceptors, middleware **Files:** - Create: `apps/backend/src/common/dto/api-response.dto.ts` - Create: `apps/backend/src/common/dto/pagination.dto.ts` - Create: `apps/backend/src/common/filters/http-exception.filter.ts` - Create: `apps/backend/src/common/interceptors/transform.interceptor.ts` - Create: `apps/backend/src/common/middleware/request-logging.middleware.ts` - [ ] **Create common/dto/api-response.dto.ts** ```typescript import { ApiProperty } from '@nestjs/swagger'; export class ApiResponseMeta { @ApiProperty({ nullable: true }) cachedAt: string | null; @ApiProperty() fromCache: boolean; } export class ApiResponse { data: T; meta: ApiResponseMeta; constructor(data: T, fromCache = false, cachedAt: string | null = null) { this.data = data; this.meta = { cachedAt, fromCache }; } } ``` - [ ] **Create common/dto/pagination.dto.ts** ```typescript import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsOptional, IsInt, Min, Max } from 'class-validator'; export class PaginationDto { @ApiPropertyOptional({ default: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number = 1; @ApiPropertyOptional({ default: 20 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) limit?: number = 20; } ``` - [ ] **Create common/filters/http-exception.filter.ts** ```typescript import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, } from '@nestjs/common'; import { Response } from 'express'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); const request = ctx.getRequest(); 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; message = (r.message as string) || message; error = (r.error as string) || exception.name; } } else if (exception instanceof Error) { message = exception.message; } response.status(status).json({ statusCode: status, message, error, timestamp: new Date().toISOString(), path: request.url, }); } } ``` - [ ] **Create common/interceptors/transform.interceptor.ts** ```typescript import { Injectable, NestInterceptor, ExecutionContext, CallHandler, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { ApiResponse } from '../dto/api-response.dto'; @Injectable() export class TransformInterceptor implements NestInterceptor> { intercept( context: ExecutionContext, next: CallHandler, ): Observable> { return next.handle().pipe( map((data) => { if (data instanceof ApiResponse) return data; return new ApiResponse(data, false, null); }), ); } } ``` - [ ] **Create common/middleware/request-logging.middleware.ts** ```typescript import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; import { Request, Response, NextFunction } from 'express'; @Injectable() export class RequestLoggingMiddleware implements NestMiddleware { private logger = new Logger('HTTP'); use(req: Request, res: Response, next: NextFunction): void { const { method, originalUrl } = req; const start = Date.now(); res.on('finish', () => { const { statusCode } = res; const duration = Date.now() - start; this.logger.log(`${method} ${originalUrl} ${statusCode} ${duration}ms`); }); next(); } } ``` - [ ] **Commit** ```bash git add apps/backend/src/common/ git commit -m "feat: add common DTOs, exception filter, transform interceptor, logging middleware" ``` ### Task 1.5: Health check endpoint **Files:** - Create: `apps/backend/src/modules/health/health.controller.ts` - [ ] **Create health.controller.ts** ```typescript import { Controller, Get } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; @ApiTags('Health') @Controller('health') export class HealthController { @Get() @ApiOperation({ summary: 'Проверка состояния сервиса' }) check() { return { status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime(), }; } } ``` - [ ] **Commit** ```bash git add apps/backend/src/modules/health/ git commit -m "feat: add health check endpoint" ``` ### Task 1.6: MoexClient module with rate limiting **Files:** - Create: `apps/backend/src/modules/moex-client/moex-client.module.ts` - Create: `apps/backend/src/modules/moex-client/moex-client.service.ts` - Create: `apps/backend/src/modules/moex-client/moex-client.types.ts` - Create: `apps/backend/src/modules/moex-client/moex-client.service.spec.ts` - [ ] **Create moex-client.types.ts** ```typescript export interface MoexSecurityDescription { secid: string; isin: string; name: string; shortName: string; latName: string | null; listLevel: number; issueSize: number; faceValue: number; faceUnit: string; issueDate: string; typeName: string; group: string; type: string; isQualifiedInvestors: boolean; morningSession: boolean; eveningSession: boolean; } export interface MoexShareMarketData { secid: string; boardid: string; bid: number | null; offer: number | null; open: number | null; low: number | null; high: number | null; last: number | null; lastChange: number | null; lastChangePrcnt: number | null; volume: number; value: number; waprice: number | null; numtrades: number; issueCapitalization: number | null; tradingStatus: string; updateTime: string; } export interface MoexBondData { secid: string; boardid: string; shortName: string; prevWaprice: number | null; yieldAtPrevWaprice: number | null; couponValue: number | null; nextCoupon: string | null; accruedInt: number | null; prevPrice: number | null; lotSize: number; faceValue: number; matDate: string; couponPeriod: number; issueSize: number; isin: string; couponPercent: number | null; offerDate: string | null; buybackDate: string | null; bondType: string; bondSubType: string; listLevel: number; } export interface MoexBondMarketData { secid: string; bid: number | null; offer: number | null; open: number | null; low: number | null; high: number | null; last: number | null; yield: number | null; waprice: number | null; yieldAtWaprice: number | null; duration: number | null; volume: number; value: number; numtrades: number; tradingStatus: string; updateTime: string; } export interface MoexDividend { secid: string; isin: string; registryCloseDate: string; value: number; currencyId: string; } export interface MoexCandle { open: number; close: number; high: number; low: number; value: number; volume: number; begin: string; end: string; } export interface MoexHistoryEntry { tradeDate: string; open: number | null; low: number | null; high: number | null; close: number | null; waprice: number | null; volume: number; value: number; numtrades: number; } export interface MoexBondHistoryEntry { tradeDate: string; close: number | null; legalClosePrice: number | null; waprice: number | null; yieldClose: number | null; duration: number | null; accruedInt: number | null; } export interface MoexBoard { secid: string; boardid: string; title: string; isPrimary: boolean; isTraded: boolean; currencyid: string; } ``` - [ ] **Create moex-client.service.ts** ```typescript import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios, { AxiosInstance } from 'axios'; import PQueue from 'p-queue'; import { MoexSecurityDescription, MoexShareMarketData, MoexBondData, MoexBondMarketData, MoexDividend, MoexCandle, MoexHistoryEntry, MoexBondHistoryEntry, } from './moex-client.types'; @Injectable() export class MoexClientService { private readonly logger = new Logger(MoexClientService.name); private readonly client: AxiosInstance; private readonly queue: PQueue; private circuitOpen = false; private circuitErrorCount = 0; private readonly threshold: number; private readonly resetMs: number; constructor(private configService: ConfigService) { const baseUrl = this.configService.get('app.moex.baseUrl')!; this.threshold = this.configService.get( 'app.moex.circuitBreakerThreshold', 5, ); this.resetMs = this.configService.get( 'app.moex.circuitBreakerResetSeconds', 30, ) * 1000; const rateLimit = this.configService.get( '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(path: string, params?: Record): Promise { 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; } private extractTable(data: Record, name: string): Record[] { const table = data[name] as Record | 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 = {}; columns.forEach((col, i) => { obj[col] = row[i]; }); return obj; }); } async searchSecurities(query: string): Promise { const data = await this.request>('/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 { const data = await this.request>(`/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 { const data = await this.request>( `/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 { const data = await this.request>( `/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 { const data = await this.request>( `/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 { const data = await this.request>(`/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 { const intervalMap: Record = { 1: '1min', 10: '10min', 60: '1hour', 24: '24hours', }; const data = await this.request>( `/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 { const data = await this.request>( `/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 { const data = await this.request>( `/engines/stock/markets/bonds/securities/${secid}`, { from, till }, ); const tableName = Object.keys(data).find( (k) => k.startsWith('history') && !k.includes('cursor'), ); if (!tableName) return []; return this.extractTable(data, tableName).map((h) => ({ tradeDate: h.TRADEDATE as string, close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null, legalClosePrice: h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null, waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null, yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null, duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null, accruedInt: h.ACCINT != null ? parseFloat(h.ACCINT as string) : null, })); } } ``` - [ ] **Create moex-client.module.ts** ```typescript import { Global, Module } from '@nestjs/common'; import { MoexClientService } from './moex-client.service'; @Global() @Module({ providers: [MoexClientService], exports: [MoexClientService], }) export class MoexClientModule {} ``` - [ ] **Create moex-client.service.spec.ts** ```typescript import { Test, TestingModule } from '@nestjs/testing'; import { ConfigModule } from '@nestjs/config'; import { MoexClientService } from './moex-client.service'; import configuration from '../../config/configuration'; describe('MoexClientService', () => { let service: MoexClientService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ ConfigModule.forRoot({ load: [configuration] }), ], providers: [MoexClientService], }).compile(); service = module.get(MoexClientService); }); it('should be defined', () => { expect(service).toBeDefined(); }); describe('searchSecurities', () => { it('should return results for SBER query', async () => { const results = await service.searchSecurities('SBER'); expect(Array.isArray(results)).toBe(true); if (results.length > 0) { expect(results[0].secid).toBeDefined(); } }, 15000); }); describe('getShareMarketData', () => { it('should return market data for SBER', async () => { const data = await service.getShareMarketData('SBER'); expect(data).toBeDefined(); expect(data!.secid).toBe('SBER'); }, 15000); }); }); ``` - [ ] **Commit** ```bash git add apps/backend/src/modules/moex-client/ git commit -m "feat: add MoexClient with rate-limited HTTP client, circuit breaker, and MOEX ISS data methods" ``` ### Task 1.7: Cache module **Files:** - Create: `apps/backend/src/modules/cache/cache.module.ts` - Create: `apps/backend/src/modules/cache/cache.service.ts` - [ ] **Create cache.module.ts** ```typescript import { Module, CacheModule as NestCacheModule } from '@nestjs/cache-manager'; import { CacheService } from './cache.service'; @Module({ imports: [ NestCacheModule.register({ ttl: 900, max: 1000, isGlobal: true, }), ], providers: [CacheService], exports: [CacheService], }) export class CacheModule {} ``` - [ ] **Create cache.service.ts** ```typescript import { Injectable, Inject } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { ConfigService } from '@nestjs/config'; @Injectable() export class CacheService { constructor( @Inject(CACHE_MANAGER) private cacheManager: Cache, private configService: ConfigService, ) {} async get(key: string): Promise { return this.cacheManager.get(key); } async set(key: string, value: unknown, ttl?: number): Promise { await this.cacheManager.set(key, value, ttl); } private buildKey(...parts: string[]): string { return parts.join(':'); } async getOrFetch( keyPrefix: string, keyParts: string[], fetchFn: () => Promise, ttlConfigKey: string, ): Promise<{ data: T; fromCache: boolean; cachedAt: string | null }> { const key = this.buildKey(keyPrefix, ...keyParts); const ttl = this.configService.get(`app.cache.${ttlConfigKey}`, 900); const cached = await this.get(key); if (cached !== undefined) { return { data: cached, fromCache: true, cachedAt: null }; } const data = await fetchFn(); await this.set(key, data, ttl); return { data, fromCache: false, cachedAt: new Date().toISOString() }; } } ``` - [ ] **Commit** ```bash git add apps/backend/src/modules/cache/ git commit -m "feat: add cache module with getOrFetch pattern and configurable TTL" ``` --- ## SPRINT 2: Securities API ### Task 2.1: Securities search module **Files:** - Create: `apps/backend/src/modules/securities/dto/search-query.dto.ts` - Create: `apps/backend/src/modules/securities/securities.controller.ts` - Create: `apps/backend/src/modules/securities/securities.service.ts` - Create: `apps/backend/src/modules/securities/securities.module.ts` - Create: `apps/backend/src/modules/securities/securities.controller.spec.ts` - Create: `apps/backend/src/modules/securities/securities.service.spec.ts` - [ ] **Create search-query.dto.ts** ```typescript import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsString, IsOptional, IsEnum, MinLength, MaxLength } from 'class-validator'; export enum SecurityType { ALL = 'all', SHARE = 'share', BOND = 'bond', } export class SearchQueryDto { @ApiProperty({ description: 'Поисковый запрос (тикер, название, ISIN)' }) @IsString() @MinLength(1) @MaxLength(100) q: string; @ApiPropertyOptional({ enum: SecurityType, default: SecurityType.ALL }) @IsOptional() @IsEnum(SecurityType) type?: SecurityType = SecurityType.ALL; @ApiPropertyOptional({ default: 20 }) @IsOptional() limit?: number = 20; } ``` - [ ] **Create search result DTO inline or reuse** — add to controller response via transform interceptor. - [ ] **Create securities.service.ts** ```typescript import { Injectable } from '@nestjs/common'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; import { SecurityType } from './dto/search-query.dto'; export interface SearchResultItem { secid: string; isin: string; shortName: string; type: 'share' | 'bond'; listLevel: number; currency: string | null; price: number | null; } @Injectable() export class SecuritiesService { constructor( private readonly moexClient: MoexClientService, private readonly cache: CacheService, ) {} async search(query: string, type: SecurityType, limit: number): Promise { 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 { try { const desc = await this.moexClient.getSecurityDescription(secid); if (!desc) return null; return { secid: desc.secid, isin: desc.isin, shortName: desc.shortName, type: 'share', listLevel: desc.listLevel, currency: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit, price: null, }; } catch { return null; } } } ``` - [ ] **Create securities.controller.ts** ```typescript import { Controller, Get, Query, ValidationPipe } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger'; import { SecuritiesService } from './securities.service'; import { SearchQueryDto, SecurityType } from './dto/search-query.dto'; @ApiTags('Securities') @Controller('securities') export class SecuritiesController { constructor(private readonly securitiesService: SecuritiesService) {} @Get('search') @ApiOperation({ summary: 'Поиск по инструментам' }) async search(@Query(ValidationPipe) query: SearchQueryDto) { const results = await this.securitiesService.search( query.q, query.type || SecurityType.ALL, query.limit || 20, ); return { data: results, meta: { cachedAt: null, fromCache: false } }; } } ``` - [ ] **Create securities.module.ts** ```typescript import { Module } from '@nestjs/common'; import { SecuritiesController } from './securities.controller'; import { SecuritiesService } from './securities.service'; @Module({ controllers: [SecuritiesController], providers: [SecuritiesService], exports: [SecuritiesService], }) export class SecuritiesModule {} ``` - [ ] **Create securities.service.spec.ts** ```typescript import { Test, TestingModule } from '@nestjs/testing'; import { ConfigModule } from '@nestjs/config'; import { SecuritiesService } from './securities.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; import configuration from '../../config/configuration'; import { SecurityType } from './dto/search-query.dto'; describe('SecuritiesService', () => { let service: SecuritiesService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ imports: [ConfigModule.forRoot({ load: [configuration] })], providers: [ SecuritiesService, MoexClientService, { provide: 'CACHE_MANAGER', useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() }, }, CacheService, ], }).compile(); service = module.get(SecuritiesService); }); it('should return search results for SBER', async () => { const results = await service.search('SBER', SecurityType.ALL, 5); expect(results.length).toBeGreaterThan(0); expect(results[0].secid).toBeDefined(); }, 15000); }); ``` - [ ] **Create securities.controller.spec.ts** — similar pattern with mocked service. - [ ] **Commit** ```bash git add apps/backend/src/modules/securities/ git commit -m "feat: add securities search endpoint" ``` ### Task 2.2: Shares module — spec + marketdata **Files:** - Create: `apps/backend/src/modules/shares/dto/share-response.dto.ts` - Create: `apps/backend/src/modules/shares/dto/share-marketdata-response.dto.ts` - Create: `apps/backend/src/modules/shares/shares.service.ts` - Create: `apps/backend/src/modules/shares/shares.controller.ts` - Create: `apps/backend/src/modules/shares/shares.module.ts` - [ ] **Create share-response.dto.ts** ```typescript import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class StockMarketDataDto { @ApiProperty({ example: 322.35 }) price: number; @ApiProperty({ example: 1.15 }) change: number; @ApiProperty({ example: 0.36 }) changePercent: number; @ApiProperty({ example: 321.3 }) open: number; @ApiPropertyOptional({ example: 322.66 }) high: number | null; @ApiPropertyOptional({ example: 321.2 }) low: number | null; @ApiProperty({ example: 1925163 }) volume: number; @ApiProperty({ example: 620184479 }) value: number; @ApiPropertyOptional({ example: 6958336818320 }) issueCapitalization: number | null; @ApiProperty() updatedAt: string; } export class ShareResponseDto { @ApiProperty({ example: 'SBER' }) secid: string; @ApiProperty({ example: 'RU0009029540' }) isin: string; @ApiProperty({ example: 'Сбербанк России ПАО ао' }) name: string; @ApiProperty({ example: 'Сбербанк' }) shortName: string; @ApiPropertyOptional() latName: string | null; @ApiProperty({ example: 1 }) listLevel: number; @ApiProperty({ example: 21586948000 }) issueSize: number; @ApiProperty({ example: 3 }) faceValue: number; @ApiProperty({ example: 'RUB' }) faceUnit: string; @ApiProperty({ example: 'common_share' }) type: string; @ApiProperty() marketData: StockMarketDataDto; } ``` - [ ] **Create share-marketdata-response.dto.ts** ```typescript import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { StockMarketDataDto } from './share-response.dto'; export class ShareMarketDataResponseDto extends StockMarketDataDto {} ``` - [ ] **Create shares.service.ts** ```typescript import { Injectable, NotFoundException } from '@nestjs/common'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; @Injectable() export class SharesService { constructor( private readonly moexClient: MoexClientService, private readonly cache: CacheService, ) {} async getShare(secid: string) { const desc = await this.moexClient.getSecurityDescription(secid); if (!desc || !(desc.group === 'stock_shares' || desc.type === 'common_share' || desc.type === 'preferred_share')) { throw new NotFoundException(`Share ${secid} not found`); } const { data: marketData } = await this.cache.getOrFetch( 'marketdata', ['shares', secid], () => this.moexClient.getShareMarketData(secid), 'marketDataTtl', ); const price = marketData?.last ?? (marketData ? null : 0); const prevPrice = 0; // not stored separately, but available from securities table const change = marketData?.lastChange ?? 0; const changePercent = marketData?.lastChangePrcnt ?? 0; return { secid: desc.secid, isin: desc.isin, name: desc.name, shortName: desc.shortName, latName: desc.latName, listLevel: desc.listLevel, issueSize: desc.issueSize, faceValue: desc.faceValue, faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit, type: desc.type, marketData: { price: price ?? 0, change, changePercent, open: marketData?.open ?? 0, high: marketData?.high ?? null, low: marketData?.low ?? null, volume: marketData?.volume ?? 0, value: marketData?.value ?? 0, issueCapitalization: marketData?.issueCapitalization ?? null, updatedAt: marketData?.updateTime ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime : new Date().toISOString(), }, }; } async getMarketData(secid: string) { const { data: marketData, fromCache, cachedAt } = await this.cache.getOrFetch( 'marketdata', ['shares', secid], () => this.moexClient.getShareMarketData(secid), 'marketDataTtl', ); if (!marketData) { throw new NotFoundException(`Market data for ${secid} not found`); } return { data: { price: marketData.last ?? 0, change: marketData.lastChange ?? 0, changePercent: marketData.lastChangePrcnt ?? 0, open: marketData.open ?? 0, high: marketData.high ?? null, low: marketData.low ?? null, volume: marketData.volume ?? 0, value: marketData.value ?? 0, issueCapitalization: marketData.issueCapitalization ?? null, updatedAt: marketData.updateTime ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime : new Date().toISOString(), }, meta: { fromCache, cachedAt }, }; } async getDividends(secid: string) { const { data, fromCache, cachedAt } = await this.cache.getOrFetch( 'dividends', [secid], () => this.moexClient.getDividends(secid), 'dividendsTtl', ); return { data: data.map((d) => ({ registryCloseDate: d.registryCloseDate, value: d.value, currency: d.currencyId, })), meta: { fromCache, cachedAt }, }; } } ``` - [ ] **Create shares.controller.ts** ```typescript import { Controller, Get, Param } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { SharesService } from './shares.service'; @ApiTags('Shares') @Controller('securities/shares') export class SharesController { constructor(private readonly sharesService: SharesService) {} @Get(':secid') @ApiOperation({ summary: 'Получить спецификацию акции' }) async getShare(@Param('secid') secid: string) { const share = await this.sharesService.getShare(secid); return { data: share, meta: { cachedAt: null, fromCache: false } }; } @Get(':secid/marketdata') @ApiOperation({ summary: 'Получить рыночные данные акции' }) async getMarketData(@Param('secid') secid: string) { return this.sharesService.getMarketData(secid); } @Get(':secid/dividends') @ApiOperation({ summary: 'Получить дивиденды' }) async getDividends(@Param('secid') secid: string) { return this.sharesService.getDividends(secid); } } ``` - [ ] **Create shares.module.ts** ```typescript import { Module } from '@nestjs/common'; import { SharesController } from './shares.controller'; import { SharesService } from './shares.service'; @Module({ controllers: [SharesController], providers: [SharesService], exports: [SharesService], }) export class SharesModule {} ``` - [ ] **Commit** ```bash git add apps/backend/src/modules/shares/ git commit -m "feat: add shares endpoint with market data and dividends" ``` --- ## SPRINT 3: Bonds + History + Candles ### Task 3.1: Bonds module **Files:** - Create: `apps/backend/src/modules/bonds/dto/bond-response.dto.ts` - Create: `apps/backend/src/modules/bonds/dto/bond-marketdata-response.dto.ts` - Create: `apps/backend/src/modules/bonds/bonds.service.ts` - Create: `apps/backend/src/modules/bonds/bonds.controller.ts` - Create: `apps/backend/src/modules/bonds/bonds.module.ts` - [ ] **Create bond-response.dto.ts** ```typescript import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class BondMarketDataDto { @ApiProperty({ description: 'Цена в % от номинала', example: 100.45 }) price: number; @ApiPropertyOptional({ example: 12.71 }) yieldToMaturity: number | null; @ApiPropertyOptional() duration: number | null; @ApiProperty({ example: 29.48 }) accruedInt: number; @ApiProperty({ example: 40.64 }) couponValue: number; @ApiPropertyOptional({ example: 8.15 }) couponPercent: number | null; @ApiPropertyOptional({ example: '2026-08-05' }) nextCouponDate: string | null; @ApiProperty() open: number; @ApiPropertyOptional() high: number | null; @ApiPropertyOptional() low: number | null; @ApiProperty() volume: number; @ApiProperty() updatedAt: string; } export class BondResponseDto { @ApiProperty({ example: 'SU26207RMFS9' }) secid: string; @ApiProperty({ example: 'RU000A0JS3W6' }) isin: string; @ApiProperty({ example: 'ОФЗ-ПД 26207 03/02/27' }) name: string; @ApiProperty({ example: 'ОФЗ 26207' }) shortName: string; @ApiPropertyOptional() latName: string | null; @ApiProperty({ example: 1 }) listLevel: number; @ApiProperty({ example: 370200604 }) issueSize: number; @ApiProperty({ example: 1000 }) faceValue: number; @ApiProperty({ example: 'RUB' }) faceUnit: string; @ApiProperty({ example: '2027-02-03' }) matDate: string; @ApiProperty({ example: 40.64 }) couponValue: number; @ApiPropertyOptional({ example: 8.15 }) couponPercent: number | null; @ApiProperty({ example: 182 }) couponPeriod: number; @ApiProperty({ example: '2026-08-05' }) nextCoupon: string | null; @ApiProperty({ example: 29.48 }) accruedInt: number; @ApiProperty({ example: 'Фикс с известным купоном' }) bondType: string; @ApiProperty({ example: 'До погашения' }) bondSubType: string; @ApiPropertyOptional() offerDate: string | null; @ApiPropertyOptional() buybackDate: string | null; @ApiProperty() marketData: BondMarketDataDto; } ``` - [ ] **Create bonds.service.ts** ```typescript import { Injectable, NotFoundException } from '@nestjs/common'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; @Injectable() export class BondsService { constructor( private readonly moexClient: MoexClientService, private readonly cache: CacheService, ) {} async getBond(secid: string) { const { data: bond, fromCache, cachedAt } = await this.cache.getOrFetch( 'bond', [secid], () => this.moexClient.getBondData(secid), 'securityTtl', ); if (!bond) { throw new NotFoundException(`Bond ${secid} not found`); } const { data: mkt } = await this.cache.getOrFetch( 'marketdata', ['bonds', secid], () => this.moexClient.getBondMarketData(secid), 'marketDataTtl', ); return { data: { secid: bond.secid, isin: bond.isin, name: bond.shortName, shortName: bond.shortName, latName: null, listLevel: bond.listLevel, issueSize: bond.issueSize, faceValue: bond.faceValue, faceUnit: bond.isin.startsWith('XS') ? 'USD' : 'RUB', matDate: bond.matDate, couponValue: bond.couponValue ?? 0, couponPercent: bond.couponPercent, couponPeriod: bond.couponPeriod, nextCoupon: bond.nextCoupon, accruedInt: bond.accruedInt ?? 0, bondType: bond.bondType, bondSubType: bond.bondSubType, offerDate: bond.offerDate, buybackDate: bond.buybackDate, marketData: { price: mkt?.last ?? bond.prevPrice ?? 0, yieldToMaturity: mkt?.yield ?? bond.yieldAtPrevWaprice ?? null, duration: mkt?.duration ?? null, accruedInt: bond.accruedInt ?? 0, couponValue: bond.couponValue ?? 0, couponPercent: bond.couponPercent, nextCouponDate: bond.nextCoupon, open: mkt?.open ?? 0, high: mkt?.high ?? null, low: mkt?.low ?? null, volume: mkt?.volume ?? 0, updatedAt: mkt?.updateTime ? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime : new Date().toISOString(), }, }, meta: { fromCache, cachedAt }, }; } async getMarketData(secid: string) { const { data: mkt, fromCache, cachedAt } = await this.cache.getOrFetch( 'marketdata', ['bonds', secid], () => this.moexClient.getBondMarketData(secid), 'marketDataTtl', ); if (!mkt) { throw new NotFoundException(`Market data for bond ${secid} not found`); } return { data: { price: mkt.last ?? 0, yieldToMaturity: mkt.yield ?? null, duration: mkt.duration ?? null, accruedInt: 0, couponValue: 0, couponPercent: null, nextCouponDate: null, open: mkt.open ?? 0, high: mkt.high ?? null, low: mkt.low ?? null, volume: mkt.volume ?? 0, updatedAt: mkt.updateTime ? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime : new Date().toISOString(), }, meta: { fromCache, cachedAt }, }; } async getHistory(secid: string, from: string, till: string) { const { data, fromCache, cachedAt } = await this.cache.getOrFetch( 'history', ['bonds', secid, from, till], () => this.moexClient.getBondHistory(secid, from, till), 'historyTtl', ); return { data: data.map((h) => ({ date: h.tradeDate, closePrice: h.legalClosePrice ?? h.close ?? 0, yieldClose: h.yieldClose ?? null, duration: h.duration ?? null, })), meta: { fromCache, cachedAt }, }; } } ``` - [ ] **Create bonds.controller.ts** ```typescript import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { BondsService } from './bonds.service'; @ApiTags('Bonds') @Controller('securities/bonds') export class BondsController { constructor(private readonly bondsService: BondsService) {} @Get(':secid') @ApiOperation({ summary: 'Получить спецификацию облигации' }) async getBond(@Param('secid') secid: string) { return this.bondsService.getBond(secid); } @Get(':secid/marketdata') @ApiOperation({ summary: 'Получить рыночные данные облигации' }) async getMarketData(@Param('secid') secid: string) { return this.bondsService.getMarketData(secid); } @Get(':secid/history') @ApiOperation({ summary: 'Получить дневную историю торгов облигации' }) async getHistory( @Param('secid') secid: string, @Query('from') from: string, @Query('till') till: string, ) { return this.bondsService.getHistory(secid, from, till); } } ``` - [ ] **Create bonds.module.ts** ```typescript import { Module } from '@nestjs/common'; import { BondsController } from './bonds.controller'; import { BondsService } from './bonds.service'; @Module({ controllers: [BondsController], providers: [BondsService], exports: [BondsService], }) export class BondsModule {} ``` - [ ] **Commit** ```bash git add apps/backend/src/modules/bonds/ git commit -m "feat: add bonds endpoint with market data and history" ``` ### Task 3.2: Candles module (shared by shares + bonds) **Files:** - Create: `apps/backend/src/modules/candles/dto/candles-query.dto.ts` - Create: `apps/backend/src/modules/candles/candles.service.ts` - Create: `apps/backend/src/modules/candles/candles.controller.ts` - Create: `apps/backend/src/modules/candles/candles.module.ts` - [ ] **Create candles-query.dto.ts** ```typescript import { ApiProperty } from '@nestjs/swagger'; import { IsString, IsEnum, IsDateString } from 'class-validator'; export enum CandleInterval { HOUR = '1h', DAY = '24h', } export class CandlesQueryDto { @ApiProperty({ enum: CandleInterval }) @IsEnum(CandleInterval) interval: CandleInterval; @ApiProperty({ format: 'date', example: '2025-06-13' }) @IsDateString() from: string; @ApiProperty({ format: 'date', example: '2026-06-13' }) @IsDateString() till: string; } ``` - [ ] **Create candles.service.ts** ```typescript import { Injectable } from '@nestjs/common'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; import { CandleInterval } from './dto/candles-query.dto'; @Injectable() export class CandlesService { constructor( private readonly moexClient: MoexClientService, private readonly cache: CacheService, ) {} private mapInterval(interval: CandleInterval): 60 | 24 { return interval === CandleInterval.HOUR ? 60 : 24; } async getCandles( market: 'shares' | 'bonds', secid: string, interval: CandleInterval, from: string, till: string, ) { const moexInterval = this.mapInterval(interval); const { data, fromCache, cachedAt } = await this.cache.getOrFetch( 'candles', [market, secid, String(moexInterval), from, till], () => this.moexClient.getCandles('stock', market, secid, moexInterval, from, till), 'candlesTtl', ); return { data: data.map((c) => ({ open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume, value: c.value, begin: c.begin, end: c.end, })), meta: { fromCache, cachedAt }, }; } } ``` - [ ] **Create candles.controller.ts** ```typescript import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { CandlesService } from './candles.service'; import { CandlesQueryDto } from './dto/candles-query.dto'; @ApiTags('Candles') @Controller('securities') export class CandlesController { constructor(private readonly candlesService: CandlesService) {} @Get('shares/:secid/candles') @ApiOperation({ summary: 'Получить свечи акции' }) async getShareCandles( @Param('secid') secid: string, @Query(ValidationPipe) query: CandlesQueryDto, ) { return this.candlesService.getCandles('shares', secid, query.interval, query.from, query.till); } @Get('bonds/:secid/candles') @ApiOperation({ summary: 'Получить свечи облигации' }) async getBondCandles( @Param('secid') secid: string, @Query(ValidationPipe) query: CandlesQueryDto, ) { return this.candlesService.getCandles('bonds', secid, query.interval, query.from, query.till); } } ``` - [ ] **Create candles.module.ts** ```typescript import { Module } from '@nestjs/common'; import { CandlesController } from './candles.controller'; import { CandlesService } from './candles.service'; @Module({ controllers: [CandlesController], providers: [CandlesService], exports: [CandlesService], }) export class CandlesModule {} ``` - [ ] **Add OpenAPI decorators to share history endpoint** in `shares.controller.ts`: ```typescript @Get(':secid/history') @ApiOperation({ summary: 'Получить дневную историю торгов акции' }) async getHistory( @Param('secid') secid: string, @Query('from') from: string, @Query('till') till: string, ) { return this.sharesService.getHistory(secid, from, till); } ``` - [ ] **Add getHistory method to SharesService**: ```typescript async getHistory(secid: string, from: string, till: string) { const { data, fromCache, cachedAt } = await this.cache.getOrFetch( 'history', ['shares', secid, from, till], () => this.moexClient.getHistory(secid, from, till), 'historyTtl', ); return { data: data.map((h) => ({ date: h.tradeDate, open: h.open ?? 0, high: h.high ?? 0, low: h.low ?? 0, close: h.close ?? 0, volume: h.volume, value: h.value, })), meta: { fromCache, cachedAt }, }; } ``` - [ ] **Commit** ```bash git add apps/backend/src/modules/candles/ git commit -m "feat: add candles module with 1h/24h intervals for shares and bonds" ``` --- ## SPRINT 4: Frontend Foundation ### Task 4.1: Scaffold React + Vite frontend **Files:** - Create: `apps/frontend/package.json` - Create: `apps/frontend/tsconfig.json` - Create: `apps/frontend/tsconfig.node.json` - Create: `apps/frontend/vite.config.ts` - Create: `apps/frontend/index.html` - Create: `apps/frontend/src/vite-env.d.ts` - Create: `apps/frontend/src/main.tsx` - Create: `apps/frontend/src/App.tsx` - Create: `apps/frontend/src/routes.tsx` - Create: `apps/frontend/src/styles.css` - [ ] **Create apps/frontend/package.json** ```json { "name": "@moex-vibe/frontend", "version": "0.0.1", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", "codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts" }, "dependencies": { "react": "^18.3.0", "react-dom": "^18.3.0", "react-router-dom": "^6.20.0", "@tanstack/react-query": "^5.20.0", "openapi-fetch": "^0.9.0", "lightweight-charts": "^4.1.0" }, "devDependencies": { "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.2.0", "typescript": "^5.3.0", "vite": "^5.4.0", "openapi-typescript": "^7.0.0" } } ``` - [ ] **Create apps/frontend/tsconfig.json** ```json { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": false, "noUnusedParameters": false, "noFallthroughCasesInSwitch": true, "paths": { "@/*": ["./src/*"] } }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ``` - [ ] **Create apps/frontend/tsconfig.node.json** ```json { "compilerOptions": { "target": "ES2022", "lib": ["ES2023"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true, "strict": true }, "include": ["vite.config.ts"] } ``` - [ ] **Create apps/frontend/vite.config.ts** ```typescript import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, server: { port: 5173, proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, }, }, }, }); ``` - [ ] **Create apps/frontend/index.html** ```html MoexVibe
``` - [ ] **Create apps/frontend/src/vite-env.d.ts** ```typescript /// ``` - [ ] **Create apps/frontend/src/main.tsx** ```typescript import React from 'react'; import ReactDOM from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import App from './App'; import './styles.css'; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 2, staleTime: 900_000, refetchOnWindowFocus: false, }, }, }); ReactDOM.createRoot(document.getElementById('root')!).render( , ); ``` - [ ] **Create apps/frontend/src/App.tsx** ```typescript import { BrowserRouter } from 'react-router-dom'; import { AppRoutes } from './routes'; export default function App() { return ( ); } ``` - [ ] **Create apps/frontend/src/routes.tsx** ```typescript import { Routes, Route } from 'react-router-dom'; import { Layout } from './components/Layout'; import { HomePage } from './pages/HomePage'; import { StockPage } from './pages/StockPage'; import { BondPage } from './pages/BondPage'; export function AppRoutes() { return ( }> } /> } /> } /> ); } ``` - [ ] **Create apps/frontend/src/styles.css** — minimal reset: ```css *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } :root { --color-bg: #f5f5f5; --color-surface: #ffffff; --color-text: #1a1a1a; --color-text-secondary: #666; --color-primary: #1976d2; --color-positive: #2e7d32; --color-negative: #c62828; --border-radius: 8px; --shadow: 0 1px 3px rgba(0, 0, 0, 0.12); } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--color-bg); color: var(--color-text); line-height: 1.6; } a { color: var(--color-primary); text-decoration: none; } ``` - [ ] **Commit** ```bash git add apps/frontend/ git commit -m "feat: scaffold React + Vite frontend with routing" ``` ### Task 4.2: Generate API client from OpenAPI schema - [ ] **Start backend**, then run: ```bash npm run codegen -w apps/frontend ``` This creates `apps/frontend/src/api/types.ts` with all typed DTOs. - [ ] **Create apps/frontend/src/api/client.ts** — typed fetch wrapper: ```typescript import createClient from 'openapi-fetch'; import type { paths } from './types'; export const apiClient = createClient({ baseUrl: '/api/v1', }); export type ApiResponse = { data: T; meta: { cachedAt: string | null; fromCache: boolean; }; }; ``` - [ ] **Commit** ```bash git add apps/frontend/src/api/ git commit -m "feat: add openapi-typescript generated types and API client" ``` ### Task 4.3: Layout component **Files:** - Create: `apps/frontend/src/components/Layout.tsx` - [ ] **Create Layout.tsx** ```typescript import { Outlet, Link } from 'react-router-dom'; const headerStyle: React.CSSProperties = { background: 'var(--color-surface)', borderBottom: '1px solid #e0e0e0', padding: '12px 24px', display: 'flex', alignItems: 'center', gap: 24, position: 'sticky', top: 0, zIndex: 100, }; const mainStyle: React.CSSProperties = { maxWidth: 1200, margin: '0 auto', padding: '24px 16px', }; export function Layout() { return (
MoexVibe
); } ``` - [ ] **Commit** ```bash git add apps/frontend/src/components/Layout.tsx git commit -m "feat: add Layout component with header" ``` ### Task 4.4: Search hook + HomePage **Files:** - Create: `apps/frontend/src/hooks/useSearch.ts` - Create: `apps/frontend/src/components/SearchBar.tsx` - Create: `apps/frontend/src/components/SecurityCard.tsx` - Create: `apps/frontend/src/pages/HomePage.tsx` - [ ] **Create useSearch.ts** ```typescript import { useQuery } from '@tanstack/react-query'; import { apiClient } from '../api/client'; export function useSearch(query: string) { return useQuery({ queryKey: ['search', query], queryFn: async () => { const { data } = await apiClient.GET('/securities/search', { params: { query: { q: query, limit: 20 } }, }); return data?.data ?? []; }, enabled: query.length >= 1, staleTime: 60_000, }); } ``` - [ ] **Create SearchBar.tsx** ```typescript import { useState, useCallback } from 'react'; interface SearchBarProps { onSearch: (query: string) => void; } const inputStyle: React.CSSProperties = { width: '100%', padding: '12px 16px', fontSize: 16, border: '1px solid #ddd', borderRadius: 'var(--border-radius)', outline: 'none', }; export function SearchBar({ onSearch }: SearchBarProps) { const [value, setValue] = useState(''); const handleChange = useCallback( (e: React.ChangeEvent) => { const v = e.target.value; setValue(v); onSearch(v); }, [onSearch], ); return ( ); } ``` - [ ] **Create SecurityCard.tsx** ```typescript import { Link } from 'react-router-dom'; interface SecurityCardProps { secid: string; shortName: string; type: 'share' | 'bond'; isin: string; listLevel: number; currency: string | null; price: number | null; } const cardStyle: React.CSSProperties = { background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', }; const badgeStyle: React.CSSProperties = { fontSize: 12, padding: '2px 8px', borderRadius: 4, fontWeight: 600, }; export function SecurityCard({ secid, shortName, type, isin, currency, price }: SecurityCardProps) { const linkTo = type === 'share' ? `/stocks/${secid}` : `/bonds/${secid}`; return (
{secid}
{shortName} · {isin}
{type === 'share' ? 'Акция' : 'Облигация'} {price != null && (
{price.toLocaleString('ru-RU')} {currency || ''}
)}
); } ``` - [ ] **Create HomePage.tsx** ```typescript import { useState } from 'react'; import { SearchBar } from '../components/SearchBar'; import { SecurityCard } from '../components/SecurityCard'; import { useSearch } from '../hooks/useSearch'; export function HomePage() { const [query, setQuery] = useState(''); const { data: results, isLoading } = useSearch(query); return (

Поиск инструментов

{isLoading &&
Загрузка...
} {results && results.length === 0 && query.length > 0 && (
Ничего не найдено
)} {results && (
{results.map((item) => ( ))}
)}
); } ``` - [ ] **Commit** ```bash git add apps/frontend/src/hooks/useSearch.ts apps/frontend/src/components/SearchBar.tsx apps/frontend/src/components/SecurityCard.tsx apps/frontend/src/pages/HomePage.tsx git commit -m "feat: add search page with SecurityCard and SearchBar" ``` --- ## SPRINT 5: Frontend Details ### Task 5.1: Stock page hook **Files:** - Create: `apps/frontend/src/hooks/useStock.ts` - Create: `apps/frontend/src/hooks/useStockCandles.ts` - Create: `apps/frontend/src/hooks/useStockDividends.ts` - [ ] **Create useStock.ts** ```typescript import { useQuery } from '@tanstack/react-query'; import { apiClient } from '../api/client'; export function useStock(secid: string) { return useQuery({ queryKey: ['stock', secid], queryFn: async () => { const { data } = await apiClient.GET('/securities/shares/{secid}', { params: { path: { secid } }, }); return data?.data ?? null; }, staleTime: 900_000, }); } export function useStockMarketData(secid: string) { return useQuery({ queryKey: ['stockMarketData', secid], queryFn: async () => { const { data } = await apiClient.GET('/securities/shares/{secid}/marketdata', { params: { path: { secid } }, }); return data?.data ?? null; }, staleTime: 900_000, }); } ``` - [ ] **Create useStockCandles.ts** ```typescript import { useQuery } from '@tanstack/react-query'; import { apiClient } from '../api/client'; export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) { return useQuery({ queryKey: ['stockCandles', secid, interval, from, till], queryFn: async () => { const { data } = await apiClient.GET('/securities/shares/{secid}/candles', { params: { path: { secid }, query: { interval, from, till }, }, }); return data?.data ?? []; }, staleTime: 3600_000, }); } ``` - [ ] **Create useStockDividends.ts** ```typescript import { useQuery } from '@tanstack/react-query'; import { apiClient } from '../api/client'; export function useStockDividends(secid: string) { return useQuery({ queryKey: ['stockDividends', secid], queryFn: async () => { const { data } = await apiClient.GET('/securities/shares/{secid}/dividends', { params: { path: { secid } }, }); return data?.data ?? []; }, staleTime: 86400_000, }); } ``` - [ ] **Commit** ```bash git add apps/frontend/src/hooks/ git commit -m "feat: add React Query hooks for stock, candles, dividends" ``` ### Task 5.2: StockDetails component **Files:** - Create: `apps/frontend/src/components/StockDetails.tsx` - [ ] **Create StockDetails.tsx** ```typescript import type { components } from '../api/types'; type Stock = components['schemas']['StockResponse']['data']; interface StockDetailsProps { stock: NonNullable; } 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 (

{stock.shortName} ({stock.secid})

{stock.name} · {stock.isin}
{marketData.price.toLocaleString('ru-RU', { minimumFractionDigits: 2 })}{' '} {isPositive ? '+' : ''}{marketData.change.toFixed(2)} ({marketData.changePercent.toFixed(2)}%)
Открытие {marketData.open.toFixed(2)}
Максимум {marketData.high?.toFixed(2) ?? '—'}
Минимум {marketData.low?.toFixed(2) ?? '—'}
Объём {marketData.volume.toLocaleString('ru-RU')}
Капитализация {marketData.issueCapitalization ? (marketData.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽' : '—'}
ISIN {stock.isin}
Уровень листинга {stock.listLevel}
); } ``` - [ ] **Commit** ```bash git add apps/frontend/src/components/StockDetails.tsx git commit -m "feat: add StockDetails component" ``` ### Task 5.3: Bond page hooks **Files:** - Create: `apps/frontend/src/hooks/useBond.ts` - Create: `apps/frontend/src/hooks/useBondCandles.ts` - [ ] **Create useBond.ts** ```typescript import { useQuery } from '@tanstack/react-query'; import { apiClient } from '../api/client'; export function useBond(secid: string) { return useQuery({ queryKey: ['bond', secid], queryFn: async () => { const { data, error } = await apiClient.GET('/securities/bonds/{secid}', { params: { path: { secid } }, }); if (error) throw new Error(error.message); return data?.data ?? null; }, staleTime: 900_000, }); } ``` - [ ] **Create useBondCandles.ts** ```typescript import { useQuery } from '@tanstack/react-query'; import { apiClient } from '../api/client'; export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) { return useQuery({ queryKey: ['bondCandles', secid, interval, from, till], queryFn: async () => { const { data } = await apiClient.GET('/securities/bonds/{secid}/candles', { params: { path: { secid }, query: { interval, from, till }, }, }); return data?.data ?? []; }, staleTime: 3600_000, }); } ``` - [ ] **Commit** ```bash git add apps/frontend/src/hooks/useBond.ts apps/frontend/src/hooks/useBondCandles.ts git commit -m "feat: add React Query hooks for bond and bond candles" ``` ### Task 5.4: BondDetails component **Files:** - Create: `apps/frontend/src/components/BondDetails.tsx` - [ ] **Create BondDetails.tsx** ```typescript interface BondDetailsProps { bond: any; // Тип генерируется openapi-typescript из схемы BondResponse } const rowStyle: React.CSSProperties = { display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #eee', }; export function BondDetails({ bond }: BondDetailsProps) { const md = bond.marketData; return (

{bond.shortName}

{bond.isin}
{md.price.toFixed(2)}%
Номинал {bond.faceValue.toLocaleString('ru-RU')} {bond.faceUnit}
Дата погашения {bond.matDate}
Купон {md.couponValue} ₽ {md.couponPercent != null ? `(${md.couponPercent}%)` : ''}
Период купона {bond.couponPeriod} дней
Следующий купон {md.nextCouponDate ?? '—'}
НКД {md.accruedInt.toFixed(2)} ₽
Доходность к погашению {md.yieldToMaturity != null ? md.yieldToMaturity.toFixed(2) + '%' : '—'}
Дюрация {md.duration != null ? md.duration.toFixed(2) : '—'}
Тип {bond.bondType}
ISIN {bond.isin}
); } ``` - [ ] **Commit** ```bash git add apps/frontend/src/components/BondDetails.tsx git commit -m "feat: add BondDetails component" ``` ### Task 5.5: PriceChart component **Files:** - Create: `apps/frontend/src/components/PriceChart.tsx` - [ ] **Create PriceChart.tsx** ```typescript import { useEffect, useRef } from 'react'; import { createChart, ColorType, IChartApi, CandlestickData, Time } from 'lightweight-charts'; interface PriceChartProps { data: Array<{ open: number; high: number; low: number; close: number; begin: string; }>; height?: number; } export function PriceChart({ data, height = 400 }: PriceChartProps) { const chartContainerRef = useRef(null); const chartRef = useRef(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
; } ``` - [ ] **Commit** ```bash git add apps/frontend/src/components/PriceChart.tsx git commit -m "feat: add PriceChart component using lightweight-charts" ``` ### Task 5.6: StockPage and BondPage **Files:** - Modify: `apps/frontend/src/pages/StockPage.tsx` - Modify: `apps/frontend/src/pages/BondPage.tsx` - [ ] **Create StockPage.tsx** ```typescript import { useParams } from 'react-router-dom'; import { useStock } from '../hooks/useStock'; import { useStockCandles } from '../hooks/useStockCandles'; import { useStockDividends } from '../hooks/useStockDividends'; import { StockDetails } from '../components/StockDetails'; import { PriceChart } from '../components/PriceChart'; export function StockPage() { const { secid } = useParams<{ secid: string }>(); const { data: stock, isLoading, error } = useStock(secid!); const till = new Date().toISOString().split('T')[0]; const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const { data: candles } = useStockCandles(secid!, '24h', from, till); const { data: dividends } = useStockDividends(secid!); if (isLoading) return
Загрузка...
; if (error || !stock) return
Инструмент не найден
; return (

График цены

{dividends && dividends.length > 0 && (

Дивиденды

{dividends.map((d, i) => ( ))}
Дата закрытия реестра Сумма
{d.registryCloseDate} {d.value.toFixed(2)} {d.currency}
)}
); } ``` - [ ] **Create BondPage.tsx** ```typescript import { useParams } from 'react-router-dom'; import { useBond } from '../hooks/useBond'; import { useBondCandles } from '../hooks/useBondCandles'; import { BondDetails } from '../components/BondDetails'; import { PriceChart } from '../components/PriceChart'; export function BondPage() { const { secid } = useParams<{ secid: string }>(); const { data: bond, isLoading, error } = useBond(secid!); const till = new Date().toISOString().split('T')[0]; const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; const { data: candles } = useBondCandles(secid!, '24h', from, till); if (isLoading) return
Загрузка...
; if (error || !bond) return
Инструмент не найден
; return (

График цены

); } ``` - [ ] **Commit** ```bash git add apps/frontend/src/pages/ git commit -m "feat: add StockPage and BondPage with charts and details" ``` --- ## SPRINT 6: Documentation + Infrastructure ### Task 6.1: OpenAPI spec finalization - [ ] **Verify OpenAPI spec** — start backend, check Swagger UI at `/api/docs`. Ensure all endpoints, schemas, and examples are present. - [ ] **Sync `docs/openapi/openapi.yaml`** with the generated spec if any changes were made during development. - [ ] **Commit** ```bash git add docs/openapi/openapi.yaml git commit -m "docs: finalize OpenAPI specification" ``` ### Task 6.2: ADR documentation - [ ] **Ensure all ADR files exist** in `docs/architecture/adr/` (created during design phase, adjust if needed). - [ ] **Add architecture overview diagram** (ASCII sequence diagram or Mermaid): ```markdown # Architecture Overview ```mermaid sequenceDiagram participant User participant Frontend as React SPA participant Backend as NestJS API participant Cache as In-Memory Cache participant MOEX as MOEX ISS User->>Frontend: Search / View instrument Frontend->>Backend: GET /api/v1/securities/search?q=SBER Backend->>Cache: getOrFetch('search:sber') alt Cache miss Cache->>Backend: null Backend->>MOEX: GET /iss/securities?q=SBER MOEX-->>Backend: raw data Backend->>Cache: set('search:sber', normalized, TTL=3600) else Cache hit Cache-->>Backend: cached data end Backend-->>Frontend: normalized response Frontend-->>User: rendered UI ``` ``` - [ ] **Commit** ```bash git add docs/ git commit -m "docs: add ADR documents and architecture diagrams" ``` ### Task 6.3: Docker setup **Files:** - Create: `docker/Dockerfile.backend` - Create: `docker/Dockerfile.frontend` - Create: `docker/nginx.conf` - Create: `docker-compose.yml` - [ ] **Create Dockerfile.backend** ```dockerfile FROM node:20-alpine AS build WORKDIR /app COPY apps/backend/package.json ./ RUN npm install COPY apps/backend/ ./ RUN npm run build FROM node:20-alpine AS production WORKDIR /app COPY --from=build /app/dist ./dist COPY --from=build /app/node_modules ./node_modules COPY apps/backend/package.json ./ EXPOSE 3000 CMD ["node", "dist/main.js"] ``` - [ ] **Create Dockerfile.frontend** ```dockerfile FROM node:20-alpine AS build WORKDIR /app COPY apps/frontend/package.json ./ RUN npm install COPY apps/frontend/ ./ RUN npm run build FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html COPY docker/nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"] ``` - [ ] **Create nginx.conf** ```nginx server { listen 80; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://backend:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } } ``` - [ ] **Create docker-compose.yml** ```yaml services: backend: build: context: . dockerfile: docker/Dockerfile.backend ports: - "3000:3000" environment: - PORT=3000 - MOEX_BASE_URL=https://iss.moex.com/iss - MOEX_RATE_LIMIT=10 frontend: build: context: . dockerfile: docker/Dockerfile.frontend ports: - "80:80" depends_on: - backend ``` - [ ] **Commit** ```bash git add docker/ docker-compose.yml git commit -m "infra: add Docker setup with docker-compose" ``` ### Task 6.4: README and final checks - [ ] **Create README.md** with: - Project overview - Tech stack - Quick start (npm install, npm run dev:backend, npm run dev:frontend) - Docker instructions - Links to docs - [ ] **Run full test suite**: ```bash npm run test:backend ``` - [ ] **Verify type generation**: ```bash cd apps/frontend && npx openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts ``` - [ ] **Final commit** ```bash git add README.md git commit -m "chore: add README with quick start instructions" ``` ---