3457 lines
94 KiB
Markdown
3457 lines
94 KiB
Markdown
# MoexVibe MVP Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build a working MVP of MoexVibe — web application for analyzing MOEX stocks and bonds.
|
||
|
||
**Architecture:** NestJS monolith serving normalized REST API (OpenAPI 3.0), in-memory cache, rate-limited MOEX ISS client. React SPA with TanStack Query and openapi-typescript codegen. Dev-only single server, production via Docker.
|
||
|
||
**Tech Stack:** NestJS, React, Vite, TypeScript, TanStack Query, React Router, @nestjs/cache-manager, node-fetch/axios, lightweight-charts, vitest, Docusaurus
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
moex-vibe/
|
||
├── package.json # Root npm workspaces
|
||
├── tsconfig.base.json
|
||
├── .gitignore
|
||
├── .prettierrc
|
||
├── .eslintrc.cjs
|
||
├── apps/
|
||
│ ├── backend/
|
||
│ │ ├── package.json
|
||
│ │ ├── tsconfig.json
|
||
│ │ ├── nest-cli.json
|
||
│ │ └── src/
|
||
│ │ ├── main.ts
|
||
│ │ ├── app.module.ts
|
||
│ │ ├── config/
|
||
│ │ │ └── configuration.ts
|
||
│ │ ├── common/
|
||
│ │ │ ├── dto/
|
||
│ │ │ │ ├── api-response.dto.ts
|
||
│ │ │ │ └── pagination.dto.ts
|
||
│ │ │ ├── filters/
|
||
│ │ │ │ └── http-exception.filter.ts
|
||
│ │ │ ├── interceptors/
|
||
│ │ │ │ ├── logging.interceptor.ts
|
||
│ │ │ │ └── transform.interceptor.ts
|
||
│ │ │ └── middleware/
|
||
│ │ │ └── request-logging.middleware.ts
|
||
│ │ └── modules/
|
||
│ │ ├── moex-client/
|
||
│ │ │ ├── moex-client.module.ts
|
||
│ │ │ ├── moex-client.service.ts
|
||
│ │ │ ├── moex-client.service.spec.ts
|
||
│ │ │ └── moex-client.types.ts
|
||
│ │ ├── cache/
|
||
│ │ │ ├── cache.module.ts
|
||
│ │ │ └── cache.service.ts
|
||
│ │ ├── securities/
|
||
│ │ │ ├── securities.module.ts
|
||
│ │ │ ├── securities.controller.ts
|
||
│ │ │ ├── securities.controller.spec.ts
|
||
│ │ │ ├── securities.service.ts
|
||
│ │ │ ├── securities.service.spec.ts
|
||
│ │ │ └── dto/
|
||
│ │ │ └── search-query.dto.ts
|
||
│ │ ├── shares/
|
||
│ │ │ ├── shares.module.ts
|
||
│ │ │ ├── shares.controller.ts
|
||
│ │ │ ├── shares.controller.spec.ts
|
||
│ │ │ ├── shares.service.ts
|
||
│ │ │ ├── shares.service.spec.ts
|
||
│ │ │ └── dto/
|
||
│ │ │ ├── share-response.dto.ts
|
||
│ │ │ ├── share-marketdata-response.dto.ts
|
||
│ │ │ ├── dividends-response.dto.ts
|
||
│ │ │ └── history-query.dto.ts
|
||
│ │ ├── bonds/
|
||
│ │ │ ├── bonds.module.ts
|
||
│ │ │ ├── bonds.controller.ts
|
||
│ │ │ ├── bonds.controller.spec.ts
|
||
│ │ │ ├── bonds.service.ts
|
||
│ │ │ ├── bonds.service.spec.ts
|
||
│ │ │ └── dto/
|
||
│ │ │ ├── bond-response.dto.ts
|
||
│ │ │ ├── bond-marketdata-response.dto.ts
|
||
│ │ │ └── bond-history.dto.ts
|
||
│ │ ├── candles/
|
||
│ │ │ ├── candles.module.ts
|
||
│ │ │ ├── candles.controller.ts
|
||
│ │ │ ├── candles.controller.spec.ts
|
||
│ │ │ ├── candles.service.ts
|
||
│ │ │ ├── candles.service.spec.ts
|
||
│ │ │ └── dto/
|
||
│ │ │ └── candles-query.dto.ts
|
||
│ │ └── health/
|
||
│ │ └── health.controller.ts
|
||
│ └── frontend/
|
||
│ ├── package.json
|
||
│ ├── tsconfig.json
|
||
│ ├── tsconfig.node.json
|
||
│ ├── vite.config.ts
|
||
│ ├── index.html
|
||
│ └── src/
|
||
│ ├── main.tsx
|
||
│ ├── App.tsx
|
||
│ ├── routes.tsx
|
||
│ ├── styles.css
|
||
│ ├── api/
|
||
│ │ └── (generated by openapi-typescript)
|
||
│ ├── hooks/
|
||
│ │ ├── useSearch.ts
|
||
│ │ ├── useStock.ts
|
||
│ │ ├── useStockCandles.ts
|
||
│ │ ├── useStockDividends.ts
|
||
│ │ ├── useBond.ts
|
||
│ │ └── useBondCandles.ts
|
||
│ ├── pages/
|
||
│ │ ├── HomePage.tsx
|
||
│ │ ├── StockPage.tsx
|
||
│ │ └── BondPage.tsx
|
||
│ ├── components/
|
||
│ │ ├── Layout.tsx
|
||
│ │ ├── SearchBar.tsx
|
||
│ │ ├── SecurityCard.tsx
|
||
│ │ ├── PriceChart.tsx
|
||
│ │ ├── StockDetails.tsx
|
||
│ │ └── BondDetails.tsx
|
||
│ ├── types/
|
||
│ │ └── (generated, re-exported)
|
||
│ └── vite-env.d.ts
|
||
├── docker/
|
||
│ ├── Dockerfile.backend
|
||
│ ├── Dockerfile.frontend
|
||
│ └── nginx.conf
|
||
├── docker-compose.yml
|
||
└── docs/
|
||
├── architecture/adr/
|
||
├── openapi/openapi.yaml
|
||
└── superpowers/specs/2026-06-13-moex-vibe-design.md
|
||
```
|
||
|
||
---
|
||
|
||
## SPRINT 1: Backend Foundation
|
||
|
||
### Task 1.1: Initialize project root with npm workspaces
|
||
|
||
**Files:**
|
||
- Create: `package.json`
|
||
- Create: `tsconfig.base.json`
|
||
- Create: `.gitignore`
|
||
- Create: `.prettierrc`
|
||
|
||
- [ ] **Create root package.json with workspaces**
|
||
|
||
```json
|
||
{
|
||
"name": "moex-vibe",
|
||
"private": true,
|
||
"workspaces": [
|
||
"apps/backend",
|
||
"apps/frontend"
|
||
],
|
||
"scripts": {
|
||
"dev:backend": "npm run start:dev -w apps/backend",
|
||
"dev:frontend": "npm run dev -w apps/frontend",
|
||
"build:backend": "npm run build -w apps/backend",
|
||
"build:frontend": "npm run build -w apps/frontend",
|
||
"test:backend": "npm run test -w apps/backend",
|
||
"lint": "npm run lint -w apps/backend",
|
||
"format": "prettier --write \"**/*.ts\""
|
||
},
|
||
"devDependencies": {
|
||
"prettier": "^3.0.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create tsconfig.base.json**
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2022",
|
||
"module": "ESNext",
|
||
"moduleResolution": "bundler",
|
||
"strict": true,
|
||
"esModuleInterop": true,
|
||
"skipLibCheck": true,
|
||
"forceConsistentCasingInFileNames": true,
|
||
"resolveJsonModule": true,
|
||
"declaration": true,
|
||
"declarationMap": true,
|
||
"sourceMap": true
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create .gitignore**
|
||
|
||
```
|
||
node_modules/
|
||
dist/
|
||
.env
|
||
*.log
|
||
.DS_Store
|
||
```
|
||
|
||
- [ ] **Create .prettierrc**
|
||
|
||
```json
|
||
{
|
||
"singleQuote": true,
|
||
"trailingComma": "all",
|
||
"printWidth": 100,
|
||
"semi": true
|
||
}
|
||
```
|
||
|
||
- [ ] **Run `npm install` at root** to create lockfile and workspace links.
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add package.json tsconfig.base.json .gitignore .prettierrc
|
||
git commit -m "chore: initialize monorepo with npm workspaces"
|
||
```
|
||
|
||
### Task 1.2: Scaffold NestJS backend
|
||
|
||
**Files:**
|
||
- Create: `apps/backend/package.json`
|
||
- Create: `apps/backend/tsconfig.json`
|
||
- Create: `apps/backend/nest-cli.json`
|
||
- Create: `apps/backend/src/main.ts`
|
||
- Create: `apps/backend/src/app.module.ts`
|
||
|
||
- [ ] **Create apps/backend/package.json**
|
||
|
||
```json
|
||
{
|
||
"name": "@moex-vibe/backend",
|
||
"version": "0.0.1",
|
||
"private": true,
|
||
"scripts": {
|
||
"build": "nest build",
|
||
"start:dev": "nest start --watch",
|
||
"start:prod": "node dist/main",
|
||
"lint": "eslint \"{src,test}/**/*.ts\"",
|
||
"test": "vitest run",
|
||
"test:watch": "vitest"
|
||
},
|
||
"dependencies": {
|
||
"@nestjs/common": "^10.0.0",
|
||
"@nestjs/core": "^10.0.0",
|
||
"@nestjs/platform-express": "^10.0.0",
|
||
"@nestjs/config": "^3.0.0",
|
||
"@nestjs/swagger": "^7.0.0",
|
||
"@nestjs/axios": "^3.0.0",
|
||
"@nestjs/cache-manager": "^2.0.0",
|
||
"cache-manager": "^5.0.0",
|
||
"axios": "^1.6.0",
|
||
"reflect-metadata": "^0.1.13",
|
||
"rxjs": "^7.8.0",
|
||
"class-validator": "^0.14.0",
|
||
"class-transformer": "^0.5.0",
|
||
"p-queue": "^7.3.0",
|
||
"swagger-ui-express": "^5.0.0"
|
||
},
|
||
"devDependencies": {
|
||
"@nestjs/cli": "^10.0.0",
|
||
"@nestjs/schematics": "^10.0.0",
|
||
"@nestjs/testing": "^10.0.0",
|
||
"@types/express": "^4.17.0",
|
||
"@types/node": "^20.0.0",
|
||
"typescript": "^5.3.0",
|
||
"vitest": "^1.0.0",
|
||
"eslint": "^8.0.0",
|
||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||
"@typescript-eslint/parser": "^7.0.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create apps/backend/tsconfig.json**
|
||
|
||
```json
|
||
{
|
||
"extends": "../../tsconfig.base.json",
|
||
"compilerOptions": {
|
||
"target": "ES2022",
|
||
"module": "commonjs",
|
||
"outDir": "./dist",
|
||
"emitDecoratorMetadata": true,
|
||
"experimentalDecorators": true,
|
||
"baseUrl": "./",
|
||
"paths": {
|
||
"@/*": ["src/*"]
|
||
}
|
||
},
|
||
"include": ["src/**/*"]
|
||
}
|
||
```
|
||
|
||
- [ ] **Create apps/backend/nest-cli.json**
|
||
|
||
```json
|
||
{
|
||
"collection": "@nestjs/schematics",
|
||
"sourceRoot": "src"
|
||
}
|
||
```
|
||
|
||
- [ ] **Create apps/backend/src/main.ts**
|
||
|
||
```typescript
|
||
import { NestFactory } from '@nestjs/core';
|
||
import { AppModule } from './app.module';
|
||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||
import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware';
|
||
import { ValidationPipe } from '@nestjs/common';
|
||
|
||
async function bootstrap() {
|
||
const app = await NestFactory.create(AppModule);
|
||
|
||
app.setGlobalPrefix('api/v1');
|
||
|
||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||
app.useGlobalFilters(new HttpExceptionFilter());
|
||
app.useGlobalInterceptors(new TransformInterceptor());
|
||
app.use(new RequestLoggingMiddleware().use);
|
||
|
||
app.enableCors();
|
||
|
||
const config = new DocumentBuilder()
|
||
.setTitle('MoexVibe API')
|
||
.setVersion('1.0.0')
|
||
.build();
|
||
const document = SwaggerModule.createDocument(app, config);
|
||
SwaggerModule.setup('api/docs', app, document);
|
||
|
||
const port = process.env.PORT || 3000;
|
||
await app.listen(port);
|
||
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
|
||
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||
}
|
||
bootstrap();
|
||
```
|
||
|
||
- [ ] **Create apps/backend/src/app.module.ts**
|
||
|
||
```typescript
|
||
import { Module } from '@nestjs/common';
|
||
import { ConfigModule } from '@nestjs/config';
|
||
import { CacheModule } from './modules/cache/cache.module';
|
||
import { MoexClientModule } from './modules/moex-client/moex-client.module';
|
||
import { HealthModule } from './modules/health/health.module';
|
||
import { SecuritiesModule } from './modules/securities/securities.module';
|
||
import { SharesModule } from './modules/shares/shares.module';
|
||
import { BondsModule } from './modules/bonds/bonds.module';
|
||
import { CandlesModule } from './modules/candles/candles.module';
|
||
import configuration from './config/configuration';
|
||
|
||
@Module({
|
||
imports: [
|
||
ConfigModule.forRoot({ load: [configuration], isGlobal: true }),
|
||
CacheModule,
|
||
MoexClientModule,
|
||
HealthModule,
|
||
SecuritiesModule,
|
||
SharesModule,
|
||
BondsModule,
|
||
CandlesModule,
|
||
],
|
||
})
|
||
export class AppModule {}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/backend/
|
||
git commit -m "feat: scaffold NestJS backend with Swagger, validation, CORS"
|
||
```
|
||
|
||
### Task 1.3: Configuration module
|
||
|
||
**Files:**
|
||
- Create: `apps/backend/src/config/configuration.ts`
|
||
|
||
- [ ] **Create configuration.ts**
|
||
|
||
```typescript
|
||
import { registerAs } from '@nestjs/config';
|
||
|
||
export default registerAs('app', () => ({
|
||
port: parseInt(process.env.PORT || '3000', 10),
|
||
moex: {
|
||
baseUrl: process.env.MOEX_BASE_URL || 'https://iss.moex.com/iss',
|
||
rateLimit: parseInt(process.env.MOEX_RATE_LIMIT || '10', 10),
|
||
circuitBreakerThreshold: parseInt(
|
||
process.env.MOEX_CIRCUIT_BREAKER_THRESHOLD || '5',
|
||
10,
|
||
),
|
||
circuitBreakerResetSeconds: parseInt(
|
||
process.env.MOEX_CIRCUIT_BREAKER_RESET_SECONDS || '30',
|
||
10,
|
||
),
|
||
},
|
||
cache: {
|
||
marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10),
|
||
historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10),
|
||
candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10),
|
||
securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10),
|
||
searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10),
|
||
dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10),
|
||
},
|
||
}));
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/backend/src/config/
|
||
git commit -m "feat: add configuration module with env vars"
|
||
```
|
||
|
||
### Task 1.4: Common DTOs, filters, interceptors, middleware
|
||
|
||
**Files:**
|
||
- Create: `apps/backend/src/common/dto/api-response.dto.ts`
|
||
- Create: `apps/backend/src/common/dto/pagination.dto.ts`
|
||
- Create: `apps/backend/src/common/filters/http-exception.filter.ts`
|
||
- Create: `apps/backend/src/common/interceptors/transform.interceptor.ts`
|
||
- Create: `apps/backend/src/common/middleware/request-logging.middleware.ts`
|
||
|
||
- [ ] **Create common/dto/api-response.dto.ts**
|
||
|
||
```typescript
|
||
import { ApiProperty } from '@nestjs/swagger';
|
||
|
||
export class ApiResponseMeta {
|
||
@ApiProperty({ nullable: true })
|
||
cachedAt: string | null;
|
||
|
||
@ApiProperty()
|
||
fromCache: boolean;
|
||
}
|
||
|
||
export class ApiResponse<T> {
|
||
data: T;
|
||
meta: ApiResponseMeta;
|
||
|
||
constructor(data: T, fromCache = false, cachedAt: string | null = null) {
|
||
this.data = data;
|
||
this.meta = { cachedAt, fromCache };
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create common/dto/pagination.dto.ts**
|
||
|
||
```typescript
|
||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||
import { Type } from 'class-transformer';
|
||
import { IsOptional, IsInt, Min, Max } from 'class-validator';
|
||
|
||
export class PaginationDto {
|
||
@ApiPropertyOptional({ default: 1 })
|
||
@IsOptional()
|
||
@Type(() => Number)
|
||
@IsInt()
|
||
@Min(1)
|
||
page?: number = 1;
|
||
|
||
@ApiPropertyOptional({ default: 20 })
|
||
@IsOptional()
|
||
@Type(() => Number)
|
||
@IsInt()
|
||
@Min(1)
|
||
@Max(100)
|
||
limit?: number = 20;
|
||
}
|
||
```
|
||
|
||
- [ ] **Create common/filters/http-exception.filter.ts**
|
||
|
||
```typescript
|
||
import {
|
||
ExceptionFilter,
|
||
Catch,
|
||
ArgumentsHost,
|
||
HttpException,
|
||
HttpStatus,
|
||
} from '@nestjs/common';
|
||
import { Response } from 'express';
|
||
|
||
@Catch()
|
||
export class HttpExceptionFilter implements ExceptionFilter {
|
||
catch(exception: unknown, host: ArgumentsHost) {
|
||
const ctx = host.switchToHttp();
|
||
const response = ctx.getResponse<Response>();
|
||
const request = ctx.getRequest<Request>();
|
||
|
||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||
let message = 'Internal server error';
|
||
let error = 'Internal Server Error';
|
||
|
||
if (exception instanceof HttpException) {
|
||
status = exception.getStatus();
|
||
const res = exception.getResponse();
|
||
if (typeof res === 'string') {
|
||
message = res;
|
||
error = exception.name;
|
||
} else if (typeof res === 'object') {
|
||
const r = res as Record<string, unknown>;
|
||
message = (r.message as string) || message;
|
||
error = (r.error as string) || exception.name;
|
||
}
|
||
} else if (exception instanceof Error) {
|
||
message = exception.message;
|
||
}
|
||
|
||
response.status(status).json({
|
||
statusCode: status,
|
||
message,
|
||
error,
|
||
timestamp: new Date().toISOString(),
|
||
path: request.url,
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create common/interceptors/transform.interceptor.ts**
|
||
|
||
```typescript
|
||
import {
|
||
Injectable,
|
||
NestInterceptor,
|
||
ExecutionContext,
|
||
CallHandler,
|
||
} from '@nestjs/common';
|
||
import { Observable } from 'rxjs';
|
||
import { map } from 'rxjs/operators';
|
||
import { ApiResponse } from '../dto/api-response.dto';
|
||
|
||
@Injectable()
|
||
export class TransformInterceptor<T>
|
||
implements NestInterceptor<T, ApiResponse<T>>
|
||
{
|
||
intercept(
|
||
context: ExecutionContext,
|
||
next: CallHandler,
|
||
): Observable<ApiResponse<T>> {
|
||
return next.handle().pipe(
|
||
map((data) => {
|
||
if (data instanceof ApiResponse) return data;
|
||
return new ApiResponse(data, false, null);
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create common/middleware/request-logging.middleware.ts**
|
||
|
||
```typescript
|
||
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
|
||
import { Request, Response, NextFunction } from 'express';
|
||
|
||
@Injectable()
|
||
export class RequestLoggingMiddleware implements NestMiddleware {
|
||
private logger = new Logger('HTTP');
|
||
|
||
use(req: Request, res: Response, next: NextFunction): void {
|
||
const { method, originalUrl } = req;
|
||
const start = Date.now();
|
||
|
||
res.on('finish', () => {
|
||
const { statusCode } = res;
|
||
const duration = Date.now() - start;
|
||
this.logger.log(`${method} ${originalUrl} ${statusCode} ${duration}ms`);
|
||
});
|
||
|
||
next();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/backend/src/common/
|
||
git commit -m "feat: add common DTOs, exception filter, transform interceptor, logging middleware"
|
||
```
|
||
|
||
### Task 1.5: Health check endpoint
|
||
|
||
**Files:**
|
||
- Create: `apps/backend/src/modules/health/health.controller.ts`
|
||
|
||
- [ ] **Create health.controller.ts**
|
||
|
||
```typescript
|
||
import { Controller, Get } from '@nestjs/common';
|
||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||
|
||
@ApiTags('Health')
|
||
@Controller('health')
|
||
export class HealthController {
|
||
@Get()
|
||
@ApiOperation({ summary: 'Проверка состояния сервиса' })
|
||
check() {
|
||
return {
|
||
status: 'ok',
|
||
timestamp: new Date().toISOString(),
|
||
uptime: process.uptime(),
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/backend/src/modules/health/
|
||
git commit -m "feat: add health check endpoint"
|
||
```
|
||
|
||
### Task 1.6: MoexClient module with rate limiting
|
||
|
||
**Files:**
|
||
- Create: `apps/backend/src/modules/moex-client/moex-client.module.ts`
|
||
- Create: `apps/backend/src/modules/moex-client/moex-client.service.ts`
|
||
- Create: `apps/backend/src/modules/moex-client/moex-client.types.ts`
|
||
- Create: `apps/backend/src/modules/moex-client/moex-client.service.spec.ts`
|
||
|
||
- [ ] **Create moex-client.types.ts**
|
||
|
||
```typescript
|
||
export interface MoexSecurityDescription {
|
||
secid: string;
|
||
isin: string;
|
||
name: string;
|
||
shortName: string;
|
||
latName: string | null;
|
||
listLevel: number;
|
||
issueSize: number;
|
||
faceValue: number;
|
||
faceUnit: string;
|
||
issueDate: string;
|
||
typeName: string;
|
||
group: string;
|
||
type: string;
|
||
isQualifiedInvestors: boolean;
|
||
morningSession: boolean;
|
||
eveningSession: boolean;
|
||
}
|
||
|
||
export interface MoexShareMarketData {
|
||
secid: string;
|
||
boardid: string;
|
||
bid: number | null;
|
||
offer: number | null;
|
||
open: number | null;
|
||
low: number | null;
|
||
high: number | null;
|
||
last: number | null;
|
||
lastChange: number | null;
|
||
lastChangePrcnt: number | null;
|
||
volume: number;
|
||
value: number;
|
||
waprice: number | null;
|
||
numtrades: number;
|
||
issueCapitalization: number | null;
|
||
tradingStatus: string;
|
||
updateTime: string;
|
||
}
|
||
|
||
export interface MoexBondData {
|
||
secid: string;
|
||
boardid: string;
|
||
shortName: string;
|
||
prevWaprice: number | null;
|
||
yieldAtPrevWaprice: number | null;
|
||
couponValue: number | null;
|
||
nextCoupon: string | null;
|
||
accruedInt: number | null;
|
||
prevPrice: number | null;
|
||
lotSize: number;
|
||
faceValue: number;
|
||
matDate: string;
|
||
couponPeriod: number;
|
||
issueSize: number;
|
||
isin: string;
|
||
couponPercent: number | null;
|
||
offerDate: string | null;
|
||
buybackDate: string | null;
|
||
bondType: string;
|
||
bondSubType: string;
|
||
listLevel: number;
|
||
}
|
||
|
||
export interface MoexBondMarketData {
|
||
secid: string;
|
||
bid: number | null;
|
||
offer: number | null;
|
||
open: number | null;
|
||
low: number | null;
|
||
high: number | null;
|
||
last: number | null;
|
||
yield: number | null;
|
||
waprice: number | null;
|
||
yieldAtWaprice: number | null;
|
||
duration: number | null;
|
||
volume: number;
|
||
value: number;
|
||
numtrades: number;
|
||
tradingStatus: string;
|
||
updateTime: string;
|
||
}
|
||
|
||
export interface MoexDividend {
|
||
secid: string;
|
||
isin: string;
|
||
registryCloseDate: string;
|
||
value: number;
|
||
currencyId: string;
|
||
}
|
||
|
||
export interface MoexCandle {
|
||
open: number;
|
||
close: number;
|
||
high: number;
|
||
low: number;
|
||
value: number;
|
||
volume: number;
|
||
begin: string;
|
||
end: string;
|
||
}
|
||
|
||
export interface MoexHistoryEntry {
|
||
tradeDate: string;
|
||
open: number | null;
|
||
low: number | null;
|
||
high: number | null;
|
||
close: number | null;
|
||
waprice: number | null;
|
||
volume: number;
|
||
value: number;
|
||
numtrades: number;
|
||
}
|
||
|
||
export interface MoexBondHistoryEntry {
|
||
tradeDate: string;
|
||
close: number | null;
|
||
legalClosePrice: number | null;
|
||
waprice: number | null;
|
||
yieldClose: number | null;
|
||
duration: number | null;
|
||
accruedInt: number | null;
|
||
}
|
||
|
||
export interface MoexBoard {
|
||
secid: string;
|
||
boardid: string;
|
||
title: string;
|
||
isPrimary: boolean;
|
||
isTraded: boolean;
|
||
currencyid: string;
|
||
}
|
||
```
|
||
|
||
- [ ] **Create moex-client.service.ts**
|
||
|
||
```typescript
|
||
import { Injectable, Logger } from '@nestjs/common';
|
||
import { ConfigService } from '@nestjs/config';
|
||
import axios, { AxiosInstance } from 'axios';
|
||
import PQueue from 'p-queue';
|
||
import {
|
||
MoexSecurityDescription,
|
||
MoexShareMarketData,
|
||
MoexBondData,
|
||
MoexBondMarketData,
|
||
MoexDividend,
|
||
MoexCandle,
|
||
MoexHistoryEntry,
|
||
MoexBondHistoryEntry,
|
||
} from './moex-client.types';
|
||
|
||
@Injectable()
|
||
export class MoexClientService {
|
||
private readonly logger = new Logger(MoexClientService.name);
|
||
private readonly client: AxiosInstance;
|
||
private readonly queue: PQueue;
|
||
private circuitOpen = false;
|
||
private circuitErrorCount = 0;
|
||
private readonly threshold: number;
|
||
private readonly resetMs: number;
|
||
|
||
constructor(private configService: ConfigService) {
|
||
const baseUrl = this.configService.get<string>('app.moex.baseUrl')!;
|
||
this.threshold = this.configService.get<number>(
|
||
'app.moex.circuitBreakerThreshold',
|
||
5,
|
||
);
|
||
this.resetMs =
|
||
this.configService.get<number>(
|
||
'app.moex.circuitBreakerResetSeconds',
|
||
30,
|
||
) * 1000;
|
||
const rateLimit = this.configService.get<number>(
|
||
'app.moex.rateLimit',
|
||
10,
|
||
);
|
||
|
||
this.client = axios.create({
|
||
baseURL: baseUrl,
|
||
timeout: 10000,
|
||
paramsSerializer: { indexes: null },
|
||
});
|
||
|
||
this.queue = new PQueue({
|
||
interval: 1000,
|
||
intervalCap: rateLimit,
|
||
});
|
||
}
|
||
|
||
private async request<T>(path: string, params?: Record<string, string>): Promise<T> {
|
||
if (this.circuitOpen) {
|
||
throw new Error('Circuit breaker is open — MOEX requests paused');
|
||
}
|
||
|
||
return this.queue.add(async () => {
|
||
try {
|
||
const response = await this.client.get(path, {
|
||
params: { ...params, 'iss.meta': 'off' },
|
||
});
|
||
this.circuitErrorCount = 0;
|
||
return response.data as T;
|
||
} catch (error) {
|
||
this.circuitErrorCount++;
|
||
if (this.circuitErrorCount >= this.threshold) {
|
||
this.circuitOpen = true;
|
||
this.logger.warn(`Circuit breaker opened after ${this.threshold} errors`);
|
||
setTimeout(() => {
|
||
this.circuitOpen = false;
|
||
this.circuitErrorCount = 0;
|
||
this.logger.log('Circuit breaker reset');
|
||
}, this.resetMs);
|
||
}
|
||
throw error;
|
||
}
|
||
}) as Promise<T>;
|
||
}
|
||
|
||
private extractTable(data: Record<string, unknown>, name: string): Record<string, unknown>[] {
|
||
const table = data[name] as Record<string, unknown> | undefined;
|
||
if (!table || !table.columns || !table.data) return [];
|
||
const columns = table.columns as string[];
|
||
const rows = table.data as unknown[][];
|
||
return rows.map((row) => {
|
||
const obj: Record<string, unknown> = {};
|
||
columns.forEach((col, i) => {
|
||
obj[col] = row[i];
|
||
});
|
||
return obj;
|
||
});
|
||
}
|
||
|
||
async searchSecurities(query: string): Promise<MoexSecurityDescription[]> {
|
||
const data = await this.request<Record<string, unknown>>('/securities', {
|
||
q: query,
|
||
});
|
||
return this.extractTable(data, 'securities').map((s) => ({
|
||
secid: s.secid as string,
|
||
isin: s.isin as string,
|
||
name: s.name as string,
|
||
shortName: s.shortName as string,
|
||
latName: (s.latName as string) || null,
|
||
listLevel: parseInt(s.listLevel as string, 10) || 0,
|
||
issueSize: parseInt(s.issuesize as string, 10) || 0,
|
||
faceValue: parseFloat(s.facevalue as string) || 0,
|
||
faceUnit: (s.faceunit as string) || '',
|
||
issueDate: (s.issuedate as string) || '',
|
||
typeName: (s.typename as string) || '',
|
||
group: (s.group as string) || '',
|
||
type: (s.type as string) || '',
|
||
isQualifiedInvestors: (s.isqualifiedinvestors as string) === '1',
|
||
morningSession: (s.morningsession as string) === '1',
|
||
eveningSession: (s.eveningsession as string) === '1',
|
||
}));
|
||
}
|
||
|
||
async getSecurityDescription(secid: string): Promise<MoexSecurityDescription | null> {
|
||
const data = await this.request<Record<string, unknown>>(`/securities/${secid}`);
|
||
const rows = this.extractTable(data, 'description');
|
||
if (rows.length === 0) return null;
|
||
const map = new Map(rows.map((r) => [r.name, r.value]));
|
||
return {
|
||
secid,
|
||
isin: (map.get('ISIN') as string) || '',
|
||
name: (map.get('NAME') as string) || '',
|
||
shortName: (map.get('SHORTNAME') as string) || '',
|
||
latName: (map.get('LATNAME') as string) || null,
|
||
listLevel: parseInt((map.get('LISTLEVEL') as string) || '0', 10),
|
||
issueSize: parseInt((map.get('ISSUESIZE') as string) || '0', 10),
|
||
faceValue: parseFloat((map.get('FACEVALUE') as string) || '0'),
|
||
faceUnit: (map.get('FACEUNIT') as string) || '',
|
||
issueDate: (map.get('ISSUEDATE') as string) || '',
|
||
typeName: (map.get('TYPENAME') as string) || '',
|
||
group: (map.get('GROUP') as string) || '',
|
||
type: (map.get('TYPE') as string) || '',
|
||
isQualifiedInvestors: (map.get('ISQUALIFIEDINVESTORS') as string) === '1',
|
||
morningSession: (map.get('MORNINGSESSION') as string) === '1',
|
||
eveningSession: (map.get('EVENINGSESSION') as string) === '1',
|
||
};
|
||
}
|
||
|
||
async getShareMarketData(secid: string, boardId = 'TQBR'): Promise<MoexShareMarketData | null> {
|
||
const data = await this.request<Record<string, unknown>>(
|
||
`/engines/stock/markets/shares/securities/${secid}`,
|
||
{ boards: boardId },
|
||
);
|
||
const rows = this.extractTable(data, 'securities');
|
||
const share = rows.find((r) => r.BOARDID === boardId);
|
||
if (!share) return null;
|
||
|
||
const mktRows = this.extractTable(data, 'marketdata');
|
||
const mkt = mktRows.find((r) => r.BOARDID === boardId);
|
||
|
||
return {
|
||
secid,
|
||
boardid: boardId,
|
||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
||
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
||
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
||
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
|
||
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
|
||
last: mkt ? parseFloat((mkt.LAST as string) || '') : parseFloat((share.PREVPRICE as string) || ''),
|
||
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
|
||
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
|
||
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
|
||
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
|
||
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
|
||
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
|
||
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
|
||
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
|
||
updateTime: (mkt?.UPDATETIME as string) || '',
|
||
};
|
||
}
|
||
|
||
async getBondData(secid: string, boardId = 'TQCB'): Promise<MoexBondData | null> {
|
||
const data = await this.request<Record<string, unknown>>(
|
||
`/engines/stock/markets/bonds/securities/${secid}`,
|
||
{ boards: boardId },
|
||
);
|
||
const rows = this.extractTable(data, 'securities');
|
||
const bond = rows.find((r) => r.BOARDID === boardId);
|
||
if (!bond) return null;
|
||
|
||
return {
|
||
secid,
|
||
boardid: boardId,
|
||
shortName: (bond.SHORTNAME as string) || '',
|
||
prevWaprice: parseFloat((bond.PREVWAPRICE as string) || '') || null,
|
||
yieldAtPrevWaprice: parseFloat((bond.YIELDATPREVWAPRICE as string) || '') || null,
|
||
couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
|
||
nextCoupon: (bond.NEXTCOUPON as string) || null,
|
||
accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
|
||
prevPrice: parseFloat((bond.PREVPRICE as string) || '') || null,
|
||
lotSize: parseInt((bond.LOTSIZE as string) || '1', 10),
|
||
faceValue: parseFloat((bond.FACEVALUE as string) || '1000'),
|
||
matDate: (bond.MATDATE as string) || '',
|
||
couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10),
|
||
issueSize: parseInt((bond.ISSUESIZE as string) || '0', 10),
|
||
isin: (bond.ISIN as string) || '',
|
||
couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
|
||
offerDate: (bond.OFFERDATE as string) || null,
|
||
buybackDate: (bond.BUYBACKDATE as string) || null,
|
||
bondType: (bond.BONDTYPE as string) || '',
|
||
bondSubType: (bond.BONDSUBTYPE as string) || '',
|
||
listLevel: parseInt((bond.LISTLEVEL as string) || '0', 10),
|
||
};
|
||
}
|
||
|
||
async getBondMarketData(secid: string, boardId = 'TQCB'): Promise<MoexBondMarketData | null> {
|
||
const data = await this.request<Record<string, unknown>>(
|
||
`/engines/stock/markets/bonds/securities/${secid}`,
|
||
{ boards: boardId },
|
||
);
|
||
const mktRows = this.extractTable(data, 'marketdata');
|
||
const mkt = mktRows.find((r) => r.SECID === secid);
|
||
if (!mkt) return null;
|
||
|
||
return {
|
||
secid,
|
||
bid: mkt.BID != null ? parseFloat(mkt.BID as string) : null,
|
||
offer: mkt.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
|
||
open: mkt.OPEN != null ? parseFloat(mkt.OPEN as string) : null,
|
||
low: mkt.LOW != null ? parseFloat(mkt.LOW as string) : null,
|
||
high: mkt.HIGH != null ? parseFloat(mkt.HIGH as string) : null,
|
||
last: mkt.LAST != null ? parseFloat(mkt.LAST as string) : null,
|
||
yield: mkt.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
|
||
waprice: mkt.WAPRICE != null ? parseFloat(mkt.WAPRICE as string) : null,
|
||
yieldAtWaprice: mkt.YIELDATWAPRICE != null ? parseFloat(mkt.YIELDATWAPRICE as string) : null,
|
||
duration: mkt.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
|
||
volume: parseInt((mkt.VOLTODAY as string) || '0', 10),
|
||
value: parseFloat((mkt.VALTODAY as string) || '0'),
|
||
numtrades: parseInt((mkt.NUMTRADES as string) || '0', 10),
|
||
tradingStatus: (mkt.TRADINGSTATUS as string) || '',
|
||
updateTime: (mkt.UPDATETIME as string) || '',
|
||
};
|
||
}
|
||
|
||
async getDividends(secid: string): Promise<MoexDividend[]> {
|
||
const data = await this.request<Record<string, unknown>>(`/securities/${secid}/dividends`);
|
||
return this.extractTable(data, 'dividends').map((d) => ({
|
||
secid: d.secid as string,
|
||
isin: d.isin as string,
|
||
registryCloseDate: d.registryclosedate as string,
|
||
value: parseFloat(d.value as string),
|
||
currencyId: (d.currencyid as string) || 'RUB',
|
||
}));
|
||
}
|
||
|
||
async getCandles(
|
||
engine: 'stock',
|
||
market: 'shares' | 'bonds',
|
||
secid: string,
|
||
interval: 1 | 10 | 60 | 24,
|
||
from: string,
|
||
till: string,
|
||
): Promise<MoexCandle[]> {
|
||
const intervalMap: Record<number, string> = {
|
||
1: '1min',
|
||
10: '10min',
|
||
60: '1hour',
|
||
24: '24hours',
|
||
};
|
||
const data = await this.request<Record<string, unknown>>(
|
||
`/engines/${engine}/markets/${market}/securities/${secid}/candles`,
|
||
{
|
||
interval: String(interval),
|
||
from,
|
||
till,
|
||
},
|
||
);
|
||
return this.extractTable(data, 'candles').map((c) => ({
|
||
open: parseFloat(c.open as string),
|
||
close: parseFloat(c.close as string),
|
||
high: parseFloat(c.high as string),
|
||
low: parseFloat(c.low as string),
|
||
value: parseFloat(c.value as string),
|
||
volume: parseInt(c.volume as string, 10),
|
||
begin: c.begin as string,
|
||
end: c.end as string,
|
||
}));
|
||
}
|
||
|
||
async getHistory(
|
||
secid: string,
|
||
from: string,
|
||
till: string,
|
||
): Promise<MoexHistoryEntry[]> {
|
||
const data = await this.request<Record<string, unknown>>(
|
||
`/engines/stock/markets/shares/securities/${secid}`,
|
||
{ from, till },
|
||
);
|
||
const tableName = Object.keys(data).find(
|
||
(k) => k.startsWith('history') && !k.includes('cursor'),
|
||
);
|
||
if (!tableName) return [];
|
||
return this.extractTable(data, tableName).map((h) => ({
|
||
tradeDate: h.TRADEDATE as string,
|
||
open: h.OPEN != null ? parseFloat(h.OPEN as string) : null,
|
||
low: h.LOW != null ? parseFloat(h.LOW as string) : null,
|
||
high: h.HIGH != null ? parseFloat(h.HIGH as string) : null,
|
||
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
|
||
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
|
||
volume: parseInt((h.VOLUME as string) || '0', 10),
|
||
value: parseFloat((h.VALUE as string) || '0'),
|
||
numtrades: parseInt((h.NUMTRADES as string) || '0', 10),
|
||
}));
|
||
}
|
||
|
||
async getBondHistory(
|
||
secid: string,
|
||
from: string,
|
||
till: string,
|
||
): Promise<MoexBondHistoryEntry[]> {
|
||
const data = await this.request<Record<string, unknown>>(
|
||
`/engines/stock/markets/bonds/securities/${secid}`,
|
||
{ from, till },
|
||
);
|
||
const tableName = Object.keys(data).find(
|
||
(k) => k.startsWith('history') && !k.includes('cursor'),
|
||
);
|
||
if (!tableName) return [];
|
||
return this.extractTable(data, tableName).map((h) => ({
|
||
tradeDate: h.TRADEDATE as string,
|
||
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
|
||
legalClosePrice:
|
||
h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null,
|
||
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
|
||
yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null,
|
||
duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null,
|
||
accruedInt: h.ACCINT != null ? parseFloat(h.ACCINT as string) : null,
|
||
}));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create moex-client.module.ts**
|
||
|
||
```typescript
|
||
import { Global, Module } from '@nestjs/common';
|
||
import { MoexClientService } from './moex-client.service';
|
||
|
||
@Global()
|
||
@Module({
|
||
providers: [MoexClientService],
|
||
exports: [MoexClientService],
|
||
})
|
||
export class MoexClientModule {}
|
||
```
|
||
|
||
- [ ] **Create moex-client.service.spec.ts**
|
||
|
||
```typescript
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { ConfigModule } from '@nestjs/config';
|
||
import { MoexClientService } from './moex-client.service';
|
||
import configuration from '../../config/configuration';
|
||
|
||
describe('MoexClientService', () => {
|
||
let service: MoexClientService;
|
||
|
||
beforeEach(async () => {
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
imports: [
|
||
ConfigModule.forRoot({ load: [configuration] }),
|
||
],
|
||
providers: [MoexClientService],
|
||
}).compile();
|
||
|
||
service = module.get<MoexClientService>(MoexClientService);
|
||
});
|
||
|
||
it('should be defined', () => {
|
||
expect(service).toBeDefined();
|
||
});
|
||
|
||
describe('searchSecurities', () => {
|
||
it('should return results for SBER query', async () => {
|
||
const results = await service.searchSecurities('SBER');
|
||
expect(Array.isArray(results)).toBe(true);
|
||
if (results.length > 0) {
|
||
expect(results[0].secid).toBeDefined();
|
||
}
|
||
}, 15000);
|
||
});
|
||
|
||
describe('getShareMarketData', () => {
|
||
it('should return market data for SBER', async () => {
|
||
const data = await service.getShareMarketData('SBER');
|
||
expect(data).toBeDefined();
|
||
expect(data!.secid).toBe('SBER');
|
||
}, 15000);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/backend/src/modules/moex-client/
|
||
git commit -m "feat: add MoexClient with rate-limited HTTP client, circuit breaker, and MOEX ISS data methods"
|
||
```
|
||
|
||
### Task 1.7: Cache module
|
||
|
||
**Files:**
|
||
- Create: `apps/backend/src/modules/cache/cache.module.ts`
|
||
- Create: `apps/backend/src/modules/cache/cache.service.ts`
|
||
|
||
- [ ] **Create cache.module.ts**
|
||
|
||
```typescript
|
||
import { Module, CacheModule as NestCacheModule } from '@nestjs/cache-manager';
|
||
import { CacheService } from './cache.service';
|
||
|
||
@Module({
|
||
imports: [
|
||
NestCacheModule.register({
|
||
ttl: 900,
|
||
max: 1000,
|
||
isGlobal: true,
|
||
}),
|
||
],
|
||
providers: [CacheService],
|
||
exports: [CacheService],
|
||
})
|
||
export class CacheModule {}
|
||
```
|
||
|
||
- [ ] **Create cache.service.ts**
|
||
|
||
```typescript
|
||
import { Injectable, Inject } from '@nestjs/common';
|
||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||
import { Cache } from 'cache-manager';
|
||
import { ConfigService } from '@nestjs/config';
|
||
|
||
@Injectable()
|
||
export class CacheService {
|
||
constructor(
|
||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||
private configService: ConfigService,
|
||
) {}
|
||
|
||
async get<T>(key: string): Promise<T | undefined> {
|
||
return this.cacheManager.get<T>(key);
|
||
}
|
||
|
||
async set(key: string, value: unknown, ttl?: number): Promise<void> {
|
||
await this.cacheManager.set(key, value, ttl);
|
||
}
|
||
|
||
private buildKey(...parts: string[]): string {
|
||
return parts.join(':');
|
||
}
|
||
|
||
async getOrFetch<T>(
|
||
keyPrefix: string,
|
||
keyParts: string[],
|
||
fetchFn: () => Promise<T>,
|
||
ttlConfigKey: string,
|
||
): Promise<{ data: T; fromCache: boolean; cachedAt: string | null }> {
|
||
const key = this.buildKey(keyPrefix, ...keyParts);
|
||
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
|
||
|
||
const cached = await this.get<T>(key);
|
||
if (cached !== undefined) {
|
||
return { data: cached, fromCache: true, cachedAt: null };
|
||
}
|
||
|
||
const data = await fetchFn();
|
||
await this.set(key, data, ttl);
|
||
|
||
return { data, fromCache: false, cachedAt: new Date().toISOString() };
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/backend/src/modules/cache/
|
||
git commit -m "feat: add cache module with getOrFetch pattern and configurable TTL"
|
||
```
|
||
|
||
---
|
||
|
||
## SPRINT 2: Securities API
|
||
|
||
### Task 2.1: Securities search module
|
||
|
||
**Files:**
|
||
- Create: `apps/backend/src/modules/securities/dto/search-query.dto.ts`
|
||
- Create: `apps/backend/src/modules/securities/securities.controller.ts`
|
||
- Create: `apps/backend/src/modules/securities/securities.service.ts`
|
||
- Create: `apps/backend/src/modules/securities/securities.module.ts`
|
||
- Create: `apps/backend/src/modules/securities/securities.controller.spec.ts`
|
||
- Create: `apps/backend/src/modules/securities/securities.service.spec.ts`
|
||
|
||
- [ ] **Create search-query.dto.ts**
|
||
|
||
```typescript
|
||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||
import { IsString, IsOptional, IsEnum, MinLength, MaxLength } from 'class-validator';
|
||
|
||
export enum SecurityType {
|
||
ALL = 'all',
|
||
SHARE = 'share',
|
||
BOND = 'bond',
|
||
}
|
||
|
||
export class SearchQueryDto {
|
||
@ApiProperty({ description: 'Поисковый запрос (тикер, название, ISIN)' })
|
||
@IsString()
|
||
@MinLength(1)
|
||
@MaxLength(100)
|
||
q: string;
|
||
|
||
@ApiPropertyOptional({ enum: SecurityType, default: SecurityType.ALL })
|
||
@IsOptional()
|
||
@IsEnum(SecurityType)
|
||
type?: SecurityType = SecurityType.ALL;
|
||
|
||
@ApiPropertyOptional({ default: 20 })
|
||
@IsOptional()
|
||
limit?: number = 20;
|
||
}
|
||
```
|
||
|
||
- [ ] **Create search result DTO inline or reuse** — add to controller response via transform interceptor.
|
||
|
||
- [ ] **Create securities.service.ts**
|
||
|
||
```typescript
|
||
import { Injectable } from '@nestjs/common';
|
||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||
import { CacheService } from '../cache/cache.service';
|
||
import { SecurityType } from './dto/search-query.dto';
|
||
|
||
export interface SearchResultItem {
|
||
secid: string;
|
||
isin: string;
|
||
shortName: string;
|
||
type: 'share' | 'bond';
|
||
listLevel: number;
|
||
currency: string | null;
|
||
price: number | null;
|
||
}
|
||
|
||
@Injectable()
|
||
export class SecuritiesService {
|
||
constructor(
|
||
private readonly moexClient: MoexClientService,
|
||
private readonly cache: CacheService,
|
||
) {}
|
||
|
||
async search(query: string, type: SecurityType, limit: number): Promise<SearchResultItem[]> {
|
||
const { data } = await this.cache.getOrFetch(
|
||
'search',
|
||
[query.toLowerCase()],
|
||
async () => {
|
||
const results = await this.moexClient.searchSecurities(query);
|
||
return results.map((s) => ({
|
||
secid: s.secid,
|
||
isin: s.isin,
|
||
shortName: s.shortName,
|
||
type: (s.group === 'stock_shares' || s.type === 'common_share' || s.type === 'preferred_share')
|
||
? 'share' as const
|
||
: (s.group === 'stock_bonds' ? 'bond' as const : null),
|
||
listLevel: s.listLevel,
|
||
currency: s.faceUnit === 'SUR' ? 'RUB' : s.faceUnit || null,
|
||
price: null,
|
||
})).filter((r): r is SearchResultItem => r.type !== null);
|
||
},
|
||
'searchTtl',
|
||
);
|
||
|
||
let filtered = data;
|
||
if (type === SecurityType.SHARE) {
|
||
filtered = data.filter((r) => r.type === 'share');
|
||
} else if (type === SecurityType.BOND) {
|
||
filtered = data.filter((r) => r.type === 'bond');
|
||
}
|
||
|
||
return filtered.slice(0, limit);
|
||
}
|
||
|
||
async getShareBrief(secid: string): Promise<SearchResultItem | null> {
|
||
try {
|
||
const desc = await this.moexClient.getSecurityDescription(secid);
|
||
if (!desc) return null;
|
||
return {
|
||
secid: desc.secid,
|
||
isin: desc.isin,
|
||
shortName: desc.shortName,
|
||
type: 'share',
|
||
listLevel: desc.listLevel,
|
||
currency: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
|
||
price: null,
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create securities.controller.ts**
|
||
|
||
```typescript
|
||
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
|
||
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||
import { SecuritiesService } from './securities.service';
|
||
import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
|
||
|
||
@ApiTags('Securities')
|
||
@Controller('securities')
|
||
export class SecuritiesController {
|
||
constructor(private readonly securitiesService: SecuritiesService) {}
|
||
|
||
@Get('search')
|
||
@ApiOperation({ summary: 'Поиск по инструментам' })
|
||
async search(@Query(ValidationPipe) query: SearchQueryDto) {
|
||
const results = await this.securitiesService.search(
|
||
query.q,
|
||
query.type || SecurityType.ALL,
|
||
query.limit || 20,
|
||
);
|
||
return { data: results, meta: { cachedAt: null, fromCache: false } };
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create securities.module.ts**
|
||
|
||
```typescript
|
||
import { Module } from '@nestjs/common';
|
||
import { SecuritiesController } from './securities.controller';
|
||
import { SecuritiesService } from './securities.service';
|
||
|
||
@Module({
|
||
controllers: [SecuritiesController],
|
||
providers: [SecuritiesService],
|
||
exports: [SecuritiesService],
|
||
})
|
||
export class SecuritiesModule {}
|
||
```
|
||
|
||
- [ ] **Create securities.service.spec.ts**
|
||
|
||
```typescript
|
||
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { ConfigModule } from '@nestjs/config';
|
||
import { SecuritiesService } from './securities.service';
|
||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||
import { CacheService } from '../cache/cache.service';
|
||
import configuration from '../../config/configuration';
|
||
import { SecurityType } from './dto/search-query.dto';
|
||
|
||
describe('SecuritiesService', () => {
|
||
let service: SecuritiesService;
|
||
|
||
beforeEach(async () => {
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||
providers: [
|
||
SecuritiesService,
|
||
MoexClientService,
|
||
{
|
||
provide: 'CACHE_MANAGER',
|
||
useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() },
|
||
},
|
||
CacheService,
|
||
],
|
||
}).compile();
|
||
|
||
service = module.get<SecuritiesService>(SecuritiesService);
|
||
});
|
||
|
||
it('should return search results for SBER', async () => {
|
||
const results = await service.search('SBER', SecurityType.ALL, 5);
|
||
expect(results.length).toBeGreaterThan(0);
|
||
expect(results[0].secid).toBeDefined();
|
||
}, 15000);
|
||
});
|
||
```
|
||
|
||
- [ ] **Create securities.controller.spec.ts** — similar pattern with mocked service.
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/backend/src/modules/securities/
|
||
git commit -m "feat: add securities search endpoint"
|
||
```
|
||
|
||
### Task 2.2: Shares module — spec + marketdata
|
||
|
||
**Files:**
|
||
- Create: `apps/backend/src/modules/shares/dto/share-response.dto.ts`
|
||
- Create: `apps/backend/src/modules/shares/dto/share-marketdata-response.dto.ts`
|
||
- Create: `apps/backend/src/modules/shares/shares.service.ts`
|
||
- Create: `apps/backend/src/modules/shares/shares.controller.ts`
|
||
- Create: `apps/backend/src/modules/shares/shares.module.ts`
|
||
|
||
- [ ] **Create share-response.dto.ts**
|
||
|
||
```typescript
|
||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||
|
||
export class StockMarketDataDto {
|
||
@ApiProperty({ example: 322.35 })
|
||
price: number;
|
||
|
||
@ApiProperty({ example: 1.15 })
|
||
change: number;
|
||
|
||
@ApiProperty({ example: 0.36 })
|
||
changePercent: number;
|
||
|
||
@ApiProperty({ example: 321.3 })
|
||
open: number;
|
||
|
||
@ApiPropertyOptional({ example: 322.66 })
|
||
high: number | null;
|
||
|
||
@ApiPropertyOptional({ example: 321.2 })
|
||
low: number | null;
|
||
|
||
@ApiProperty({ example: 1925163 })
|
||
volume: number;
|
||
|
||
@ApiProperty({ example: 620184479 })
|
||
value: number;
|
||
|
||
@ApiPropertyOptional({ example: 6958336818320 })
|
||
issueCapitalization: number | null;
|
||
|
||
@ApiProperty()
|
||
updatedAt: string;
|
||
}
|
||
|
||
export class ShareResponseDto {
|
||
@ApiProperty({ example: 'SBER' })
|
||
secid: string;
|
||
|
||
@ApiProperty({ example: 'RU0009029540' })
|
||
isin: string;
|
||
|
||
@ApiProperty({ example: 'Сбербанк России ПАО ао' })
|
||
name: string;
|
||
|
||
@ApiProperty({ example: 'Сбербанк' })
|
||
shortName: string;
|
||
|
||
@ApiPropertyOptional()
|
||
latName: string | null;
|
||
|
||
@ApiProperty({ example: 1 })
|
||
listLevel: number;
|
||
|
||
@ApiProperty({ example: 21586948000 })
|
||
issueSize: number;
|
||
|
||
@ApiProperty({ example: 3 })
|
||
faceValue: number;
|
||
|
||
@ApiProperty({ example: 'RUB' })
|
||
faceUnit: string;
|
||
|
||
@ApiProperty({ example: 'common_share' })
|
||
type: string;
|
||
|
||
@ApiProperty()
|
||
marketData: StockMarketDataDto;
|
||
}
|
||
```
|
||
|
||
- [ ] **Create share-marketdata-response.dto.ts**
|
||
|
||
```typescript
|
||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||
import { StockMarketDataDto } from './share-response.dto';
|
||
|
||
export class ShareMarketDataResponseDto extends StockMarketDataDto {}
|
||
```
|
||
|
||
- [ ] **Create shares.service.ts**
|
||
|
||
```typescript
|
||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||
import { CacheService } from '../cache/cache.service';
|
||
|
||
@Injectable()
|
||
export class SharesService {
|
||
constructor(
|
||
private readonly moexClient: MoexClientService,
|
||
private readonly cache: CacheService,
|
||
) {}
|
||
|
||
async getShare(secid: string) {
|
||
const desc = await this.moexClient.getSecurityDescription(secid);
|
||
if (!desc || !(desc.group === 'stock_shares' || desc.type === 'common_share' || desc.type === 'preferred_share')) {
|
||
throw new NotFoundException(`Share ${secid} not found`);
|
||
}
|
||
|
||
const { data: marketData } = await this.cache.getOrFetch(
|
||
'marketdata',
|
||
['shares', secid],
|
||
() => this.moexClient.getShareMarketData(secid),
|
||
'marketDataTtl',
|
||
);
|
||
|
||
const price = marketData?.last ?? (marketData ? null : 0);
|
||
const prevPrice = 0; // not stored separately, but available from securities table
|
||
const change = marketData?.lastChange ?? 0;
|
||
const changePercent = marketData?.lastChangePrcnt ?? 0;
|
||
|
||
return {
|
||
secid: desc.secid,
|
||
isin: desc.isin,
|
||
name: desc.name,
|
||
shortName: desc.shortName,
|
||
latName: desc.latName,
|
||
listLevel: desc.listLevel,
|
||
issueSize: desc.issueSize,
|
||
faceValue: desc.faceValue,
|
||
faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
|
||
type: desc.type,
|
||
marketData: {
|
||
price: price ?? 0,
|
||
change,
|
||
changePercent,
|
||
open: marketData?.open ?? 0,
|
||
high: marketData?.high ?? null,
|
||
low: marketData?.low ?? null,
|
||
volume: marketData?.volume ?? 0,
|
||
value: marketData?.value ?? 0,
|
||
issueCapitalization: marketData?.issueCapitalization ?? null,
|
||
updatedAt: marketData?.updateTime
|
||
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
||
: new Date().toISOString(),
|
||
},
|
||
};
|
||
}
|
||
|
||
async getMarketData(secid: string) {
|
||
const { data: marketData, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||
'marketdata',
|
||
['shares', secid],
|
||
() => this.moexClient.getShareMarketData(secid),
|
||
'marketDataTtl',
|
||
);
|
||
|
||
if (!marketData) {
|
||
throw new NotFoundException(`Market data for ${secid} not found`);
|
||
}
|
||
|
||
return {
|
||
data: {
|
||
price: marketData.last ?? 0,
|
||
change: marketData.lastChange ?? 0,
|
||
changePercent: marketData.lastChangePrcnt ?? 0,
|
||
open: marketData.open ?? 0,
|
||
high: marketData.high ?? null,
|
||
low: marketData.low ?? null,
|
||
volume: marketData.volume ?? 0,
|
||
value: marketData.value ?? 0,
|
||
issueCapitalization: marketData.issueCapitalization ?? null,
|
||
updatedAt: marketData.updateTime
|
||
? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime
|
||
: new Date().toISOString(),
|
||
},
|
||
meta: { fromCache, cachedAt },
|
||
};
|
||
}
|
||
|
||
async getDividends(secid: string) {
|
||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||
'dividends',
|
||
[secid],
|
||
() => this.moexClient.getDividends(secid),
|
||
'dividendsTtl',
|
||
);
|
||
|
||
return {
|
||
data: data.map((d) => ({
|
||
registryCloseDate: d.registryCloseDate,
|
||
value: d.value,
|
||
currency: d.currencyId,
|
||
})),
|
||
meta: { fromCache, cachedAt },
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create shares.controller.ts**
|
||
|
||
```typescript
|
||
import { Controller, Get, Param } from '@nestjs/common';
|
||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||
import { SharesService } from './shares.service';
|
||
|
||
@ApiTags('Shares')
|
||
@Controller('securities/shares')
|
||
export class SharesController {
|
||
constructor(private readonly sharesService: SharesService) {}
|
||
|
||
@Get(':secid')
|
||
@ApiOperation({ summary: 'Получить спецификацию акции' })
|
||
async getShare(@Param('secid') secid: string) {
|
||
const share = await this.sharesService.getShare(secid);
|
||
return { data: share, meta: { cachedAt: null, fromCache: false } };
|
||
}
|
||
|
||
@Get(':secid/marketdata')
|
||
@ApiOperation({ summary: 'Получить рыночные данные акции' })
|
||
async getMarketData(@Param('secid') secid: string) {
|
||
return this.sharesService.getMarketData(secid);
|
||
}
|
||
|
||
@Get(':secid/dividends')
|
||
@ApiOperation({ summary: 'Получить дивиденды' })
|
||
async getDividends(@Param('secid') secid: string) {
|
||
return this.sharesService.getDividends(secid);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create shares.module.ts**
|
||
|
||
```typescript
|
||
import { Module } from '@nestjs/common';
|
||
import { SharesController } from './shares.controller';
|
||
import { SharesService } from './shares.service';
|
||
|
||
@Module({
|
||
controllers: [SharesController],
|
||
providers: [SharesService],
|
||
exports: [SharesService],
|
||
})
|
||
export class SharesModule {}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/backend/src/modules/shares/
|
||
git commit -m "feat: add shares endpoint with market data and dividends"
|
||
```
|
||
|
||
---
|
||
|
||
## SPRINT 3: Bonds + History + Candles
|
||
|
||
### Task 3.1: Bonds module
|
||
|
||
**Files:**
|
||
- Create: `apps/backend/src/modules/bonds/dto/bond-response.dto.ts`
|
||
- Create: `apps/backend/src/modules/bonds/dto/bond-marketdata-response.dto.ts`
|
||
- Create: `apps/backend/src/modules/bonds/bonds.service.ts`
|
||
- Create: `apps/backend/src/modules/bonds/bonds.controller.ts`
|
||
- Create: `apps/backend/src/modules/bonds/bonds.module.ts`
|
||
|
||
- [ ] **Create bond-response.dto.ts**
|
||
|
||
```typescript
|
||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||
|
||
export class BondMarketDataDto {
|
||
@ApiProperty({ description: 'Цена в % от номинала', example: 100.45 })
|
||
price: number;
|
||
|
||
@ApiPropertyOptional({ example: 12.71 })
|
||
yieldToMaturity: number | null;
|
||
|
||
@ApiPropertyOptional()
|
||
duration: number | null;
|
||
|
||
@ApiProperty({ example: 29.48 })
|
||
accruedInt: number;
|
||
|
||
@ApiProperty({ example: 40.64 })
|
||
couponValue: number;
|
||
|
||
@ApiPropertyOptional({ example: 8.15 })
|
||
couponPercent: number | null;
|
||
|
||
@ApiPropertyOptional({ example: '2026-08-05' })
|
||
nextCouponDate: string | null;
|
||
|
||
@ApiProperty()
|
||
open: number;
|
||
|
||
@ApiPropertyOptional()
|
||
high: number | null;
|
||
|
||
@ApiPropertyOptional()
|
||
low: number | null;
|
||
|
||
@ApiProperty()
|
||
volume: number;
|
||
|
||
@ApiProperty()
|
||
updatedAt: string;
|
||
}
|
||
|
||
export class BondResponseDto {
|
||
@ApiProperty({ example: 'SU26207RMFS9' })
|
||
secid: string;
|
||
|
||
@ApiProperty({ example: 'RU000A0JS3W6' })
|
||
isin: string;
|
||
|
||
@ApiProperty({ example: 'ОФЗ-ПД 26207 03/02/27' })
|
||
name: string;
|
||
|
||
@ApiProperty({ example: 'ОФЗ 26207' })
|
||
shortName: string;
|
||
|
||
@ApiPropertyOptional()
|
||
latName: string | null;
|
||
|
||
@ApiProperty({ example: 1 })
|
||
listLevel: number;
|
||
|
||
@ApiProperty({ example: 370200604 })
|
||
issueSize: number;
|
||
|
||
@ApiProperty({ example: 1000 })
|
||
faceValue: number;
|
||
|
||
@ApiProperty({ example: 'RUB' })
|
||
faceUnit: string;
|
||
|
||
@ApiProperty({ example: '2027-02-03' })
|
||
matDate: string;
|
||
|
||
@ApiProperty({ example: 40.64 })
|
||
couponValue: number;
|
||
|
||
@ApiPropertyOptional({ example: 8.15 })
|
||
couponPercent: number | null;
|
||
|
||
@ApiProperty({ example: 182 })
|
||
couponPeriod: number;
|
||
|
||
@ApiProperty({ example: '2026-08-05' })
|
||
nextCoupon: string | null;
|
||
|
||
@ApiProperty({ example: 29.48 })
|
||
accruedInt: number;
|
||
|
||
@ApiProperty({ example: 'Фикс с известным купоном' })
|
||
bondType: string;
|
||
|
||
@ApiProperty({ example: 'До погашения' })
|
||
bondSubType: string;
|
||
|
||
@ApiPropertyOptional()
|
||
offerDate: string | null;
|
||
|
||
@ApiPropertyOptional()
|
||
buybackDate: string | null;
|
||
|
||
@ApiProperty()
|
||
marketData: BondMarketDataDto;
|
||
}
|
||
```
|
||
|
||
- [ ] **Create bonds.service.ts**
|
||
|
||
```typescript
|
||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||
import { CacheService } from '../cache/cache.service';
|
||
|
||
@Injectable()
|
||
export class BondsService {
|
||
constructor(
|
||
private readonly moexClient: MoexClientService,
|
||
private readonly cache: CacheService,
|
||
) {}
|
||
|
||
async getBond(secid: string) {
|
||
const { data: bond, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||
'bond',
|
||
[secid],
|
||
() => this.moexClient.getBondData(secid),
|
||
'securityTtl',
|
||
);
|
||
|
||
if (!bond) {
|
||
throw new NotFoundException(`Bond ${secid} not found`);
|
||
}
|
||
|
||
const { data: mkt } = await this.cache.getOrFetch(
|
||
'marketdata',
|
||
['bonds', secid],
|
||
() => this.moexClient.getBondMarketData(secid),
|
||
'marketDataTtl',
|
||
);
|
||
|
||
return {
|
||
data: {
|
||
secid: bond.secid,
|
||
isin: bond.isin,
|
||
name: bond.shortName,
|
||
shortName: bond.shortName,
|
||
latName: null,
|
||
listLevel: bond.listLevel,
|
||
issueSize: bond.issueSize,
|
||
faceValue: bond.faceValue,
|
||
faceUnit: bond.isin.startsWith('XS') ? 'USD' : 'RUB',
|
||
matDate: bond.matDate,
|
||
couponValue: bond.couponValue ?? 0,
|
||
couponPercent: bond.couponPercent,
|
||
couponPeriod: bond.couponPeriod,
|
||
nextCoupon: bond.nextCoupon,
|
||
accruedInt: bond.accruedInt ?? 0,
|
||
bondType: bond.bondType,
|
||
bondSubType: bond.bondSubType,
|
||
offerDate: bond.offerDate,
|
||
buybackDate: bond.buybackDate,
|
||
marketData: {
|
||
price: mkt?.last ?? bond.prevPrice ?? 0,
|
||
yieldToMaturity: mkt?.yield ?? bond.yieldAtPrevWaprice ?? null,
|
||
duration: mkt?.duration ?? null,
|
||
accruedInt: bond.accruedInt ?? 0,
|
||
couponValue: bond.couponValue ?? 0,
|
||
couponPercent: bond.couponPercent,
|
||
nextCouponDate: bond.nextCoupon,
|
||
open: mkt?.open ?? 0,
|
||
high: mkt?.high ?? null,
|
||
low: mkt?.low ?? null,
|
||
volume: mkt?.volume ?? 0,
|
||
updatedAt: mkt?.updateTime
|
||
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
|
||
: new Date().toISOString(),
|
||
},
|
||
},
|
||
meta: { fromCache, cachedAt },
|
||
};
|
||
}
|
||
|
||
async getMarketData(secid: string) {
|
||
const { data: mkt, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||
'marketdata',
|
||
['bonds', secid],
|
||
() => this.moexClient.getBondMarketData(secid),
|
||
'marketDataTtl',
|
||
);
|
||
|
||
if (!mkt) {
|
||
throw new NotFoundException(`Market data for bond ${secid} not found`);
|
||
}
|
||
|
||
return {
|
||
data: {
|
||
price: mkt.last ?? 0,
|
||
yieldToMaturity: mkt.yield ?? null,
|
||
duration: mkt.duration ?? null,
|
||
accruedInt: 0,
|
||
couponValue: 0,
|
||
couponPercent: null,
|
||
nextCouponDate: null,
|
||
open: mkt.open ?? 0,
|
||
high: mkt.high ?? null,
|
||
low: mkt.low ?? null,
|
||
volume: mkt.volume ?? 0,
|
||
updatedAt: mkt.updateTime
|
||
? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime
|
||
: new Date().toISOString(),
|
||
},
|
||
meta: { fromCache, cachedAt },
|
||
};
|
||
}
|
||
|
||
async getHistory(secid: string, from: string, till: string) {
|
||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||
'history',
|
||
['bonds', secid, from, till],
|
||
() => this.moexClient.getBondHistory(secid, from, till),
|
||
'historyTtl',
|
||
);
|
||
|
||
return {
|
||
data: data.map((h) => ({
|
||
date: h.tradeDate,
|
||
closePrice: h.legalClosePrice ?? h.close ?? 0,
|
||
yieldClose: h.yieldClose ?? null,
|
||
duration: h.duration ?? null,
|
||
})),
|
||
meta: { fromCache, cachedAt },
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create bonds.controller.ts**
|
||
|
||
```typescript
|
||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||
import { BondsService } from './bonds.service';
|
||
|
||
@ApiTags('Bonds')
|
||
@Controller('securities/bonds')
|
||
export class BondsController {
|
||
constructor(private readonly bondsService: BondsService) {}
|
||
|
||
@Get(':secid')
|
||
@ApiOperation({ summary: 'Получить спецификацию облигации' })
|
||
async getBond(@Param('secid') secid: string) {
|
||
return this.bondsService.getBond(secid);
|
||
}
|
||
|
||
@Get(':secid/marketdata')
|
||
@ApiOperation({ summary: 'Получить рыночные данные облигации' })
|
||
async getMarketData(@Param('secid') secid: string) {
|
||
return this.bondsService.getMarketData(secid);
|
||
}
|
||
|
||
@Get(':secid/history')
|
||
@ApiOperation({ summary: 'Получить дневную историю торгов облигации' })
|
||
async getHistory(
|
||
@Param('secid') secid: string,
|
||
@Query('from') from: string,
|
||
@Query('till') till: string,
|
||
) {
|
||
return this.bondsService.getHistory(secid, from, till);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create bonds.module.ts**
|
||
|
||
```typescript
|
||
import { Module } from '@nestjs/common';
|
||
import { BondsController } from './bonds.controller';
|
||
import { BondsService } from './bonds.service';
|
||
|
||
@Module({
|
||
controllers: [BondsController],
|
||
providers: [BondsService],
|
||
exports: [BondsService],
|
||
})
|
||
export class BondsModule {}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/backend/src/modules/bonds/
|
||
git commit -m "feat: add bonds endpoint with market data and history"
|
||
```
|
||
|
||
### Task 3.2: Candles module (shared by shares + bonds)
|
||
|
||
**Files:**
|
||
- Create: `apps/backend/src/modules/candles/dto/candles-query.dto.ts`
|
||
- Create: `apps/backend/src/modules/candles/candles.service.ts`
|
||
- Create: `apps/backend/src/modules/candles/candles.controller.ts`
|
||
- Create: `apps/backend/src/modules/candles/candles.module.ts`
|
||
|
||
- [ ] **Create candles-query.dto.ts**
|
||
|
||
```typescript
|
||
import { ApiProperty } from '@nestjs/swagger';
|
||
import { IsString, IsEnum, IsDateString } from 'class-validator';
|
||
|
||
export enum CandleInterval {
|
||
HOUR = '1h',
|
||
DAY = '24h',
|
||
}
|
||
|
||
export class CandlesQueryDto {
|
||
@ApiProperty({ enum: CandleInterval })
|
||
@IsEnum(CandleInterval)
|
||
interval: CandleInterval;
|
||
|
||
@ApiProperty({ format: 'date', example: '2025-06-13' })
|
||
@IsDateString()
|
||
from: string;
|
||
|
||
@ApiProperty({ format: 'date', example: '2026-06-13' })
|
||
@IsDateString()
|
||
till: string;
|
||
}
|
||
```
|
||
|
||
- [ ] **Create candles.service.ts**
|
||
|
||
```typescript
|
||
import { Injectable } from '@nestjs/common';
|
||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||
import { CacheService } from '../cache/cache.service';
|
||
import { CandleInterval } from './dto/candles-query.dto';
|
||
|
||
@Injectable()
|
||
export class CandlesService {
|
||
constructor(
|
||
private readonly moexClient: MoexClientService,
|
||
private readonly cache: CacheService,
|
||
) {}
|
||
|
||
private mapInterval(interval: CandleInterval): 60 | 24 {
|
||
return interval === CandleInterval.HOUR ? 60 : 24;
|
||
}
|
||
|
||
async getCandles(
|
||
market: 'shares' | 'bonds',
|
||
secid: string,
|
||
interval: CandleInterval,
|
||
from: string,
|
||
till: string,
|
||
) {
|
||
const moexInterval = this.mapInterval(interval);
|
||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||
'candles',
|
||
[market, secid, String(moexInterval), from, till],
|
||
() =>
|
||
this.moexClient.getCandles('stock', market, secid, moexInterval, from, till),
|
||
'candlesTtl',
|
||
);
|
||
|
||
return {
|
||
data: data.map((c) => ({
|
||
open: c.open,
|
||
high: c.high,
|
||
low: c.low,
|
||
close: c.close,
|
||
volume: c.volume,
|
||
value: c.value,
|
||
begin: c.begin,
|
||
end: c.end,
|
||
})),
|
||
meta: { fromCache, cachedAt },
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create candles.controller.ts**
|
||
|
||
```typescript
|
||
import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common';
|
||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||
import { CandlesService } from './candles.service';
|
||
import { CandlesQueryDto } from './dto/candles-query.dto';
|
||
|
||
@ApiTags('Candles')
|
||
@Controller('securities')
|
||
export class CandlesController {
|
||
constructor(private readonly candlesService: CandlesService) {}
|
||
|
||
@Get('shares/:secid/candles')
|
||
@ApiOperation({ summary: 'Получить свечи акции' })
|
||
async getShareCandles(
|
||
@Param('secid') secid: string,
|
||
@Query(ValidationPipe) query: CandlesQueryDto,
|
||
) {
|
||
return this.candlesService.getCandles('shares', secid, query.interval, query.from, query.till);
|
||
}
|
||
|
||
@Get('bonds/:secid/candles')
|
||
@ApiOperation({ summary: 'Получить свечи облигации' })
|
||
async getBondCandles(
|
||
@Param('secid') secid: string,
|
||
@Query(ValidationPipe) query: CandlesQueryDto,
|
||
) {
|
||
return this.candlesService.getCandles('bonds', secid, query.interval, query.from, query.till);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create candles.module.ts**
|
||
|
||
```typescript
|
||
import { Module } from '@nestjs/common';
|
||
import { CandlesController } from './candles.controller';
|
||
import { CandlesService } from './candles.service';
|
||
|
||
@Module({
|
||
controllers: [CandlesController],
|
||
providers: [CandlesService],
|
||
exports: [CandlesService],
|
||
})
|
||
export class CandlesModule {}
|
||
```
|
||
|
||
- [ ] **Add OpenAPI decorators to share history endpoint** in `shares.controller.ts`:
|
||
|
||
```typescript
|
||
@Get(':secid/history')
|
||
@ApiOperation({ summary: 'Получить дневную историю торгов акции' })
|
||
async getHistory(
|
||
@Param('secid') secid: string,
|
||
@Query('from') from: string,
|
||
@Query('till') till: string,
|
||
) {
|
||
return this.sharesService.getHistory(secid, from, till);
|
||
}
|
||
```
|
||
|
||
- [ ] **Add getHistory method to SharesService**:
|
||
|
||
```typescript
|
||
async getHistory(secid: string, from: string, till: string) {
|
||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||
'history',
|
||
['shares', secid, from, till],
|
||
() => this.moexClient.getHistory(secid, from, till),
|
||
'historyTtl',
|
||
);
|
||
|
||
return {
|
||
data: data.map((h) => ({
|
||
date: h.tradeDate,
|
||
open: h.open ?? 0,
|
||
high: h.high ?? 0,
|
||
low: h.low ?? 0,
|
||
close: h.close ?? 0,
|
||
volume: h.volume,
|
||
value: h.value,
|
||
})),
|
||
meta: { fromCache, cachedAt },
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/backend/src/modules/candles/
|
||
git commit -m "feat: add candles module with 1h/24h intervals for shares and bonds"
|
||
```
|
||
|
||
---
|
||
|
||
## SPRINT 4: Frontend Foundation
|
||
|
||
### Task 4.1: Scaffold React + Vite frontend
|
||
|
||
**Files:**
|
||
- Create: `apps/frontend/package.json`
|
||
- Create: `apps/frontend/tsconfig.json`
|
||
- Create: `apps/frontend/tsconfig.node.json`
|
||
- Create: `apps/frontend/vite.config.ts`
|
||
- Create: `apps/frontend/index.html`
|
||
- Create: `apps/frontend/src/vite-env.d.ts`
|
||
- Create: `apps/frontend/src/main.tsx`
|
||
- Create: `apps/frontend/src/App.tsx`
|
||
- Create: `apps/frontend/src/routes.tsx`
|
||
- Create: `apps/frontend/src/styles.css`
|
||
|
||
- [ ] **Create apps/frontend/package.json**
|
||
|
||
```json
|
||
{
|
||
"name": "@moex-vibe/frontend",
|
||
"version": "0.0.1",
|
||
"private": true,
|
||
"type": "module",
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "tsc -b && vite build",
|
||
"preview": "vite preview",
|
||
"codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts"
|
||
},
|
||
"dependencies": {
|
||
"react": "^18.3.0",
|
||
"react-dom": "^18.3.0",
|
||
"react-router-dom": "^6.20.0",
|
||
"@tanstack/react-query": "^5.20.0",
|
||
"openapi-fetch": "^0.9.0",
|
||
"lightweight-charts": "^4.1.0"
|
||
},
|
||
"devDependencies": {
|
||
"@types/react": "^18.3.0",
|
||
"@types/react-dom": "^18.3.0",
|
||
"@vitejs/plugin-react": "^4.2.0",
|
||
"typescript": "^5.3.0",
|
||
"vite": "^5.4.0",
|
||
"openapi-typescript": "^7.0.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create apps/frontend/tsconfig.json**
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2020",
|
||
"useDefineForClassFields": true,
|
||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||
"module": "ESNext",
|
||
"skipLibCheck": true,
|
||
"moduleResolution": "bundler",
|
||
"allowImportingTsExtensions": true,
|
||
"isolatedModules": true,
|
||
"moduleDetection": "force",
|
||
"noEmit": true,
|
||
"jsx": "react-jsx",
|
||
"strict": true,
|
||
"noUnusedLocals": false,
|
||
"noUnusedParameters": false,
|
||
"noFallthroughCasesInSwitch": true,
|
||
"paths": {
|
||
"@/*": ["./src/*"]
|
||
}
|
||
},
|
||
"include": ["src"],
|
||
"references": [{ "path": "./tsconfig.node.json" }]
|
||
}
|
||
```
|
||
|
||
- [ ] **Create apps/frontend/tsconfig.node.json**
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2022",
|
||
"lib": ["ES2023"],
|
||
"module": "ESNext",
|
||
"skipLibCheck": true,
|
||
"moduleResolution": "bundler",
|
||
"allowImportingTsExtensions": true,
|
||
"isolatedModules": true,
|
||
"moduleDetection": "force",
|
||
"noEmit": true,
|
||
"strict": true
|
||
},
|
||
"include": ["vite.config.ts"]
|
||
}
|
||
```
|
||
|
||
- [ ] **Create apps/frontend/vite.config.ts**
|
||
|
||
```typescript
|
||
import { defineConfig } from 'vite';
|
||
import react from '@vitejs/plugin-react';
|
||
import path from 'path';
|
||
|
||
export default defineConfig({
|
||
plugins: [react()],
|
||
resolve: {
|
||
alias: {
|
||
'@': path.resolve(__dirname, './src'),
|
||
},
|
||
},
|
||
server: {
|
||
port: 5173,
|
||
proxy: {
|
||
'/api': {
|
||
target: 'http://localhost:3000',
|
||
changeOrigin: true,
|
||
},
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
- [ ] **Create apps/frontend/index.html**
|
||
|
||
```html
|
||
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>MoexVibe</title>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
<script type="module" src="/src/main.tsx"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Create apps/frontend/src/vite-env.d.ts**
|
||
|
||
```typescript
|
||
/// <reference types="vite/client" />
|
||
```
|
||
|
||
- [ ] **Create apps/frontend/src/main.tsx**
|
||
|
||
```typescript
|
||
import React from 'react';
|
||
import ReactDOM from 'react-dom/client';
|
||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||
import App from './App';
|
||
import './styles.css';
|
||
|
||
const queryClient = new QueryClient({
|
||
defaultOptions: {
|
||
queries: {
|
||
retry: 2,
|
||
staleTime: 900_000,
|
||
refetchOnWindowFocus: false,
|
||
},
|
||
},
|
||
});
|
||
|
||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||
<React.StrictMode>
|
||
<QueryClientProvider client={queryClient}>
|
||
<App />
|
||
</QueryClientProvider>
|
||
</React.StrictMode>,
|
||
);
|
||
```
|
||
|
||
- [ ] **Create apps/frontend/src/App.tsx**
|
||
|
||
```typescript
|
||
import { BrowserRouter } from 'react-router-dom';
|
||
import { AppRoutes } from './routes';
|
||
|
||
export default function App() {
|
||
return (
|
||
<BrowserRouter>
|
||
<AppRoutes />
|
||
</BrowserRouter>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Create apps/frontend/src/routes.tsx**
|
||
|
||
```typescript
|
||
import { Routes, Route } from 'react-router-dom';
|
||
import { Layout } from './components/Layout';
|
||
import { HomePage } from './pages/HomePage';
|
||
import { StockPage } from './pages/StockPage';
|
||
import { BondPage } from './pages/BondPage';
|
||
|
||
export function AppRoutes() {
|
||
return (
|
||
<Routes>
|
||
<Route element={<Layout />}>
|
||
<Route path="/" element={<HomePage />} />
|
||
<Route path="/stocks/:secid" element={<StockPage />} />
|
||
<Route path="/bonds/:secid" element={<BondPage />} />
|
||
</Route>
|
||
</Routes>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Create apps/frontend/src/styles.css** — minimal reset:
|
||
|
||
```css
|
||
*,
|
||
*::before,
|
||
*::after {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
:root {
|
||
--color-bg: #f5f5f5;
|
||
--color-surface: #ffffff;
|
||
--color-text: #1a1a1a;
|
||
--color-text-secondary: #666;
|
||
--color-primary: #1976d2;
|
||
--color-positive: #2e7d32;
|
||
--color-negative: #c62828;
|
||
--border-radius: 8px;
|
||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
background: var(--color-bg);
|
||
color: var(--color-text);
|
||
line-height: 1.6;
|
||
}
|
||
|
||
a {
|
||
color: var(--color-primary);
|
||
text-decoration: none;
|
||
}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/
|
||
git commit -m "feat: scaffold React + Vite frontend with routing"
|
||
```
|
||
|
||
### Task 4.2: Generate API client from OpenAPI schema
|
||
|
||
- [ ] **Start backend**, then run:
|
||
|
||
```bash
|
||
npm run codegen -w apps/frontend
|
||
```
|
||
|
||
This creates `apps/frontend/src/api/types.ts` with all typed DTOs.
|
||
|
||
- [ ] **Create apps/frontend/src/api/client.ts** — typed fetch wrapper:
|
||
|
||
```typescript
|
||
import createClient from 'openapi-fetch';
|
||
import type { paths } from './types';
|
||
|
||
export const apiClient = createClient<paths>({
|
||
baseUrl: '/api/v1',
|
||
});
|
||
|
||
export type ApiResponse<T> = {
|
||
data: T;
|
||
meta: {
|
||
cachedAt: string | null;
|
||
fromCache: boolean;
|
||
};
|
||
};
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/api/
|
||
git commit -m "feat: add openapi-typescript generated types and API client"
|
||
```
|
||
|
||
### Task 4.3: Layout component
|
||
|
||
**Files:**
|
||
- Create: `apps/frontend/src/components/Layout.tsx`
|
||
|
||
- [ ] **Create Layout.tsx**
|
||
|
||
```typescript
|
||
import { Outlet, Link } from 'react-router-dom';
|
||
|
||
const headerStyle: React.CSSProperties = {
|
||
background: 'var(--color-surface)',
|
||
borderBottom: '1px solid #e0e0e0',
|
||
padding: '12px 24px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 24,
|
||
position: 'sticky',
|
||
top: 0,
|
||
zIndex: 100,
|
||
};
|
||
|
||
const mainStyle: React.CSSProperties = {
|
||
maxWidth: 1200,
|
||
margin: '0 auto',
|
||
padding: '24px 16px',
|
||
};
|
||
|
||
export function Layout() {
|
||
return (
|
||
<div>
|
||
<header style={headerStyle}>
|
||
<Link to="/" style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)' }}>
|
||
MoexVibe
|
||
</Link>
|
||
</header>
|
||
<main style={mainStyle}>
|
||
<Outlet />
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/components/Layout.tsx
|
||
git commit -m "feat: add Layout component with header"
|
||
```
|
||
|
||
### Task 4.4: Search hook + HomePage
|
||
|
||
**Files:**
|
||
- Create: `apps/frontend/src/hooks/useSearch.ts`
|
||
- Create: `apps/frontend/src/components/SearchBar.tsx`
|
||
- Create: `apps/frontend/src/components/SecurityCard.tsx`
|
||
- Create: `apps/frontend/src/pages/HomePage.tsx`
|
||
|
||
- [ ] **Create useSearch.ts**
|
||
|
||
```typescript
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { apiClient } from '../api/client';
|
||
|
||
export function useSearch(query: string) {
|
||
return useQuery({
|
||
queryKey: ['search', query],
|
||
queryFn: async () => {
|
||
const { data } = await apiClient.GET('/securities/search', {
|
||
params: { query: { q: query, limit: 20 } },
|
||
});
|
||
return data?.data ?? [];
|
||
},
|
||
enabled: query.length >= 1,
|
||
staleTime: 60_000,
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Create SearchBar.tsx**
|
||
|
||
```typescript
|
||
import { useState, useCallback } from 'react';
|
||
|
||
interface SearchBarProps {
|
||
onSearch: (query: string) => void;
|
||
}
|
||
|
||
const inputStyle: React.CSSProperties = {
|
||
width: '100%',
|
||
padding: '12px 16px',
|
||
fontSize: 16,
|
||
border: '1px solid #ddd',
|
||
borderRadius: 'var(--border-radius)',
|
||
outline: 'none',
|
||
};
|
||
|
||
export function SearchBar({ onSearch }: SearchBarProps) {
|
||
const [value, setValue] = useState('');
|
||
|
||
const handleChange = useCallback(
|
||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const v = e.target.value;
|
||
setValue(v);
|
||
onSearch(v);
|
||
},
|
||
[onSearch],
|
||
);
|
||
|
||
return (
|
||
<input
|
||
style={inputStyle}
|
||
type="text"
|
||
placeholder="Поиск по тикеру, названию или ISIN..."
|
||
value={value}
|
||
onChange={handleChange}
|
||
autoFocus
|
||
/>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Create SecurityCard.tsx**
|
||
|
||
```typescript
|
||
import { Link } from 'react-router-dom';
|
||
|
||
interface SecurityCardProps {
|
||
secid: string;
|
||
shortName: string;
|
||
type: 'share' | 'bond';
|
||
isin: string;
|
||
listLevel: number;
|
||
currency: string | null;
|
||
price: number | null;
|
||
}
|
||
|
||
const cardStyle: React.CSSProperties = {
|
||
background: 'var(--color-surface)',
|
||
borderRadius: 'var(--border-radius)',
|
||
boxShadow: 'var(--shadow)',
|
||
padding: 16,
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
};
|
||
|
||
const badgeStyle: React.CSSProperties = {
|
||
fontSize: 12,
|
||
padding: '2px 8px',
|
||
borderRadius: 4,
|
||
fontWeight: 600,
|
||
};
|
||
|
||
export function SecurityCard({ secid, shortName, type, isin, currency, price }: SecurityCardProps) {
|
||
const linkTo = type === 'share' ? `/stocks/${secid}` : `/bonds/${secid}`;
|
||
|
||
return (
|
||
<Link to={linkTo} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||
<div style={cardStyle}>
|
||
<div>
|
||
<div style={{ fontSize: 18, fontWeight: 600 }}>{secid}</div>
|
||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||
{shortName} · {isin}
|
||
</div>
|
||
</div>
|
||
<div style={{ textAlign: 'right' }}>
|
||
<span
|
||
style={{
|
||
...badgeStyle,
|
||
background: type === 'share' ? '#e3f2fd' : '#f3e5f5',
|
||
color: type === 'share' ? '#1565c0' : '#7b1fa2',
|
||
}}
|
||
>
|
||
{type === 'share' ? 'Акция' : 'Облигация'}
|
||
</span>
|
||
{price != null && (
|
||
<div style={{ marginTop: 4, fontWeight: 600 }}>
|
||
{price.toLocaleString('ru-RU')} {currency || ''}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Create HomePage.tsx**
|
||
|
||
```typescript
|
||
import { useState } from 'react';
|
||
import { SearchBar } from '../components/SearchBar';
|
||
import { SecurityCard } from '../components/SecurityCard';
|
||
import { useSearch } from '../hooks/useSearch';
|
||
|
||
export function HomePage() {
|
||
const [query, setQuery] = useState('');
|
||
const { data: results, isLoading } = useSearch(query);
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<h1 style={{ fontSize: 24, fontWeight: 700 }}>Поиск инструментов</h1>
|
||
<SearchBar onSearch={setQuery} />
|
||
|
||
{isLoading && <div>Загрузка...</div>}
|
||
|
||
{results && results.length === 0 && query.length > 0 && (
|
||
<div style={{ color: 'var(--color-text-secondary)' }}>
|
||
Ничего не найдено
|
||
</div>
|
||
)}
|
||
|
||
{results && (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{results.map((item) => (
|
||
<SecurityCard
|
||
key={item.secid}
|
||
secid={item.secid}
|
||
shortName={item.shortName}
|
||
type={item.type as 'share' | 'bond'}
|
||
isin={item.isin}
|
||
listLevel={item.listLevel}
|
||
currency={item.currency}
|
||
price={item.price}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/hooks/useSearch.ts apps/frontend/src/components/SearchBar.tsx apps/frontend/src/components/SecurityCard.tsx apps/frontend/src/pages/HomePage.tsx
|
||
git commit -m "feat: add search page with SecurityCard and SearchBar"
|
||
```
|
||
|
||
---
|
||
|
||
## SPRINT 5: Frontend Details
|
||
|
||
### Task 5.1: Stock page hook
|
||
|
||
**Files:**
|
||
- Create: `apps/frontend/src/hooks/useStock.ts`
|
||
- Create: `apps/frontend/src/hooks/useStockCandles.ts`
|
||
- Create: `apps/frontend/src/hooks/useStockDividends.ts`
|
||
|
||
- [ ] **Create useStock.ts**
|
||
|
||
```typescript
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { apiClient } from '../api/client';
|
||
|
||
export function useStock(secid: string) {
|
||
return useQuery({
|
||
queryKey: ['stock', secid],
|
||
queryFn: async () => {
|
||
const { data } = await apiClient.GET('/securities/shares/{secid}', {
|
||
params: { path: { secid } },
|
||
});
|
||
return data?.data ?? null;
|
||
},
|
||
staleTime: 900_000,
|
||
});
|
||
}
|
||
|
||
export function useStockMarketData(secid: string) {
|
||
return useQuery({
|
||
queryKey: ['stockMarketData', secid],
|
||
queryFn: async () => {
|
||
const { data } = await apiClient.GET('/securities/shares/{secid}/marketdata', {
|
||
params: { path: { secid } },
|
||
});
|
||
return data?.data ?? null;
|
||
},
|
||
staleTime: 900_000,
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Create useStockCandles.ts**
|
||
|
||
```typescript
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { apiClient } from '../api/client';
|
||
|
||
export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
|
||
return useQuery({
|
||
queryKey: ['stockCandles', secid, interval, from, till],
|
||
queryFn: async () => {
|
||
const { data } = await apiClient.GET('/securities/shares/{secid}/candles', {
|
||
params: {
|
||
path: { secid },
|
||
query: { interval, from, till },
|
||
},
|
||
});
|
||
return data?.data ?? [];
|
||
},
|
||
staleTime: 3600_000,
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Create useStockDividends.ts**
|
||
|
||
```typescript
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { apiClient } from '../api/client';
|
||
|
||
export function useStockDividends(secid: string) {
|
||
return useQuery({
|
||
queryKey: ['stockDividends', secid],
|
||
queryFn: async () => {
|
||
const { data } = await apiClient.GET('/securities/shares/{secid}/dividends', {
|
||
params: { path: { secid } },
|
||
});
|
||
return data?.data ?? [];
|
||
},
|
||
staleTime: 86400_000,
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/hooks/
|
||
git commit -m "feat: add React Query hooks for stock, candles, dividends"
|
||
```
|
||
|
||
### Task 5.2: StockDetails component
|
||
|
||
**Files:**
|
||
- Create: `apps/frontend/src/components/StockDetails.tsx`
|
||
|
||
- [ ] **Create StockDetails.tsx**
|
||
|
||
```typescript
|
||
import type { components } from '../api/types';
|
||
|
||
type Stock = components['schemas']['StockResponse']['data'];
|
||
|
||
interface StockDetailsProps {
|
||
stock: NonNullable<Stock>;
|
||
}
|
||
|
||
const rowStyle: React.CSSProperties = {
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
padding: '8px 0',
|
||
borderBottom: '1px solid #eee',
|
||
};
|
||
|
||
export function StockDetails({ stock }: StockDetailsProps) {
|
||
const { marketData } = stock;
|
||
const isPositive = marketData.change >= 0;
|
||
|
||
return (
|
||
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}>
|
||
<div style={{ marginBottom: 16 }}>
|
||
<h2 style={{ fontSize: 28, fontWeight: 700 }}>
|
||
{stock.shortName} ({stock.secid})
|
||
</h2>
|
||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||
{stock.name} · {stock.isin}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>
|
||
{marketData.price.toLocaleString('ru-RU', { minimumFractionDigits: 2 })}{' '}
|
||
<span style={{ fontSize: 18, color: isPositive ? 'var(--color-positive)' : 'var(--color-negative)' }}>
|
||
{isPositive ? '+' : ''}{marketData.change.toFixed(2)} ({marketData.changePercent.toFixed(2)}%)
|
||
</span>
|
||
</div>
|
||
|
||
<div style={{ marginTop: 16 }}>
|
||
<div style={rowStyle}>
|
||
<span>Открытие</span>
|
||
<span>{marketData.open.toFixed(2)}</span>
|
||
</div>
|
||
<div style={rowStyle}>
|
||
<span>Максимум</span>
|
||
<span>{marketData.high?.toFixed(2) ?? '—'}</span>
|
||
</div>
|
||
<div style={rowStyle}>
|
||
<span>Минимум</span>
|
||
<span>{marketData.low?.toFixed(2) ?? '—'}</span>
|
||
</div>
|
||
<div style={rowStyle}>
|
||
<span>Объём</span>
|
||
<span>{marketData.volume.toLocaleString('ru-RU')}</span>
|
||
</div>
|
||
<div style={rowStyle}>
|
||
<span>Капитализация</span>
|
||
<span>
|
||
{marketData.issueCapitalization
|
||
? (marketData.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽'
|
||
: '—'}
|
||
</span>
|
||
</div>
|
||
<div style={rowStyle}>
|
||
<span>ISIN</span>
|
||
<span>{stock.isin}</span>
|
||
</div>
|
||
<div style={rowStyle}>
|
||
<span>Уровень листинга</span>
|
||
<span>{stock.listLevel}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/components/StockDetails.tsx
|
||
git commit -m "feat: add StockDetails component"
|
||
```
|
||
|
||
### Task 5.3: Bond page hooks
|
||
|
||
**Files:**
|
||
- Create: `apps/frontend/src/hooks/useBond.ts`
|
||
- Create: `apps/frontend/src/hooks/useBondCandles.ts`
|
||
|
||
- [ ] **Create useBond.ts**
|
||
|
||
```typescript
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { apiClient } from '../api/client';
|
||
|
||
export function useBond(secid: string) {
|
||
return useQuery({
|
||
queryKey: ['bond', secid],
|
||
queryFn: async () => {
|
||
const { data, error } = await apiClient.GET('/securities/bonds/{secid}', {
|
||
params: { path: { secid } },
|
||
});
|
||
if (error) throw new Error(error.message);
|
||
return data?.data ?? null;
|
||
},
|
||
staleTime: 900_000,
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Create useBondCandles.ts**
|
||
|
||
```typescript
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { apiClient } from '../api/client';
|
||
|
||
export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) {
|
||
return useQuery({
|
||
queryKey: ['bondCandles', secid, interval, from, till],
|
||
queryFn: async () => {
|
||
const { data } = await apiClient.GET('/securities/bonds/{secid}/candles', {
|
||
params: {
|
||
path: { secid },
|
||
query: { interval, from, till },
|
||
},
|
||
});
|
||
return data?.data ?? [];
|
||
},
|
||
staleTime: 3600_000,
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/hooks/useBond.ts apps/frontend/src/hooks/useBondCandles.ts
|
||
git commit -m "feat: add React Query hooks for bond and bond candles"
|
||
```
|
||
|
||
### Task 5.4: BondDetails component
|
||
|
||
**Files:**
|
||
- Create: `apps/frontend/src/components/BondDetails.tsx`
|
||
|
||
- [ ] **Create BondDetails.tsx**
|
||
|
||
```typescript
|
||
interface BondDetailsProps {
|
||
bond: any; // Тип генерируется openapi-typescript из схемы BondResponse
|
||
}
|
||
|
||
const rowStyle: React.CSSProperties = {
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
padding: '8px 0',
|
||
borderBottom: '1px solid #eee',
|
||
};
|
||
|
||
export function BondDetails({ bond }: BondDetailsProps) {
|
||
const md = bond.marketData;
|
||
|
||
return (
|
||
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}>
|
||
<div style={{ marginBottom: 16 }}>
|
||
<h2 style={{ fontSize: 28, fontWeight: 700 }}>{bond.shortName}</h2>
|
||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||
{bond.isin}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>
|
||
{md.price.toFixed(2)}%
|
||
</div>
|
||
|
||
<div style={{ marginTop: 16 }}>
|
||
<div style={rowStyle}>
|
||
<span>Номинал</span>
|
||
<span>{bond.faceValue.toLocaleString('ru-RU')} {bond.faceUnit}</span>
|
||
</div>
|
||
<div style={rowStyle}>
|
||
<span>Дата погашения</span>
|
||
<span>{bond.matDate}</span>
|
||
</div>
|
||
<div style={rowStyle}>
|
||
<span>Купон</span>
|
||
<span>{md.couponValue} ₽ {md.couponPercent != null ? `(${md.couponPercent}%)` : ''}</span>
|
||
</div>
|
||
<div style={rowStyle}>
|
||
<span>Период купона</span>
|
||
<span>{bond.couponPeriod} дней</span>
|
||
</div>
|
||
<div style={rowStyle}>
|
||
<span>Следующий купон</span>
|
||
<span>{md.nextCouponDate ?? '—'}</span>
|
||
</div>
|
||
<div style={rowStyle}>
|
||
<span>НКД</span>
|
||
<span>{md.accruedInt.toFixed(2)} ₽</span>
|
||
</div>
|
||
<div style={rowStyle}>
|
||
<span>Доходность к погашению</span>
|
||
<span>{md.yieldToMaturity != null ? md.yieldToMaturity.toFixed(2) + '%' : '—'}</span>
|
||
</div>
|
||
<div style={rowStyle}>
|
||
<span>Дюрация</span>
|
||
<span>{md.duration != null ? md.duration.toFixed(2) : '—'}</span>
|
||
</div>
|
||
<div style={rowStyle}>
|
||
<span>Тип</span>
|
||
<span>{bond.bondType}</span>
|
||
</div>
|
||
<div style={rowStyle}>
|
||
<span>ISIN</span>
|
||
<span>{bond.isin}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/components/BondDetails.tsx
|
||
git commit -m "feat: add BondDetails component"
|
||
```
|
||
|
||
### Task 5.5: PriceChart component
|
||
|
||
**Files:**
|
||
- Create: `apps/frontend/src/components/PriceChart.tsx`
|
||
|
||
- [ ] **Create PriceChart.tsx**
|
||
|
||
```typescript
|
||
import { useEffect, useRef } from 'react';
|
||
import { createChart, ColorType, IChartApi, CandlestickData, Time } from 'lightweight-charts';
|
||
|
||
interface PriceChartProps {
|
||
data: Array<{
|
||
open: number;
|
||
high: number;
|
||
low: number;
|
||
close: number;
|
||
begin: string;
|
||
}>;
|
||
height?: number;
|
||
}
|
||
|
||
export function PriceChart({ data, height = 400 }: PriceChartProps) {
|
||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||
const chartRef = useRef<IChartApi | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!chartContainerRef.current) return;
|
||
|
||
const chart = createChart(chartContainerRef.current, {
|
||
layout: {
|
||
background: { type: ColorType.Solid, color: '#ffffff' },
|
||
textColor: '#333',
|
||
},
|
||
width: chartContainerRef.current.clientWidth,
|
||
height,
|
||
grid: {
|
||
vertLines: { color: '#f0f0f0' },
|
||
horzLines: { color: '#f0f0f0' },
|
||
},
|
||
timeScale: {
|
||
timeVisible: false,
|
||
},
|
||
});
|
||
|
||
const candleSeries = chart.addCandlestickSeries({
|
||
upColor: '#2e7d32',
|
||
downColor: '#c62828',
|
||
borderDownColor: '#c62828',
|
||
borderUpColor: '#2e7d32',
|
||
wickDownColor: '#c62828',
|
||
wickUpColor: '#2e7d32',
|
||
});
|
||
|
||
const chartData: CandlestickData[] = data.map((candle) => ({
|
||
time: (new Date(candle.begin).getTime() / 1000) as Time,
|
||
open: candle.open,
|
||
high: candle.high,
|
||
low: candle.low,
|
||
close: candle.close,
|
||
}));
|
||
|
||
candleSeries.setData(chartData);
|
||
chart.timeScale().fitContent();
|
||
chartRef.current = chart;
|
||
|
||
const handleResize = () => {
|
||
if (chartContainerRef.current) {
|
||
chart.applyOptions({ width: chartContainerRef.current.clientWidth });
|
||
}
|
||
};
|
||
window.addEventListener('resize', handleResize);
|
||
|
||
return () => {
|
||
window.removeEventListener('resize', handleResize);
|
||
chart.remove();
|
||
};
|
||
}, [data, height]);
|
||
|
||
return <div ref={chartContainerRef} />;
|
||
}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/components/PriceChart.tsx
|
||
git commit -m "feat: add PriceChart component using lightweight-charts"
|
||
```
|
||
|
||
### Task 5.6: StockPage and BondPage
|
||
|
||
**Files:**
|
||
- Modify: `apps/frontend/src/pages/StockPage.tsx`
|
||
- Modify: `apps/frontend/src/pages/BondPage.tsx`
|
||
|
||
- [ ] **Create StockPage.tsx**
|
||
|
||
```typescript
|
||
import { useParams } from 'react-router-dom';
|
||
import { useStock } from '../hooks/useStock';
|
||
import { useStockCandles } from '../hooks/useStockCandles';
|
||
import { useStockDividends } from '../hooks/useStockDividends';
|
||
import { StockDetails } from '../components/StockDetails';
|
||
import { PriceChart } from '../components/PriceChart';
|
||
|
||
export function StockPage() {
|
||
const { secid } = useParams<{ secid: string }>();
|
||
const { data: stock, isLoading, error } = useStock(secid!);
|
||
const till = new Date().toISOString().split('T')[0];
|
||
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||
const { data: candles } = useStockCandles(secid!, '24h', from, till);
|
||
const { data: dividends } = useStockDividends(secid!);
|
||
|
||
if (isLoading) return <div>Загрузка...</div>;
|
||
if (error || !stock) return <div>Инструмент не найден</div>;
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<StockDetails stock={stock} />
|
||
|
||
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}>
|
||
<h3 style={{ marginBottom: 16 }}>График цены</h3>
|
||
<PriceChart data={candles ?? []} />
|
||
</div>
|
||
|
||
{dividends && dividends.length > 0 && (
|
||
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}>
|
||
<h3 style={{ marginBottom: 16 }}>Дивиденды</h3>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||
<thead>
|
||
<tr style={{ borderBottom: '2px solid #eee' }}>
|
||
<th style={{ textAlign: 'left', padding: 8 }}>Дата закрытия реестра</th>
|
||
<th style={{ textAlign: 'right', padding: 8 }}>Сумма</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{dividends.map((d, i) => (
|
||
<tr key={i} style={{ borderBottom: '1px solid #eee' }}>
|
||
<td style={{ padding: 8 }}>{d.registryCloseDate}</td>
|
||
<td style={{ textAlign: 'right', padding: 8 }}>
|
||
{d.value.toFixed(2)} {d.currency}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Create BondPage.tsx**
|
||
|
||
```typescript
|
||
import { useParams } from 'react-router-dom';
|
||
import { useBond } from '../hooks/useBond';
|
||
import { useBondCandles } from '../hooks/useBondCandles';
|
||
import { BondDetails } from '../components/BondDetails';
|
||
import { PriceChart } from '../components/PriceChart';
|
||
|
||
export function BondPage() {
|
||
const { secid } = useParams<{ secid: string }>();
|
||
const { data: bond, isLoading, error } = useBond(secid!);
|
||
const till = new Date().toISOString().split('T')[0];
|
||
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||
const { data: candles } = useBondCandles(secid!, '24h', from, till);
|
||
|
||
if (isLoading) return <div>Загрузка...</div>;
|
||
if (error || !bond) return <div>Инструмент не найден</div>;
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<BondDetails bond={bond} />
|
||
|
||
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}>
|
||
<h3 style={{ marginBottom: 16 }}>График цены</h3>
|
||
<PriceChart data={candles ?? []} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add apps/frontend/src/pages/
|
||
git commit -m "feat: add StockPage and BondPage with charts and details"
|
||
```
|
||
|
||
---
|
||
|
||
## SPRINT 6: Documentation + Infrastructure
|
||
|
||
### Task 6.1: OpenAPI spec finalization
|
||
|
||
- [ ] **Verify OpenAPI spec** — start backend, check Swagger UI at `/api/docs`. Ensure all endpoints, schemas, and examples are present.
|
||
|
||
- [ ] **Sync `docs/openapi/openapi.yaml`** with the generated spec if any changes were made during development.
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add docs/openapi/openapi.yaml
|
||
git commit -m "docs: finalize OpenAPI specification"
|
||
```
|
||
|
||
### Task 6.2: ADR documentation
|
||
|
||
- [ ] **Ensure all ADR files exist** in `docs/architecture/adr/` (created during design phase, adjust if needed).
|
||
|
||
- [ ] **Add architecture overview diagram** (ASCII sequence diagram or Mermaid):
|
||
|
||
```markdown
|
||
# Architecture Overview
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant User
|
||
participant Frontend as React SPA
|
||
participant Backend as NestJS API
|
||
participant Cache as In-Memory Cache
|
||
participant MOEX as MOEX ISS
|
||
|
||
User->>Frontend: Search / View instrument
|
||
Frontend->>Backend: GET /api/v1/securities/search?q=SBER
|
||
Backend->>Cache: getOrFetch('search:sber')
|
||
alt Cache miss
|
||
Cache->>Backend: null
|
||
Backend->>MOEX: GET /iss/securities?q=SBER
|
||
MOEX-->>Backend: raw data
|
||
Backend->>Cache: set('search:sber', normalized, TTL=3600)
|
||
else Cache hit
|
||
Cache-->>Backend: cached data
|
||
end
|
||
Backend-->>Frontend: normalized response
|
||
Frontend-->>User: rendered UI
|
||
```
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add docs/
|
||
git commit -m "docs: add ADR documents and architecture diagrams"
|
||
```
|
||
|
||
### Task 6.3: Docker setup
|
||
|
||
**Files:**
|
||
- Create: `docker/Dockerfile.backend`
|
||
- Create: `docker/Dockerfile.frontend`
|
||
- Create: `docker/nginx.conf`
|
||
- Create: `docker-compose.yml`
|
||
|
||
- [ ] **Create Dockerfile.backend**
|
||
|
||
```dockerfile
|
||
FROM node:20-alpine AS build
|
||
WORKDIR /app
|
||
COPY apps/backend/package.json ./
|
||
RUN npm install
|
||
COPY apps/backend/ ./
|
||
RUN npm run build
|
||
|
||
FROM node:20-alpine AS production
|
||
WORKDIR /app
|
||
COPY --from=build /app/dist ./dist
|
||
COPY --from=build /app/node_modules ./node_modules
|
||
COPY apps/backend/package.json ./
|
||
EXPOSE 3000
|
||
CMD ["node", "dist/main.js"]
|
||
```
|
||
|
||
- [ ] **Create Dockerfile.frontend**
|
||
|
||
```dockerfile
|
||
FROM node:20-alpine AS build
|
||
WORKDIR /app
|
||
COPY apps/frontend/package.json ./
|
||
RUN npm install
|
||
COPY apps/frontend/ ./
|
||
RUN npm run build
|
||
|
||
FROM nginx:alpine
|
||
COPY --from=build /app/dist /usr/share/nginx/html
|
||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||
EXPOSE 80
|
||
CMD ["nginx", "-g", "daemon off;"]
|
||
```
|
||
|
||
- [ ] **Create nginx.conf**
|
||
|
||
```nginx
|
||
server {
|
||
listen 80;
|
||
root /usr/share/nginx/html;
|
||
index index.html;
|
||
|
||
location /api/ {
|
||
proxy_pass http://backend:3000;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
}
|
||
|
||
location / {
|
||
try_files $uri $uri/ /index.html;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Create docker-compose.yml**
|
||
|
||
```yaml
|
||
services:
|
||
backend:
|
||
build:
|
||
context: .
|
||
dockerfile: docker/Dockerfile.backend
|
||
ports:
|
||
- "3000:3000"
|
||
environment:
|
||
- PORT=3000
|
||
- MOEX_BASE_URL=https://iss.moex.com/iss
|
||
- MOEX_RATE_LIMIT=10
|
||
|
||
frontend:
|
||
build:
|
||
context: .
|
||
dockerfile: docker/Dockerfile.frontend
|
||
ports:
|
||
- "80:80"
|
||
depends_on:
|
||
- backend
|
||
```
|
||
|
||
- [ ] **Commit**
|
||
|
||
```bash
|
||
git add docker/ docker-compose.yml
|
||
git commit -m "infra: add Docker setup with docker-compose"
|
||
```
|
||
|
||
### Task 6.4: README and final checks
|
||
|
||
- [ ] **Create README.md** with:
|
||
- Project overview
|
||
- Tech stack
|
||
- Quick start (npm install, npm run dev:backend, npm run dev:frontend)
|
||
- Docker instructions
|
||
- Links to docs
|
||
|
||
- [ ] **Run full test suite**:
|
||
|
||
```bash
|
||
npm run test:backend
|
||
```
|
||
|
||
- [ ] **Verify type generation**:
|
||
|
||
```bash
|
||
cd apps/frontend && npx openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts
|
||
```
|
||
|
||
- [ ] **Final commit**
|
||
|
||
```bash
|
||
git add README.md
|
||
git commit -m "chore: add README with quick start instructions"
|
||
```
|
||
|
||
---
|