diff --git a/AGENTS.md b/AGENTS.md index f659aa5..f63193d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Репозиторий -npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/frontend` (React + Vite). +npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/frontend` (React + Vite), `apps/docs` (Docusaurus). ## Обязательный подход к разработке @@ -16,13 +16,18 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr |---|---| | `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 | | `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 | +| `npm run dev:docs` | Docusaurus dev-сервер | | `npm run build:backend` | `nest build` | | `npm run build:frontend` | `tsc -b && vite build` (в две фазы) | +| `npm run build:docs` | `docusaurus build` | | `npm run test:backend` | `vitest run` (SWC, не ts-jest) | -| `npm run lint` | ESLint только для бэкенда | +| `npm run test:frontend` | Frontend Vitest suite | +| `npm run lint` | ESLint для backend и frontend | | `npm run format` | Prettier для всех `*.{ts,tsx}` | | `npm run codegen -w apps/frontend` | `openapi-typescript` из локального Swagger → `src/api/types.ts` | +Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`. + Один тест: `npx vitest run path/to/test.spec.ts -w apps/backend` ## Переменные окружения @@ -46,7 +51,7 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr ## Архитектура - **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую. -- Feature-модули: `PrismaModule` (глобальный), `MoexClientModule` (глобальный), `CacheModule` (глобальный), `AuthModule`, `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `HealthModule`. +- Feature-модули: `PrismaModule` (глобальный), `MoexClientModule` (глобальный), `CacheModule` (глобальный), `AuthModule`, `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `PortfolioModule`, `HealthModule`. - `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт). - In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002). - Аутентификация: JWT access token (15m, в памяти) + refresh token (7d, httpOnly cookie, bcrypt hash в БД). Глобальный `JwtAuthGuard` (`@Public()` для открытых эндпоинтов). @@ -70,5 +75,6 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr - Prettier: одинарные кавычки, trailing commas, printWidth 100, точки с запятой. - Бэкенд: `const`, PascalCase для модулей/контроллеров/сервисов, DTO в `dto/` внутри каждого модуля. - Бэкенд использует SWC через `unplugin-swc` (vitest config). -- Тесты фронтенда отсутствуют. -- CI/CD в репозитории нет. +- Тесты фронтенда есть: Vitest + Testing Library + MSW. +- CI находится в `.gitea/workflows/ci.yml`. +- Pre-commit checks настроены через Husky и lint-staged. diff --git a/README.md b/README.md index 9663633..c4618bf 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ - **Backend:** NestJS, TypeScript, OpenAPI (Swagger) - **Frontend:** React, TypeScript, Vite, TanStack Query, lightweight-charts +- **Docs:** Docusaurus - **Infrastructure:** Docker, docker-compose ## Quick Start @@ -36,6 +37,13 @@ docker compose up --build ```bash npm run test:backend +npm run test:frontend +``` + +Live MOEX integration checks are opt-in: + +```bash +npm run test:integration -w apps/backend ``` ## Project Structure @@ -44,6 +52,7 @@ npm run test:backend apps/ backend/ — NestJS API (single point of access to MOEX ISS) frontend/ — React SPA with Vite + docs/ — Docusaurus documentation site docs/ architecture/ — ADR documents and diagrams openapi/ — OpenAPI specification diff --git a/apps/backend/package.json b/apps/backend/package.json index 1d8a57e..c194228 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -8,8 +8,9 @@ "start:dev": "nest start --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,test}/**/*.ts\"", - "test": "VITE_CJS_IGNORE_WARNING=1 vitest run", - "test:watch": "vitest" + "test": "VITE_CJS_IGNORE_WARNING=1 vitest run --exclude \"src/**/*.integration.spec.ts\"", + "test:watch": "vitest --exclude \"src/**/*.integration.spec.ts\"", + "test:integration": "MOEX_LIVE_TESTS=1 VITE_CJS_IGNORE_WARNING=1 vitest run \"src/**/*.integration.spec.ts\"" }, "dependencies": { "@libsql/client": "^0.17.3", diff --git a/apps/backend/src/modules/auth/auth.controller.ts b/apps/backend/src/modules/auth/auth.controller.ts index 68c58bf..d933b34 100644 --- a/apps/backend/src/modules/auth/auth.controller.ts +++ b/apps/backend/src/modules/auth/auth.controller.ts @@ -1,10 +1,21 @@ import { Controller, Post, Get, Patch, Body, Req, Res, HttpCode, HttpStatus } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiCreatedResponse, + ApiOkResponse, +} from '@nestjs/swagger'; import { Request, Response } from 'express'; import { AuthService } from './auth.service'; import { RegisterDto } from './dto/register.dto'; import { LoginDto } from './dto/login.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; +import { + AuthLogoutResponseDto, + AuthProfileResponseDto, + AuthTokenResponseDto, +} from './dto/auth-response.dto'; import { CurrentUser } from './decorators/current-user.decorator'; import { Public } from './decorators/public.decorator'; import { JwtPayload } from './interfaces/jwt-payload.interface'; @@ -26,6 +37,7 @@ export class AuthController { @Public() @Post('register') @ApiOperation({ summary: 'Register new user' }) + @ApiCreatedResponse({ type: AuthTokenResponseDto }) async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) { const result = await this.authService.register(dto); res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); @@ -41,6 +53,7 @@ export class AuthController { @Public() @Post('login') @ApiOperation({ summary: 'Login with email and password' }) + @ApiCreatedResponse({ type: AuthTokenResponseDto }) async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) { const result = await this.authService.login(dto); res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); @@ -57,6 +70,7 @@ export class AuthController { @Post('refresh') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: 'Refresh access token' }) + @ApiOkResponse({ type: AuthTokenResponseDto }) async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) { const token = req.cookies?.[REFRESH_COOKIE]; const result = await this.authService.refresh(token); @@ -74,6 +88,7 @@ export class AuthController { @HttpCode(HttpStatus.OK) @ApiBearerAuth() @ApiOperation({ summary: 'Logout user' }) + @ApiOkResponse({ type: AuthLogoutResponseDto }) async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) { await this.authService.logout(user.sub); res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' }); @@ -86,6 +101,7 @@ export class AuthController { @Get('me') @ApiBearerAuth() @ApiOperation({ summary: 'Get current user profile' }) + @ApiOkResponse({ type: AuthProfileResponseDto }) async getProfile(@CurrentUser() user: JwtPayload) { const profile = await this.authService.getProfile(user.sub); return { @@ -97,6 +113,7 @@ export class AuthController { @Patch('me') @ApiBearerAuth() @ApiOperation({ summary: 'Update current user profile' }) + @ApiOkResponse({ type: AuthProfileResponseDto }) async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) { const profile = await this.authService.updateProfile(user.sub, dto); return { diff --git a/apps/backend/src/modules/auth/dto/auth-response.dto.ts b/apps/backend/src/modules/auth/dto/auth-response.dto.ts new file mode 100644 index 0000000..da8885d --- /dev/null +++ b/apps/backend/src/modules/auth/dto/auth-response.dto.ts @@ -0,0 +1,60 @@ +import { ApiProperty } from '@nestjs/swagger'; + +class AuthResponseMetaDto { + @ApiProperty({ type: String, nullable: true }) + cachedAt!: string | null; + + @ApiProperty() + fromCache!: boolean; +} + +class AuthUserDto { + @ApiProperty() + id!: number; + + @ApiProperty() + email!: string; + + @ApiProperty({ type: String, nullable: true }) + name!: string | null; + + @ApiProperty() + role!: string; +} + +class AuthTokenDataDto { + @ApiProperty({ type: AuthUserDto }) + user!: AuthUserDto; + + @ApiProperty() + accessToken!: string; +} + +class LogoutDataDto { + @ApiProperty() + message!: string; +} + +export class AuthTokenResponseDto { + @ApiProperty({ type: AuthTokenDataDto }) + data!: AuthTokenDataDto; + + @ApiProperty({ type: AuthResponseMetaDto }) + meta!: AuthResponseMetaDto; +} + +export class AuthProfileResponseDto { + @ApiProperty({ type: AuthUserDto }) + data!: AuthUserDto; + + @ApiProperty({ type: AuthResponseMetaDto }) + meta!: AuthResponseMetaDto; +} + +export class AuthLogoutResponseDto { + @ApiProperty({ type: LogoutDataDto }) + data!: LogoutDataDto; + + @ApiProperty({ type: AuthResponseMetaDto }) + meta!: AuthResponseMetaDto; +} diff --git a/apps/backend/src/modules/bonds/bonds.service.spec.ts b/apps/backend/src/modules/bonds/bonds.service.spec.ts index 903de5c..532214e 100644 --- a/apps/backend/src/modules/bonds/bonds.service.spec.ts +++ b/apps/backend/src/modules/bonds/bonds.service.spec.ts @@ -1,41 +1,147 @@ +import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigModule } from '@nestjs/config'; import { BondsService } from './bonds.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; -import configuration from '../../config/configuration'; describe('BondsService', () => { let service: BondsService; + let moexClient: Pick; + let cache: Pick; beforeEach(async () => { + moexClient = { + getBondData: vi.fn(), + getBondMarketData: vi.fn(), + }; + cache = { + getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: '2026-06-15T00:00:00.000Z', + })), + }; + const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigModule.forRoot({ load: [configuration] })], providers: [ BondsService, - MoexClientService, - { - provide: 'CACHE_MANAGER', - useValue: { - get: () => undefined, - set: () => Promise.resolve(), - del: () => Promise.resolve(), - }, - }, - CacheService, + { provide: MoexClientService, useValue: moexClient }, + { provide: CacheService, useValue: cache }, ], }).compile(); service = module.get(BondsService); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => { + vi.mocked(moexClient.getBondData).mockResolvedValue({ + secid: 'SU26238RMFS5', + boardid: 'TQCB', + shortName: 'ОФЗ 26238', + prevWaprice: 73.2, + yieldAtPrevWaprice: 14.1, + couponValue: 35.4, + nextCoupon: '2026-06-24', + accruedInt: 34.1, + prevPrice: 73, + lotSize: 1, + faceValue: 1000, + matDate: '2041-05-15', + couponPeriod: 182, + issueSize: 150000000, + isin: 'RU000A1038V6', + couponPercent: 7.1, + offerDate: null, + buybackDate: null, + bondType: 'ofz', + bondSubType: 'fixed', + listLevel: 1, + }); + vi.mocked(moexClient.getBondMarketData).mockResolvedValue({ + secid: 'SU26238RMFS5', + bid: 72.9, + offer: 73.1, + open: 72.8, + low: 72.5, + high: 73.4, + last: 73.05, + yield: 14.2, + waprice: 73, + yieldAtWaprice: 14.15, + duration: 2250, + volume: 10000, + value: 7305000, + numtrades: 450, + tradingStatus: 'T', + updateTime: '18:45:00', + }); + + const result = await service.getBond('SU26238RMFS5'); + + expect(cache.getOrFetch).toHaveBeenNthCalledWith( + 1, + 'bond', + ['SU26238RMFS5'], + expect.any(Function), + 'securityTtl', + ); + expect(cache.getOrFetch).toHaveBeenNthCalledWith( + 2, + 'marketdata', + ['bonds', 'SU26238RMFS5'], + expect.any(Function), + 'marketDataTtl', + ); + expect(moexClient.getBondData).toHaveBeenCalledWith('SU26238RMFS5'); + expect(moexClient.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5'); + expect(result).toMatchObject({ + data: { + secid: 'SU26238RMFS5', + isin: 'RU000A1038V6', + name: 'ОФЗ 26238', + shortName: 'ОФЗ 26238', + latName: null, + listLevel: 1, + issueSize: 150000000, + faceValue: 1000, + faceUnit: 'RUB', + matDate: '2041-05-15', + couponValue: 35.4, + couponPercent: 7.1, + couponPeriod: 182, + nextCoupon: '2026-06-24', + accruedInt: 34.1, + bondType: 'ofz', + bondSubType: 'fixed', + offerDate: null, + buybackDate: null, + marketData: { + price: 73.05, + yieldToMaturity: 14.2, + duration: 2250, + accruedInt: 34.1, + couponValue: 35.4, + couponPercent: 7.1, + nextCouponDate: '2026-06-24', + open: 72.8, + high: 73.4, + low: 72.5, + volume: 10000, + }, + }, + meta: { + fromCache: false, + cachedAt: '2026-06-15T00:00:00.000Z', + }, + }); + expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/); }); - it('should return OFZ bond data for SU26207RMFS9', async () => { - const result = await service.getBond('SU26207RMFS9'); - expect(result.data.secid).toBe('SU26207RMFS9'); - expect(result.data.marketData).toBeDefined(); - }, 15000); + it('throws NotFoundException when bond data is missing', async () => { + vi.mocked(moexClient.getBondData).mockResolvedValue(null); + + await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(NotFoundException); + expect(cache.getOrFetch).toHaveBeenCalledTimes(1); + expect(moexClient.getBondMarketData).not.toHaveBeenCalled(); + }); }); diff --git a/apps/backend/src/modules/candles/candles.service.spec.ts b/apps/backend/src/modules/candles/candles.service.spec.ts index fd37b24..d97ce7e 100644 --- a/apps/backend/src/modules/candles/candles.service.spec.ts +++ b/apps/backend/src/modules/candles/candles.service.spec.ts @@ -1,40 +1,51 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigModule } from '@nestjs/config'; import { CandlesService } from './candles.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; -import configuration from '../../config/configuration'; import { CandleInterval } from './dto/candles-query.dto'; describe('CandlesService', () => { let service: CandlesService; + let moexClient: Pick; + let cache: Pick; beforeEach(async () => { + moexClient = { + getCandles: vi.fn(), + }; + cache = { + getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: '2026-06-15T00:00:00.000Z', + })), + }; + const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigModule.forRoot({ load: [configuration] })], providers: [ CandlesService, - MoexClientService, - { - provide: 'CACHE_MANAGER', - useValue: { - get: () => undefined, - set: () => Promise.resolve(), - del: () => Promise.resolve(), - }, - }, - CacheService, + { provide: MoexClientService, useValue: moexClient }, + { provide: CacheService, useValue: cache }, ], }).compile(); service = module.get(CandlesService); }); - it('should be defined', () => { - expect(service).toBeDefined(); - }); + it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => { + vi.mocked(moexClient.getCandles).mockResolvedValue([ + { + open: 320, + high: 325, + low: 318, + close: 323, + volume: 1500000, + value: 480000000, + begin: '2026-05-01 00:00:00', + end: '2026-05-01 23:59:59', + }, + ]); - it('should return daily candles for SBER', async () => { const result = await service.getCandles( 'shares', 'SBER', @@ -42,7 +53,65 @@ describe('CandlesService', () => { '2026-05-01', '2026-06-01', ); - expect(result.data.length).toBeGreaterThan(0); - expect(result.data[0].open).toBeDefined(); - }, 15000); + + expect(cache.getOrFetch).toHaveBeenCalledWith( + 'candles', + ['shares', 'SBER', '24', '2026-05-01', '2026-06-01'], + expect.any(Function), + 'candlesTtl', + ); + expect(moexClient.getCandles).toHaveBeenCalledWith( + 'stock', + 'shares', + 'SBER', + 24, + '2026-05-01', + '2026-06-01', + ); + expect(result).toEqual({ + data: [ + { + open: 320, + high: 325, + low: 318, + close: 323, + volume: 1500000, + value: 480000000, + begin: '2026-05-01 00:00:00', + end: '2026-05-01 23:59:59', + }, + ], + meta: { + fromCache: false, + cachedAt: '2026-06-15T00:00:00.000Z', + }, + }); + }); + + it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => { + vi.mocked(moexClient.getCandles).mockResolvedValue([]); + + await service.getCandles( + 'bonds', + 'SU26238RMFS5', + CandleInterval.HOUR, + '2026-05-01', + '2026-06-01', + ); + + expect(cache.getOrFetch).toHaveBeenCalledWith( + 'candles', + ['bonds', 'SU26238RMFS5', '60', '2026-05-01', '2026-06-01'], + expect.any(Function), + 'candlesTtl', + ); + expect(moexClient.getCandles).toHaveBeenCalledWith( + 'stock', + 'bonds', + 'SU26238RMFS5', + 60, + '2026-05-01', + '2026-06-01', + ); + }); }); diff --git a/apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts b/apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts new file mode 100644 index 0000000..ff2ae26 --- /dev/null +++ b/apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts @@ -0,0 +1,35 @@ +import 'reflect-metadata'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { MoexClientService } from './moex-client.service'; +import configuration from '../../config/configuration'; + +describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')( + 'MoexClientService live MOEX integration', + () => { + let service: MoexClientService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ load: [configuration] })], + providers: [MoexClientService], + }).compile(); + + service = module.get(MoexClientService); + }); + + it('возвращает результаты поиска для SBER из live MOEX', async () => { + const results = await service.searchSecurities('SBER'); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].secid).toBeDefined(); + }, 15000); + + it('возвращает рыночные данные SBER из live MOEX', async () => { + const data = await service.getShareMarketData('SBER'); + + expect(data).toBeDefined(); + expect(data!.secid).toBe('SBER'); + }, 15000); + }, +); diff --git a/apps/backend/src/modules/moex-client/moex-client.service.spec.ts b/apps/backend/src/modules/moex-client/moex-client.service.spec.ts index 5921972..3b13bb7 100644 --- a/apps/backend/src/modules/moex-client/moex-client.service.spec.ts +++ b/apps/backend/src/modules/moex-client/moex-client.service.spec.ts @@ -1,38 +1,186 @@ import 'reflect-metadata'; -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigModule } from '@nestjs/config'; +import axios from 'axios'; +import { ConfigService } from '@nestjs/config'; import { MoexClientService } from './moex-client.service'; -import configuration from '../../config/configuration'; + +vi.mock('axios', () => ({ + default: { + create: vi.fn(), + }, +})); describe('MoexClientService', () => { let service: MoexClientService; + let getMock: ReturnType; - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigModule.forRoot({ load: [configuration] })], - providers: [MoexClientService], - }).compile(); + beforeEach(() => { + getMock = vi.fn(); + vi.mocked(axios.create).mockReturnValue({ get: getMock } as never); - service = module.get(MoexClientService); + service = new MoexClientService({ + get: vi.fn((key: string, fallback?: unknown) => { + const values: Record = { + 'app.moex.baseUrl': 'https://iss.moex.test/iss', + 'app.moex.circuitBreakerThreshold': 5, + 'app.moex.circuitBreakerResetSeconds': 30, + 'app.moex.rateLimit': 10, + }; + return values[key] ?? fallback; + }), + } as unknown as ConfigService); }); - it('should be defined', () => { + it('создаётся с настроенным MOEX client', () => { expect(service).toBeDefined(); + expect(axios.create).toHaveBeenCalledWith({ + baseURL: 'https://iss.moex.test/iss', + timeout: 10000, + paramsSerializer: { indexes: null }, + }); }); - describe('searchSecurities', () => { - it('should return results for SBER query', async () => { - const results = await service.searchSecurities('SBER'); - expect(results.length).toBeGreaterThan(0); - expect(results[0].secid).toBeDefined(); - }, 15000); + it('нормализует результаты поиска из ISS table format', async () => { + getMock.mockResolvedValueOnce({ + data: { + securities: { + columns: [ + 'secid', + 'isin', + 'name', + 'shortName', + 'latName', + 'listLevel', + 'issuesize', + 'facevalue', + 'faceunit', + 'issuedate', + 'typename', + 'group', + 'type', + 'isqualifiedinvestors', + 'morningsession', + 'eveningsession', + ], + data: [ + [ + 'SBER', + 'RU0009029540', + 'Сбербанк России ПАО ао', + 'Сбербанк', + 'Sberbank', + '1', + '21586948000', + '3', + 'SUR', + '2007-07-20', + 'Акция обыкновенная', + 'stock_shares', + 'common_share', + '0', + '1', + '1', + ], + ], + }, + }, + }); + + const results = await service.searchSecurities('SBER'); + + expect(getMock).toHaveBeenCalledWith('/securities.json', { + params: { q: 'SBER', 'iss.meta': 'off' }, + }); + expect(results).toEqual([ + { + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбербанк России ПАО ао', + shortName: 'Сбербанк', + latName: 'Sberbank', + listLevel: 1, + issueSize: 21586948000, + faceValue: 3, + faceUnit: 'SUR', + issueDate: '2007-07-20', + typeName: 'Акция обыкновенная', + group: 'stock_shares', + type: 'common_share', + isQualifiedInvestors: false, + morningSession: true, + eveningSession: true, + }, + ]); }); - 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); + it('нормализует market data акции без live MOEX запроса', async () => { + getMock.mockResolvedValueOnce({ + data: { + securities: { + columns: ['SECID', 'BOARDID', 'SHORTNAME', 'PREVPRICE'], + data: [['SBER', 'TQBR', 'Сбербанк', '320.10']], + }, + marketdata: { + columns: [ + 'SECID', + 'BOARDID', + 'BID', + 'OFFER', + 'OPEN', + 'LOW', + 'HIGH', + 'LAST', + 'LASTCHANGE', + 'LASTCHANGEPRCNT', + 'VOLTODAY', + 'VALTODAY', + 'WAPRICE', + 'NUMTRADES', + 'ISSUECAPITALIZATION', + 'TRADINGSTATUS', + 'UPDATETIME', + ], + data: [ + [ + 'SBER', + 'TQBR', + '321', + '322', + '320', + '319', + '323', + '322.35', + '1.15', + '0.36', + '1925163', + '620184479', + '321.9', + '12345', + '6958336818320', + 'T', + '10:30:00', + ], + ], + }, + }, + }); + + const data = await service.getShareMarketData('SBER'); + + expect(getMock).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER.json', { + params: { boards: 'TQBR', 'iss.meta': 'off' }, + }); + expect(data).toMatchObject({ + secid: 'SBER', + boardid: 'TQBR', + shortName: 'Сбербанк', + last: 322.35, + lastChange: 1.15, + lastChangePrcnt: 0.36, + volume: 1925163, + value: 620184479, + issueCapitalization: 6958336818320, + tradingStatus: 'T', + updateTime: '10:30:00', + }); }); }); diff --git a/apps/backend/src/modules/portfolio/dto/add-position.dto.ts b/apps/backend/src/modules/portfolio/dto/add-position.dto.ts index 5f8649c..7ed69b3 100644 --- a/apps/backend/src/modules/portfolio/dto/add-position.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/add-position.dto.ts @@ -51,7 +51,7 @@ export class AddPositionDto { @MaxLength(500) notes?: string; - @ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS }) + @ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS, isArray: true }) @IsArray() @IsIn(TAGS, { each: true }) @IsOptional() diff --git a/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts b/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts index 079eba0..059b678 100644 --- a/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts @@ -1,19 +1,19 @@ import { ApiProperty } from '@nestjs/swagger'; -import { EnrichedPosition } from '../portfolio.service'; +import { PositionWithPriceDto } from './position-with-price.dto'; export class PortfolioSummaryDto { @ApiProperty() totalInvested!: number; @ApiProperty() totalValue!: number; @ApiProperty() totalPnl!: number; - @ApiProperty() totalPnlPercent!: number | null; + @ApiProperty({ type: Number, nullable: true }) totalPnlPercent!: number | null; @ApiProperty() totalDividends!: number; @ApiProperty() totalReturn!: number; - @ApiProperty() totalReturnPercent!: number | null; + @ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null; @ApiProperty() positionCount!: number; - @ApiProperty() weightedYield!: number | null; + @ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null; } export class AnalyticsResponseDto { - @ApiProperty({ type: [Object] }) positions!: EnrichedPosition[]; + @ApiProperty({ type: [PositionWithPriceDto] }) positions!: PositionWithPriceDto[]; @ApiProperty() summary!: PortfolioSummaryDto; } diff --git a/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts b/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts new file mode 100644 index 0000000..37ae555 --- /dev/null +++ b/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { AnalyticsResponseDto } from './analytics-response.dto'; +import { PortfolioListResponseDto } from './portfolio-list-response.dto'; +import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto'; +import { PositionResponseDto } from './position-response.dto'; + +export class PortfolioResponseMetaDto { + @ApiProperty({ type: String, nullable: true }) + cachedAt!: string | null; + + @ApiProperty() + fromCache!: boolean; +} + +export class PortfolioListEnvelopeDto { + @ApiProperty({ type: [PortfolioListResponseDto] }) + data!: PortfolioListResponseDto[]; + + @ApiProperty({ type: PortfolioResponseMetaDto }) + meta!: PortfolioResponseMetaDto; +} + +export class PortfolioEnvelopeDto { + @ApiProperty({ type: PortfolioResponseDto }) + data!: PortfolioResponseDto; + + @ApiProperty({ type: PortfolioResponseMetaDto }) + meta!: PortfolioResponseMetaDto; +} + +export class PortfolioDetailEnvelopeDto { + @ApiProperty({ type: PortfolioDetailResponseDto }) + data!: PortfolioDetailResponseDto; + + @ApiProperty({ type: PortfolioResponseMetaDto }) + meta!: PortfolioResponseMetaDto; +} + +export class PositionEnvelopeDto { + @ApiProperty({ type: PositionResponseDto }) + data!: PositionResponseDto; + + @ApiProperty({ type: PortfolioResponseMetaDto }) + meta!: PortfolioResponseMetaDto; +} + +export class AnalyticsEnvelopeDto { + @ApiProperty({ type: AnalyticsResponseDto }) + data!: AnalyticsResponseDto; + + @ApiProperty({ type: PortfolioResponseMetaDto }) + meta!: PortfolioResponseMetaDto; +} diff --git a/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts b/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts index 3d25f11..1b3d02d 100644 --- a/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts @@ -1,45 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { PortfolioSummaryDto } from './analytics-response.dto'; - -class PositionWithPriceDto { - @ApiProperty() id!: number; - @ApiProperty({ example: 'SBER' }) secid!: string; - @ApiPropertyOptional() shortName!: string | null; - @ApiProperty({ example: 'share', enum: ['share', 'bond'] }) type!: string; - @ApiProperty({ example: 10 }) quantity!: number; - @ApiPropertyOptional() buyPrice!: number | null; - @ApiPropertyOptional() buyDate!: string | null; - @ApiPropertyOptional() notes!: string | null; - @ApiPropertyOptional() tags!: string[] | null; - @ApiPropertyOptional() currentPrice!: number | null; - @ApiPropertyOptional() totalCost!: number | null; - @ApiPropertyOptional() currentValue!: number | null; - @ApiProperty() weightPercent!: number; - @ApiPropertyOptional() pnl!: number | null; - @ApiPropertyOptional() pnlPercent!: number | null; - @ApiPropertyOptional() dividendIncome!: number | null; - @ApiPropertyOptional() totalReturn!: number | null; - @ApiPropertyOptional() totalReturnPercent!: number | null; - @ApiPropertyOptional() change!: number | null; - @ApiPropertyOptional() changePercent!: number | null; - @ApiPropertyOptional() yieldToMaturity!: number | null; - @ApiPropertyOptional() duration!: number | null; - @ApiPropertyOptional() couponValue!: number | null; - @ApiPropertyOptional() couponPercent!: number | null; - @ApiPropertyOptional() nextCouponDate!: string | null; - @ApiPropertyOptional() matDate!: string | null; - @ApiPropertyOptional() accruedInt!: number | null; - @ApiPropertyOptional() bid!: number | null; - @ApiPropertyOptional() offer!: number | null; - @ApiPropertyOptional() couponPeriod!: number | null; - @ApiPropertyOptional() bondType!: string | null; - @ApiPropertyOptional() offerDate!: string | null; -} +import { PositionWithPriceDto } from './position-with-price.dto'; export class PortfolioResponseDto { @ApiProperty() id!: number; @ApiProperty() name!: string; - @ApiPropertyOptional() description!: string | null; + @ApiPropertyOptional({ type: String, nullable: true }) description!: string | null; @ApiProperty({ default: 'RUB' }) currency!: string; @ApiProperty() createdAt!: string; @ApiProperty() updatedAt!: string; diff --git a/apps/backend/src/modules/portfolio/dto/position-response.dto.ts b/apps/backend/src/modules/portfolio/dto/position-response.dto.ts index 7064ea1..a76bd9a 100644 --- a/apps/backend/src/modules/portfolio/dto/position-response.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/position-response.dto.ts @@ -4,8 +4,8 @@ export class PositionResponseDto { @ApiProperty() id!: number; @ApiProperty({ example: 'SBER' }) secid!: string; @ApiProperty({ example: 10 }) quantity!: number; - @ApiPropertyOptional() notes!: string | null; - @ApiPropertyOptional() tags!: string[] | null; + @ApiPropertyOptional({ type: String, nullable: true }) notes!: string | null; + @ApiPropertyOptional({ type: String, isArray: true, nullable: true }) tags!: string[] | null; @ApiProperty() portfolioId!: number; @ApiProperty() createdAt!: string; @ApiProperty() updatedAt!: string; diff --git a/apps/backend/src/modules/portfolio/dto/position-with-price.dto.ts b/apps/backend/src/modules/portfolio/dto/position-with-price.dto.ts new file mode 100644 index 0000000..edff465 --- /dev/null +++ b/apps/backend/src/modules/portfolio/dto/position-with-price.dto.ts @@ -0,0 +1,99 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class PositionWithPriceDto { + @ApiProperty() + id!: number; + + @ApiProperty({ example: 'SBER' }) + secid!: string; + + @ApiPropertyOptional({ type: String, nullable: true }) + shortName!: string | null; + + @ApiProperty({ example: 'share', enum: ['share', 'bond'] }) + type!: string; + + @ApiProperty({ example: 10 }) + quantity!: number; + + @ApiPropertyOptional({ type: Number, nullable: true }) + buyPrice!: number | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + buyDate!: string | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + notes!: string | null; + + @ApiPropertyOptional({ type: String, isArray: true, nullable: true }) + tags!: string[] | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + currentPrice!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + totalCost!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + currentValue!: number | null; + + @ApiProperty() + weightPercent!: number; + + @ApiPropertyOptional({ type: Number, nullable: true }) + pnl!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + pnlPercent!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + dividendIncome!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + totalReturn!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + totalReturnPercent!: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + change?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + changePercent?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + yieldToMaturity?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + duration?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + couponValue?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + couponPercent?: number | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + nextCouponDate?: string | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + matDate?: string | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + accruedInt?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + bid?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + offer?: number | null; + + @ApiPropertyOptional({ type: Number, nullable: true }) + couponPeriod?: number | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + bondType?: string | null; + + @ApiPropertyOptional({ type: String, nullable: true }) + offerDate?: string | null; +} diff --git a/apps/backend/src/modules/portfolio/dto/update-position.dto.ts b/apps/backend/src/modules/portfolio/dto/update-position.dto.ts index e168843..2aa0cd7 100644 --- a/apps/backend/src/modules/portfolio/dto/update-position.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/update-position.dto.ts @@ -45,7 +45,7 @@ export class UpdatePositionDto { @MaxLength(500) notes?: string; - @ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS }) + @ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS, isArray: true }) @IsArray() @IsIn(TAGS, { each: true }) @IsOptional() diff --git a/apps/backend/src/modules/portfolio/portfolio.controller.ts b/apps/backend/src/modules/portfolio/portfolio.controller.ts index 79dd141..9632fe6 100644 --- a/apps/backend/src/modules/portfolio/portfolio.controller.ts +++ b/apps/backend/src/modules/portfolio/portfolio.controller.ts @@ -1,23 +1,51 @@ import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse } from '@nestjs/swagger'; -import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto'; -import { PortfolioDetailResponseDto } from './dto/portfolio-response.dto'; +import { + ApiTags, + ApiOperation, + ApiBearerAuth, + ApiOkResponse, + ApiCreatedResponse, + ApiExtraModels, + getSchemaPath, +} from '@nestjs/swagger'; import { PortfolioService } from './portfolio.service'; import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { AddPositionDto } from './dto/add-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { + AnalyticsEnvelopeDto, + PortfolioDetailEnvelopeDto, + PortfolioEnvelopeDto, + PortfolioListEnvelopeDto, + PortfolioResponseMetaDto, + PositionEnvelopeDto, +} from './dto/portfolio-envelope.dto'; + +const nullDataEnvelopeSchema = { + type: 'object', + properties: { + data: { + type: 'null', + }, + meta: { + $ref: getSchemaPath(PortfolioResponseMetaDto), + }, + }, + required: ['data', 'meta'], +}; @ApiTags('Portfolios') @ApiBearerAuth() +@ApiExtraModels(PortfolioResponseMetaDto) @Controller('portfolios') export class PortfolioController { constructor(private readonly portfolioService: PortfolioService) {} @Get() @ApiOperation({ summary: 'Get all portfolios for current user' }) - @ApiOkResponse({ type: PortfolioListResponseDto, isArray: true }) + @ApiOkResponse({ type: PortfolioListEnvelopeDto }) async findAll(@CurrentUser() user: { sub: number }) { const portfolios = await this.portfolioService.findAll(user.sub); return { data: portfolios, meta: { cachedAt: null, fromCache: false } }; @@ -25,6 +53,7 @@ export class PortfolioController { @Post() @ApiOperation({ summary: 'Create a new portfolio' }) + @ApiCreatedResponse({ type: PortfolioEnvelopeDto }) async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) { const portfolio = await this.portfolioService.create(user.sub, dto); return { data: portfolio, meta: { cachedAt: null, fromCache: false } }; @@ -32,7 +61,7 @@ export class PortfolioController { @Get(':id') @ApiOperation({ summary: 'Get portfolio details with positions and prices' }) - @ApiOkResponse({ type: PortfolioDetailResponseDto }) + @ApiOkResponse({ type: PortfolioDetailEnvelopeDto }) async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { const portfolio = await this.portfolioService.findOne(user.sub, id); return { data: portfolio, meta: { cachedAt: null, fromCache: false } }; @@ -40,6 +69,7 @@ export class PortfolioController { @Patch(':id') @ApiOperation({ summary: 'Update portfolio' }) + @ApiOkResponse({ type: PortfolioEnvelopeDto }) async update( @CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number, @@ -51,6 +81,7 @@ export class PortfolioController { @Delete(':id') @ApiOperation({ summary: 'Delete portfolio' }) + @ApiOkResponse({ schema: nullDataEnvelopeSchema }) async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { await this.portfolioService.remove(user.sub, id); return { data: null, meta: { cachedAt: null, fromCache: false } }; @@ -58,6 +89,7 @@ export class PortfolioController { @Post(':id/positions') @ApiOperation({ summary: 'Add position to portfolio' }) + @ApiCreatedResponse({ type: PositionEnvelopeDto }) async addPosition( @CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number, @@ -69,6 +101,7 @@ export class PortfolioController { @Patch(':id/positions/:positionId') @ApiOperation({ summary: 'Update position' }) + @ApiOkResponse({ type: PositionEnvelopeDto }) async updatePosition( @CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number, @@ -81,6 +114,7 @@ export class PortfolioController { @Get(':id/analytics') @ApiOperation({ summary: 'Get portfolio analytics with PnL' }) + @ApiOkResponse({ type: AnalyticsEnvelopeDto }) async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { const result = await this.portfolioService.getAnalytics(user.sub, id); return { data: result, meta: { cachedAt: null, fromCache: false } }; @@ -88,6 +122,7 @@ export class PortfolioController { @Delete(':id/positions/:positionId') @ApiOperation({ summary: 'Remove position from portfolio' }) + @ApiOkResponse({ schema: nullDataEnvelopeSchema }) async removePosition( @CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number, diff --git a/apps/backend/src/modules/securities/dto/screener-response.dto.ts b/apps/backend/src/modules/securities/dto/screener-response.dto.ts index f2df21d..3c765d8 100644 --- a/apps/backend/src/modules/securities/dto/screener-response.dto.ts +++ b/apps/backend/src/modules/securities/dto/screener-response.dto.ts @@ -13,13 +13,13 @@ export class ScreenerItemDto { @ApiProperty({ enum: ['share', 'bond'] }) type!: 'share' | 'bond'; - @ApiPropertyOptional({ example: 322.35 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 322.35 }) price!: number | null; - @ApiPropertyOptional({ example: 1.15 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 1.15 }) change!: number | null; - @ApiPropertyOptional({ example: 0.36 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 0.36 }) changePercent!: number | null; @ApiProperty({ example: 1925163 }) @@ -28,28 +28,28 @@ export class ScreenerItemDto { @ApiProperty({ example: 1 }) listLevel!: number; - @ApiPropertyOptional({ example: 6958336818320 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 6958336818320 }) capitalization!: number | null; - @ApiPropertyOptional({ example: 12.71 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 12.71 }) yieldToMaturity!: number | null; - @ApiPropertyOptional({ example: 4.5 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 4.5 }) duration!: number | null; - @ApiPropertyOptional({ example: 40.64 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 40.64 }) couponValue!: number | null; - @ApiPropertyOptional({ example: 8.15 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 8.15 }) couponPercent!: number | null; - @ApiPropertyOptional({ example: 29.48 }) + @ApiPropertyOptional({ type: Number, nullable: true, example: 29.48 }) accruedInt!: number | null; - @ApiPropertyOptional({ example: '2027-02-03' }) + @ApiPropertyOptional({ type: String, nullable: true, example: '2027-02-03' }) matDate!: string | null; - @ApiPropertyOptional({ example: 'ОФЗ-ПД' }) + @ApiPropertyOptional({ type: String, nullable: true, example: 'ОФЗ-ПД' }) bondType!: string | null; } @@ -69,3 +69,19 @@ export class ScreenerResultDto { @ApiProperty() totalPages!: number; } + +class ScreenerResponseMetaDto { + @ApiProperty({ type: String, nullable: true }) + cachedAt!: string | null; + + @ApiProperty() + fromCache!: boolean; +} + +export class ScreenerResponseDto { + @ApiProperty({ type: ScreenerResultDto }) + data!: ScreenerResultDto; + + @ApiProperty({ type: ScreenerResponseMetaDto }) + meta!: ScreenerResponseMetaDto; +} diff --git a/apps/backend/src/modules/securities/screener.service.spec.ts b/apps/backend/src/modules/securities/screener.service.spec.ts index 5f9f017..dc627d5 100644 --- a/apps/backend/src/modules/securities/screener.service.spec.ts +++ b/apps/backend/src/modules/securities/screener.service.spec.ts @@ -6,7 +6,6 @@ import { ScreenerType } from './dto/screener-query.dto'; describe('ScreenerService', () => { let service: ScreenerService; - let moexClient: MoexClientService; let cache: CacheService; beforeEach(async () => { @@ -30,7 +29,6 @@ describe('ScreenerService', () => { }).compile(); service = module.get(ScreenerService); - moexClient = module.get(MoexClientService); cache = module.get(CacheService); }); diff --git a/apps/backend/src/modules/securities/securities.controller.ts b/apps/backend/src/modules/securities/securities.controller.ts index b2a488f..be26469 100644 --- a/apps/backend/src/modules/securities/securities.controller.ts +++ b/apps/backend/src/modules/securities/securities.controller.ts @@ -4,7 +4,7 @@ import { SecuritiesService } from './securities.service'; import { ScreenerService } from './screener.service'; import { SearchQueryDto, SecurityType } from './dto/search-query.dto'; import { ScreenerQueryDto } from './dto/screener-query.dto'; -import { ScreenerResultDto } from './dto/screener-response.dto'; +import { ScreenerResponseDto } from './dto/screener-response.dto'; @ApiTags('Securities') @Controller('securities') @@ -27,7 +27,7 @@ export class SecuritiesController { @Get('screener') @ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' }) - @ApiOkResponse({ type: ScreenerResultDto }) + @ApiOkResponse({ type: ScreenerResponseDto }) async screener(@Query(ValidationPipe) query: ScreenerQueryDto) { const result = await this.screenerService.screen(query); return { data: result, meta: { cachedAt: null, fromCache: false } }; diff --git a/apps/backend/src/modules/securities/securities.service.spec.ts b/apps/backend/src/modules/securities/securities.service.spec.ts index d0370e3..be9434c 100644 --- a/apps/backend/src/modules/securities/securities.service.spec.ts +++ b/apps/backend/src/modules/securities/securities.service.spec.ts @@ -1,38 +1,174 @@ 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; + let moexClient: Pick; + let cache: Pick; beforeEach(async () => { + moexClient = { + searchSecurities: vi.fn(), + }; + cache = { + getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: '2026-06-15T00:00:00.000Z', + })), + }; + 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, + { provide: MoexClientService, useValue: moexClient }, + { provide: CacheService, useValue: cache }, ], }).compile(); service = module.get(SecuritiesService); }); - it('should return search results for SBER', async () => { - const results = await service.search('SBER', SecurityType.ALL, 5); - expect(results.length).toBeGreaterThan(0); - expect(results[0].secid).toBeDefined(); - }, 15000); + it('returns supported securities only and normalizes SUR currency to RUB', async () => { + vi.mocked(moexClient.searchSecurities).mockResolvedValue([ + { + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбербанк России ПАО ао', + shortName: 'Сбербанк', + latName: 'Sberbank', + listLevel: 1, + issueSize: 21586948000, + faceValue: 3, + faceUnit: 'SUR', + issueDate: '2007-07-20', + typeName: 'Акция обыкновенная', + group: 'stock_shares', + type: 'common_share', + isQualifiedInvestors: false, + morningSession: true, + eveningSession: true, + }, + { + secid: 'SU26238RMFS5', + isin: 'RU000A1038V6', + name: 'ОФЗ 26238', + shortName: 'ОФЗ 26238', + latName: null, + listLevel: 1, + issueSize: 100000000, + faceValue: 1000, + faceUnit: 'RUB', + issueDate: '2021-06-23', + typeName: 'Государственная облигация', + group: 'stock_bonds', + type: 'ofz_bond', + isQualifiedInvestors: false, + morningSession: false, + eveningSession: false, + }, + { + secid: 'SiM6', + isin: '', + name: 'USD/RUB Futures', + shortName: 'SiM6', + latName: null, + listLevel: 0, + issueSize: 0, + faceValue: 0, + faceUnit: '', + issueDate: '', + typeName: 'Фьючерс', + group: 'futures', + type: 'futures', + isQualifiedInvestors: false, + morningSession: false, + eveningSession: true, + }, + ]); + + const results = await service.search('SbEr', SecurityType.ALL, 10); + + expect(results).toEqual([ + { + secid: 'SBER', + isin: 'RU0009029540', + shortName: 'Сбербанк', + type: 'share', + listLevel: 1, + currency: 'RUB', + price: null, + }, + { + secid: 'SU26238RMFS5', + isin: 'RU000A1038V6', + shortName: 'ОФЗ 26238', + type: 'bond', + listLevel: 1, + currency: 'RUB', + price: null, + }, + ]); + expect(cache.getOrFetch).toHaveBeenCalledWith( + 'search', + ['sber'], + expect.any(Function), + 'searchTtl', + ); + expect(moexClient.searchSecurities).toHaveBeenCalledWith('SbEr'); + }); + + it('filters by type and applies limit without live MOEX dependency', async () => { + vi.mocked(cache.getOrFetch).mockResolvedValue({ + data: [ + { + secid: 'SBER', + isin: 'RU0009029540', + shortName: 'Сбербанк', + type: 'share', + listLevel: 1, + currency: 'RUB', + price: null, + }, + { + secid: 'GAZP', + isin: 'RU0007661625', + shortName: 'Газпром', + type: 'share', + listLevel: 1, + currency: 'RUB', + price: null, + }, + { + secid: 'SU26238RMFS5', + isin: 'RU000A1038V6', + shortName: 'ОФЗ 26238', + type: 'bond', + listLevel: 1, + currency: 'RUB', + price: null, + }, + ], + fromCache: true, + cachedAt: null, + }); + + const results = await service.search('ru', SecurityType.SHARE, 1); + + expect(results).toEqual([ + { + secid: 'SBER', + isin: 'RU0009029540', + shortName: 'Сбербанк', + type: 'share', + listLevel: 1, + currency: 'RUB', + price: null, + }, + ]); + expect(moexClient.searchSecurities).not.toHaveBeenCalled(); + }); }); diff --git a/apps/backend/src/modules/shares/shares.service.spec.ts b/apps/backend/src/modules/shares/shares.service.spec.ts index 82ab025..5873412 100644 --- a/apps/backend/src/modules/shares/shares.service.spec.ts +++ b/apps/backend/src/modules/shares/shares.service.spec.ts @@ -1,41 +1,135 @@ +import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigModule } from '@nestjs/config'; import { SharesService } from './shares.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; -import configuration from '../../config/configuration'; describe('SharesService', () => { let service: SharesService; + let moexClient: Pick; + let cache: Pick; beforeEach(async () => { + moexClient = { + getSecurityDescription: vi.fn(), + getShareMarketData: vi.fn(), + }; + cache = { + getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: '2026-06-15T00:00:00.000Z', + })), + }; + const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigModule.forRoot({ load: [configuration] })], providers: [ SharesService, - MoexClientService, - { - provide: 'CACHE_MANAGER', - useValue: { - get: () => undefined, - set: () => Promise.resolve(), - del: () => Promise.resolve(), - }, - }, - CacheService, + { provide: MoexClientService, useValue: moexClient }, + { provide: CacheService, useValue: cache }, ], }).compile(); service = module.get(SharesService); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('returns normalized SBER share spec and market data without live MOEX dependency', async () => { + vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({ + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбербанк России ПАО ао', + shortName: 'Сбербанк', + latName: 'Sberbank', + listLevel: 1, + issueSize: 21586948000, + faceValue: 3, + faceUnit: 'SUR', + issueDate: '2007-07-20', + typeName: 'Акция обыкновенная', + group: 'stock_shares', + type: 'common_share', + isQualifiedInvestors: false, + morningSession: true, + eveningSession: true, + }); + vi.mocked(moexClient.getShareMarketData).mockResolvedValue({ + secid: 'SBER', + boardid: 'TQBR', + shortName: 'Сбербанк', + bid: 320, + offer: 321, + open: 318, + low: 317, + high: 325, + last: 323, + lastChange: 4, + lastChangePrcnt: 1.25, + volume: 1500000, + value: 480000000, + waprice: 321, + numtrades: 4200, + issueCapitalization: 6900000000000, + tradingStatus: 'T', + updateTime: '18:45:00', + }); + + const result = await service.getShare('SBER'); + + expect(moexClient.getSecurityDescription).toHaveBeenCalledWith('SBER'); + expect(cache.getOrFetch).toHaveBeenCalledWith( + 'marketdata', + ['shares', 'SBER'], + expect.any(Function), + 'marketDataTtl', + ); + expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER'); + expect(result).toMatchObject({ + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбербанк России ПАО ао', + shortName: 'Сбербанк', + latName: 'Sberbank', + listLevel: 1, + issueSize: 21586948000, + faceValue: 3, + faceUnit: 'RUB', + type: 'common_share', + marketData: { + price: 323, + change: 4, + changePercent: 1.25, + open: 318, + high: 325, + low: 317, + volume: 1500000, + value: 480000000, + issueCapitalization: 6900000000000, + }, + }); + expect(result.marketData.updatedAt).toMatch(/T18:45:00$/); }); - it('should return SBER share data', async () => { - const share = await service.getShare('SBER'); - expect(share.secid).toBe('SBER'); - expect(share.marketData).toBeDefined(); - }, 15000); + it('throws NotFoundException for non-share security', async () => { + vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({ + secid: 'SU26238RMFS5', + isin: 'RU000A1038V6', + name: 'ОФЗ 26238', + shortName: 'ОФЗ 26238', + latName: null, + listLevel: 1, + issueSize: 100000000, + faceValue: 1000, + faceUnit: 'RUB', + issueDate: '2021-06-23', + typeName: 'Государственная облигация', + group: 'stock_bonds', + type: 'ofz_bond', + isQualifiedInvestors: false, + morningSession: false, + eveningSession: false, + }); + + await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(NotFoundException); + expect(cache.getOrFetch).not.toHaveBeenCalled(); + }); }); diff --git a/apps/backend/src/openapi-artifacts.spec.ts b/apps/backend/src/openapi-artifacts.spec.ts new file mode 100644 index 0000000..9a7a962 --- /dev/null +++ b/apps/backend/src/openapi-artifacts.spec.ts @@ -0,0 +1,108 @@ +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { load } = require('js-yaml'); + +describe('checked-in OpenAPI artifacts', () => { + const rootDir = resolve(process.cwd(), '../..'); + const frontendTypes = readFileSync(join(rootDir, 'apps/frontend/src/api/types.ts'), 'utf8'); + const openapiYaml = readFileSync(join(rootDir, 'docs/openapi/openapi.yaml'), 'utf8'); + const openapi = load(openapiYaml) as any; + + const requiredPaths = [ + '/api/v1/auth/register', + '/api/v1/auth/login', + '/api/v1/auth/refresh', + '/api/v1/auth/logout', + '/api/v1/auth/me', + '/api/v1/securities/screener', + '/api/v1/portfolios', + '/api/v1/portfolios/{id}', + '/api/v1/portfolios/{id}/positions', + '/api/v1/portfolios/{id}/positions/{positionId}', + '/api/v1/portfolios/{id}/analytics', + ]; + + it('frontend generated types include current protected domains', () => { + for (const path of requiredPaths) { + expect(frontendTypes).toContain(`'${path}'`); + } + }); + + it('static OpenAPI YAML snapshot includes current protected domains', () => { + for (const path of requiredPaths) { + expect(openapiYaml).toContain(`${path}:`); + } + }); + + it('checked-in artifacts do not leak the local alternate codegen port', () => { + expect(frontendTypes).not.toContain('localhost:3001'); + expect(frontendTypes).not.toContain('3001'); + expect(openapiYaml).not.toContain('localhost:3001'); + expect(openapiYaml).not.toContain('3001'); + }); + + it('nullable primitive schemas are typed explicitly', () => { + const screenerItem = openapi.components.schemas.ScreenerItemDto; + + expect(screenerItem.properties.price).toMatchObject({ + type: 'number', + nullable: true, + }); + expect(screenerItem.properties.matDate).toMatchObject({ + type: 'string', + nullable: true, + }); + }); + + it('position tags request schemas are arrays of known tags', () => { + for (const schemaName of ['AddPositionDto', 'UpdatePositionDto']) { + const tags = openapi.components.schemas[schemaName].properties.tags; + + expect(tags).toMatchObject({ + type: 'array', + }); + expect(tags.items.enum).toContain('DIVIDEND'); + } + }); + + it('current auth, screener and portfolio operations have typed JSON responses', () => { + const requiredJsonResponses = [ + ['post', '/api/v1/auth/register', 201], + ['post', '/api/v1/auth/login', 201], + ['post', '/api/v1/auth/refresh', 200], + ['post', '/api/v1/auth/logout', 200], + ['get', '/api/v1/auth/me', 200], + ['patch', '/api/v1/auth/me', 200], + ['get', '/api/v1/securities/screener', 200], + ['get', '/api/v1/portfolios', 200], + ['post', '/api/v1/portfolios', 201], + ['get', '/api/v1/portfolios/{id}', 200], + ['patch', '/api/v1/portfolios/{id}', 200], + ['delete', '/api/v1/portfolios/{id}', 200], + ['post', '/api/v1/portfolios/{id}/positions', 201], + ['patch', '/api/v1/portfolios/{id}/positions/{positionId}', 200], + ['delete', '/api/v1/portfolios/{id}/positions/{positionId}', 200], + ['get', '/api/v1/portfolios/{id}/analytics', 200], + ] as const; + + for (const [method, path, status] of requiredJsonResponses) { + expect( + openapi.paths[path][method].responses[status].content?.['application/json'], + ).toBeDefined(); + } + }); + + it('portfolio delete operations document a null data envelope', () => { + for (const [method, path, status] of [ + ['delete', '/api/v1/portfolios/{id}', 200], + ['delete', '/api/v1/portfolios/{id}/positions/{positionId}', 200], + ] as const) { + expect( + openapi.paths[path][method].responses[status].content['application/json'].schema.properties + .data, + ).toMatchObject({ type: 'null' }); + } + }); +}); diff --git a/apps/docs/docs/backend/api.md b/apps/docs/docs/backend/api.md index 8276499..11af40d 100644 --- a/apps/docs/docs/backend/api.md +++ b/apps/docs/docs/backend/api.md @@ -130,6 +130,25 @@ } ``` +### `GET /securities/screener` + +Скринер ценных бумаг по параметрам цены, объёма, доходности, дюрации, купона и срока погашения. + +**Parameters:** + +| Param | Type | Required | Description | +|---|---|---|---| +| `type` | enum | yes | `share` или `bond` | +| `priceMin`, `priceMax` | number | no | Диапазон цены | +| `volumeMin` | number | no | Минимальный объём | +| `yieldMin`, `yieldMax` | number | no | Диапазон доходности облигаций | +| `durationMin`, `durationMax` | number | no | Диапазон дюрации | +| `sortBy` | string | no | Поле сортировки | +| `sortOrder` | enum | no | `asc` или `desc` | +| `page`, `pageSize` | integer | no | Пагинация | + +**Response:** `{ data: { items, total, page, pageSize, totalPages }, meta }`. + ## Shares ### `GET /securities/shares/:secid` @@ -354,3 +373,22 @@ "meta": { "fromCache": false, "cachedAt": null } } ``` + +## Portfolios + +Все portfolio endpoints защищены JWT и возвращают envelope `{ data, meta }`. + +| Endpoint | Method | Description | +|---|---|---| +| `/api/v1/portfolios` | GET | Список портфелей пользователя | +| `/api/v1/portfolios` | POST | Создать портфель | +| `/api/v1/portfolios/:id` | GET | Детали портфеля с позициями и текущими ценами | +| `/api/v1/portfolios/:id` | PATCH | Обновить портфель | +| `/api/v1/portfolios/:id` | DELETE | Удалить портфель | +| `/api/v1/portfolios/:id/positions` | POST | Добавить позицию | +| `/api/v1/portfolios/:id/positions/:positionId` | PATCH | Обновить позицию | +| `/api/v1/portfolios/:id/positions/:positionId` | DELETE | Удалить позицию | +| `/api/v1/portfolios/:id/analytics` | GET | Аналитика портфеля и PnL | + +`DELETE` endpoints возвращают `{ data: null, meta }`, что отражено в OpenAPI schema и frontend +codegen types. diff --git a/apps/docs/docs/backend/portfolio.md b/apps/docs/docs/backend/portfolio.md index a7fcbde..92914f4 100644 --- a/apps/docs/docs/backend/portfolio.md +++ b/apps/docs/docs/backend/portfolio.md @@ -1,6 +1,7 @@ # Portfolio Module -The Portfolio module allows users to create and manage virtual investment portfolios for tracking purposes. +Portfolio module позволяет пользователям создавать и вести виртуальные инвестиционные портфели для +аналитики и отслеживания позиций. ## Overview @@ -10,19 +11,19 @@ The Portfolio module allows users to create and manage virtual investment portfo ## API Endpoints -All endpoints require JWT authentication (`JwtAuthGuard`). +Все endpoints требуют JWT authentication (`JwtAuthGuard`). | Endpoint | Method | Description | |---|---|---| -| `/api/v1/portfolios` | GET | List user's portfolios | -| `/api/v1/portfolios` | POST | Create portfolio | -| `/api/v1/portfolios/:id` | GET | Portfolio detail with enriched positions and analytics summary | -| `/api/v1/portfolios/:id/analytics` | GET | Detailed portfolio analytics with PnL | -| `/api/v1/portfolios/:id/patch` | PATCH | Update portfolio (name, description, currency) | -| `/api/v1/portfolios/:id` | DELETE | Delete portfolio (cascade deletes positions) | -| `/api/v1/portfolios/:id/positions` | POST | Add position (accepts buyPrice, buyDate) | -| `/api/v1/portfolios/:id/positions/:posId` | PATCH | Update position (quantity, buyPrice, buyDate, notes) | -| `/api/v1/portfolios/:id/positions/:posId` | DELETE | Remove position | +| `/api/v1/portfolios` | GET | Список портфелей пользователя | +| `/api/v1/portfolios` | POST | Создать портфель | +| `/api/v1/portfolios/:id` | GET | Детали портфеля с обогащёнными позициями и analytics summary | +| `/api/v1/portfolios/:id/analytics` | GET | Детальная аналитика портфеля и PnL | +| `/api/v1/portfolios/:id` | PATCH | Обновить портфель (name, description, currency) | +| `/api/v1/portfolios/:id` | DELETE | Удалить портфель вместе с позициями | +| `/api/v1/portfolios/:id/positions` | POST | Добавить позицию (buyPrice, buyDate, notes, tags) | +| `/api/v1/portfolios/:id/positions/:positionId` | PATCH | Обновить позицию | +| `/api/v1/portfolios/:id/positions/:positionId` | DELETE | Удалить позицию | ## Domain Model diff --git a/apps/docs/docs/development/codegen.md b/apps/docs/docs/development/codegen.md index a15dffc..c04c015 100644 --- a/apps/docs/docs/development/codegen.md +++ b/apps/docs/docs/development/codegen.md @@ -24,6 +24,19 @@ openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts ### Output - `apps/frontend/src/api/types.ts` — сгенерированные типы `paths` и `operations` +- `docs/openapi/openapi.yaml` — статический snapshot Swagger JSON для ревью и документации + +### Verify Artifacts + +После регенерации OpenAPI artifacts запустите: + +```bash +npm run test -w apps/backend -- src/openapi-artifacts.spec.ts +``` + +Тест проверяет, что checked-in frontend types и YAML содержат актуальные auth, screener и portfolio +paths, не содержат локальный alternate port и сохраняют важные schema metadata для nullable полей, +array enum tags и typed response envelopes. ### Manual Types diff --git a/apps/docs/docs/development/commands.md b/apps/docs/docs/development/commands.md index 9a1dcdb..aee5e8f 100644 --- a/apps/docs/docs/development/commands.md +++ b/apps/docs/docs/development/commands.md @@ -5,13 +5,16 @@ | Command | Description | |---|---| | `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 | -| `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 | +| `npm run dev:frontend` | Vite dev-сервер на `:5173`, проксирует `/api` на backend | +| `npm run dev:docs` | Docusaurus dev-сервер документации | | `npm run build:backend` | `nest build` | -| `npm run build:frontend` | `tsc -b && vite build` (в две фазы) | -| `npm run test:backend` | `vitest run` (SWC, не ts-jest) | -| `npm run lint` | ESLint только для бэкенда | +| `npm run build:frontend` | `tsc -b && vite build` | +| `npm run build:docs` | `docusaurus build` | +| `npm run test:backend` | Offline backend unit tests через Vitest | +| `npm run test:frontend` | Frontend tests через Vitest + Testing Library | +| `npm run lint` | ESLint для backend и frontend | | `npm run format` | Prettier для всех `*.{ts,tsx}` | -| `npm run format:check` | Prettier check для всех `*.{ts,tsx}` | +| `npm run format:check` | Проверка Prettier для всех `*.{ts,tsx}` | ## Backend Workspace @@ -23,6 +26,7 @@ | `npm run lint -w apps/backend` | ESLint для `{src,test}/**/*.ts` | | `npm run test -w apps/backend` | `vitest run` | | `npm run test:watch -w apps/backend` | `vitest` | +| `npm run test:integration -w apps/backend` | Opt-in live MOEX integration tests, требуется network access | ## Frontend Workspace @@ -32,6 +36,16 @@ | `npm run build -w apps/frontend` | `tsc -b && vite build` | | `npm run preview -w apps/frontend` | `vite preview` | | `npm run codegen -w apps/frontend` | `openapi-typescript` из Swagger → `src/api/types.ts` | +| `npm run lint -w apps/frontend` | ESLint для `src/**/*.{ts,tsx}` | +| `npm run test -w apps/frontend` | Frontend Vitest suite | + +## Docs Workspace + +| Command | Description | +|---|---| +| `npm run dev -w apps/docs` | Docusaurus dev-server | +| `npm run build -w apps/docs` | Production build документации | +| `npm run serve -w apps/docs` | Локальная проверка production build | ## Single Test diff --git a/apps/docs/docs/development/conventions.md b/apps/docs/docs/development/conventions.md index 385bd3b..63b5426 100644 --- a/apps/docs/docs/development/conventions.md +++ b/apps/docs/docs/development/conventions.md @@ -18,7 +18,7 @@ Prettier (`.prettierrc`): ## Linting -ESLint только для бэкенда (`apps/backend`). +ESLint запускается для backend (`apps/backend`) и frontend (`apps/frontend`). Плагины: - `@typescript-eslint/eslint-plugin` diff --git a/apps/docs/docs/development/testing.md b/apps/docs/docs/development/testing.md index c755178..85bcf1f 100644 --- a/apps/docs/docs/development/testing.md +++ b/apps/docs/docs/development/testing.md @@ -39,4 +39,25 @@ npm run test:watch -w apps/backend ## Frontend Tests -Фронтенд-тесты отсутствуют. +Фреймворк: **Vitest 4** + **React Testing Library** + **MSW**. + +Запуск: + +```bash +npm run test:frontend +# или +npm run test -w apps/frontend +``` + +Тесты покрывают API-клиент, auth context, hooks, базовые pages и shared components. + +## Live MOEX Integration Tests + +Live MOEX checks вынесены из default backend suite. + +```bash +npm run test:integration -w apps/backend +``` + +Эта команда opt-in: она требует network access и может падать при недоступности MOEX или сетевых +ограничениях окружения. diff --git a/apps/docs/docs/frontend/api-client.md b/apps/docs/docs/frontend/api-client.md index e39a33d..f974de8 100644 --- a/apps/docs/docs/frontend/api-client.md +++ b/apps/docs/docs/frontend/api-client.md @@ -8,6 +8,7 @@ async function request( path: string, params?: Record, + options?: { method?: string; body?: unknown; skipAuth?: boolean }, ): Promise<{ data: T; meta: ApiResponseMeta }> ``` @@ -20,7 +21,14 @@ async function request( | Function | Method | Path | |---|---|---| | `getHealth()` | GET | `/api/v1/health` | +| `register(data)` | POST | `/api/v1/auth/register` | +| `login(data)` | POST | `/api/v1/auth/login` | +| `refresh()` | POST | `/api/v1/auth/refresh` | +| `logout()` | POST | `/api/v1/auth/logout` | +| `getProfile()` / `getMe()` | GET | `/api/v1/auth/me` | +| `updateProfile(data)` | PATCH | `/api/v1/auth/me` | | `searchSecurities(q, type?, limit?)` | GET | `/api/v1/securities/search` | +| `screenSecurities(query)` / `getScreenerResults(query)` | GET | `/api/v1/securities/screener` | | `getShare(secid)` | GET | `/api/v1/securities/shares/:secid` | | `getShareMarketData(secid)` | GET | `/api/v1/securities/shares/:secid/marketdata` | | `getShareDividends(secid)` | GET | `/api/v1/securities/shares/:secid/dividends` | @@ -30,6 +38,15 @@ async function request( | `getBondHistory(secid, from, till)` | GET | `/api/v1/securities/bonds/:secid/history` | | `getShareCandles(secid, interval, from, till)` | GET | `/api/v1/securities/shares/:secid/candles` | | `getBondCandles(secid, interval, from, till)` | GET | `/api/v1/securities/bonds/:secid/candles` | +| `getPortfolios()` | GET | `/api/v1/portfolios` | +| `createPortfolio(data)` | POST | `/api/v1/portfolios` | +| `getPortfolio(id)` | GET | `/api/v1/portfolios/:id` | +| `updatePortfolio(id, data)` | PATCH | `/api/v1/portfolios/:id` | +| `deletePortfolio(id)` | DELETE | `/api/v1/portfolios/:id` | +| `addPosition(portfolioId, data)` | POST | `/api/v1/portfolios/:id/positions` | +| `updatePosition(portfolioId, positionId, data)` | PATCH | `/api/v1/portfolios/:id/positions/:positionId` | +| `removePosition(portfolioId, positionId)` | DELETE | `/api/v1/portfolios/:id/positions/:positionId` | +| `getPortfolioAnalytics(portfolioId)` | GET | `/api/v1/portfolios/:id/analytics` | ## Types @@ -41,5 +58,8 @@ async function request( - `BondResponse` — спецификация облигации + `BondMarketData` - `CandleItem`, `DividendItem`, `ShareHistoryItem`, `BondHistoryItem` - `SearchResultItem`, `HealthResponse` +- `UserResponse`, `AuthResponse` +- `Portfolio`, `PortfolioDetail`, `Position`, `PortfolioSummary`, `AnalyticsResponse` +- `ScreenerItem`, `ScreenerResult` Codegen-типы из OpenAPI в `api/types.ts` (генерируются через `npm run codegen`). diff --git a/apps/docs/docs/frontend/overview.md b/apps/docs/docs/frontend/overview.md index b589ad0..3cb6e60 100644 --- a/apps/docs/docs/frontend/overview.md +++ b/apps/docs/docs/frontend/overview.md @@ -9,6 +9,7 @@ React SPA, собранная с Vite. - TanStack Query v5 - lightweight-charts v4 - openapi-fetch (с рукописными типами `responses.ts`) +- Vitest + Testing Library + MSW - Vite 5 ## Source Layout @@ -19,10 +20,22 @@ apps/frontend/src/ ├── App.tsx # BrowserRouter ├── routes.tsx # Маршруты ├── api/ +│ ├── auth.ts # Auth API helpers │ ├── client.ts # HTTP-клиент (fetch) +│ ├── portfolio.ts # Portfolio API helpers │ ├── responses.ts # Типы ответов (ручные) +│ ├── screener.ts # Screener API helpers │ └── types.ts # Типы из openapi-typescript +├── context/ +│ └── AuthContext.tsx ├── hooks/ +│ ├── useAuth.ts +│ ├── usePortfolio.ts +│ ├── usePortfolioAnalytics.ts +│ ├── usePortfolioMutations.ts +│ ├── usePortfolios.ts +│ ├── usePositionMutations.ts +│ ├── useScreener.ts │ ├── useSearch.ts │ ├── useStock.ts │ ├── useStockCandles.ts @@ -31,14 +44,24 @@ apps/frontend/src/ │ └── useBondCandles.ts ├── components/ │ ├── Layout.tsx +│ ├── ProtectedRoute.tsx │ ├── SearchBar.tsx │ ├── PriceChart.tsx │ ├── StockDetails.tsx -│ └── BondDetails.tsx +│ ├── BondDetails.tsx +│ └── portfolios/ ├── pages/ │ ├── HomePage.tsx │ ├── StockPage.tsx -│ └── BondPage.tsx +│ ├── BondPage.tsx +│ ├── LoginPage.tsx +│ ├── RegisterPage.tsx +│ ├── ProfilePage.tsx +│ ├── portfolios/ +│ └── screener/ +├── test/ +│ ├── handlers.ts # MSW handlers +│ └── test-utils.tsx └── styles.css ``` diff --git a/apps/docs/docs/frontend/routes.md b/apps/docs/docs/frontend/routes.md index d4ba323..8fa479d 100644 --- a/apps/docs/docs/frontend/routes.md +++ b/apps/docs/docs/frontend/routes.md @@ -2,11 +2,17 @@ Определены в `apps/frontend/src/routes.tsx`. -| Path | Component | Description | -|---|---|---| -| `/` | `HomePage` | Главная страница с приветствием | -| `/stocks/:secid` | `StockPage` | Страница акции | -| `/bonds/:secid` | `BondPage` | Страница облигации | +| Path | Component | Access | Description | +|---|---|---|---| +| `/` | `HomePage` | Public | Главная страница | +| `/stocks/:secid` | `StockPage` | Public | Страница акции | +| `/bonds/:secid` | `BondPage` | Public | Страница облигации | +| `/screener` | `ScreenerPage` | Public | Скринер ценных бумаг | +| `/login` | `LoginPage` | Public | Вход | +| `/register` | `RegisterPage` | Public | Регистрация | +| `/profile` | `ProfilePage` | Protected | Профиль текущего пользователя | +| `/portfolios` | `PortfoliosListPage` | Protected | Список портфелей | +| `/portfolios/:id` | `PortfolioDetailPage` | Protected | Детальная страница портфеля | Все страницы обёрнуты в `Layout`, который содержит: @@ -22,6 +28,33 @@ } /> } /> } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> ``` diff --git a/apps/docs/docs/intro.md b/apps/docs/docs/intro.md index 91e2f5f..895392d 100644 --- a/apps/docs/docs/intro.md +++ b/apps/docs/docs/intro.md @@ -1,3 +1,7 @@ +--- +slug: / +--- + # MoexVibe Веб-приложение для анализа ценных бумаг Московской биржи (MOEX). @@ -15,7 +19,8 @@ moex-vibe/ ├── apps/ │ ├── backend/ # NestJS API (единственная точка доступа к MOEX) -│ └── frontend/ # React SPA +│ ├── frontend/ # React SPA +│ └── docs/ # Docusaurus documentation site ├── docs/ │ ├── architecture/ # ADR и диаграммы │ ├── openapi/ # OpenAPI-спецификация @@ -31,6 +36,6 @@ moex-vibe/ ## Key Principles - Backend — единственный клиент MOEX. Frontend никогда не обращается к MOEX напрямую. -- npm workspaces монорепозиторий: `apps/backend` и `apps/frontend`. +- npm workspaces монорепозиторий: `apps/backend`, `apps/frontend` и `apps/docs`. - Глобальный префикс API: `/api/v1`. Swagger: `/api/docs`. - Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`. diff --git a/apps/frontend/src/api/types.ts b/apps/frontend/src/api/types.ts index 0a51dc8..0046607 100644 --- a/apps/frontend/src/api/types.ts +++ b/apps/frontend/src/api/types.ts @@ -21,6 +21,92 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/auth/register': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Register new user */ + post: operations['AuthController_register']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/auth/login': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Login with email and password */ + post: operations['AuthController_login']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/auth/refresh': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Refresh access token */ + post: operations['AuthController_refresh']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/auth/logout': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Logout user */ + post: operations['AuthController_logout']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/auth/me': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get current user profile */ + get: operations['AuthController_getProfile']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update current user profile */ + patch: operations['AuthController_updateProfile']; + trace?: never; + }; '/api/v1/securities/search': { parameters: { query?: never; @@ -38,6 +124,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/securities/screener': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Фильтр ценных бумаг по параметрам */ + get: operations['SecuritiesController_screener']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/v1/securities/shares/{secid}': { parameters: { query?: never; @@ -191,10 +294,402 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/portfolios': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get all portfolios for current user */ + get: operations['PortfolioController_findAll']; + put?: never; + /** Create a new portfolio */ + post: operations['PortfolioController_create']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/portfolios/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get portfolio details with positions and prices */ + get: operations['PortfolioController_findOne']; + put?: never; + post?: never; + /** Delete portfolio */ + delete: operations['PortfolioController_remove']; + options?: never; + head?: never; + /** Update portfolio */ + patch: operations['PortfolioController_update']; + trace?: never; + }; + '/api/v1/portfolios/{id}/positions': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Add position to portfolio */ + post: operations['PortfolioController_addPosition']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/portfolios/{id}/positions/{positionId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove position from portfolio */ + delete: operations['PortfolioController_removePosition']; + options?: never; + head?: never; + /** Update position */ + patch: operations['PortfolioController_updatePosition']; + trace?: never; + }; + '/api/v1/portfolios/{id}/analytics': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get portfolio analytics with PnL */ + get: operations['PortfolioController_getAnalytics']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { - schemas: never; + schemas: { + RegisterDto: { + /** @example user@example.com */ + email: string; + /** @example securePass123 */ + password: string; + /** @example John */ + name?: string; + }; + AuthUserDto: { + id: number; + email: string; + name: string | null; + role: string; + }; + AuthTokenDataDto: { + user: components['schemas']['AuthUserDto']; + accessToken: string; + }; + AuthResponseMetaDto: { + cachedAt: string | null; + fromCache: boolean; + }; + AuthTokenResponseDto: { + data: components['schemas']['AuthTokenDataDto']; + meta: components['schemas']['AuthResponseMetaDto']; + }; + LoginDto: { + /** @example user@example.com */ + email: string; + /** @example securePass123 */ + password: string; + }; + LogoutDataDto: { + message: string; + }; + AuthLogoutResponseDto: { + data: components['schemas']['LogoutDataDto']; + meta: components['schemas']['AuthResponseMetaDto']; + }; + AuthProfileResponseDto: { + data: components['schemas']['AuthUserDto']; + meta: components['schemas']['AuthResponseMetaDto']; + }; + UpdateProfileDto: { + /** @example John Doe */ + name?: string; + }; + ScreenerItemDto: { + /** @example SBER */ + secid: string; + /** @example Сбербанк */ + shortName: string; + /** @example RU0009029540 */ + isin: string; + /** @enum {string} */ + type: 'share' | 'bond'; + /** @example 322.35 */ + price?: number | null; + /** @example 1.15 */ + change?: number | null; + /** @example 0.36 */ + changePercent?: number | null; + /** @example 1925163 */ + volume: number; + /** @example 1 */ + listLevel: number; + /** @example 6958336818320 */ + capitalization?: number | null; + /** @example 12.71 */ + yieldToMaturity?: number | null; + /** @example 4.5 */ + duration?: number | null; + /** @example 40.64 */ + couponValue?: number | null; + /** @example 8.15 */ + couponPercent?: number | null; + /** @example 29.48 */ + accruedInt?: number | null; + /** @example 2027-02-03 */ + matDate?: string | null; + /** @example ОФЗ-ПД */ + bondType?: string | null; + }; + ScreenerResultDto: { + items: components['schemas']['ScreenerItemDto'][]; + total: number; + page: number; + pageSize: number; + totalPages: number; + }; + ScreenerResponseMetaDto: { + cachedAt: string | null; + fromCache: boolean; + }; + ScreenerResponseDto: { + data: components['schemas']['ScreenerResultDto']; + meta: components['schemas']['ScreenerResponseMetaDto']; + }; + PortfolioResponseMetaDto: { + cachedAt: string | null; + fromCache: boolean; + }; + PortfolioListResponseDto: { + id: number; + name: string; + description?: string | null; + /** @default RUB */ + currency: string; + createdAt: string; + updatedAt: string; + /** @description Total market value of all positions */ + totalValue: number; + /** @description Total number of positions */ + positionCount: number; + /** @description Number of share positions */ + shareCount: number; + /** @description Number of bond positions */ + bondCount: number; + }; + PortfolioListEnvelopeDto: { + data: components['schemas']['PortfolioListResponseDto'][]; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; + CreatePortfolioDto: { + /** @example Мой портфель */ + name: string; + /** @example Описание портфеля */ + description?: string; + /** + * @default RUB + * @enum {string} + */ + currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN'; + }; + PortfolioResponseDto: { + id: number; + name: string; + description?: string | null; + /** @default RUB */ + currency: string; + createdAt: string; + updatedAt: string; + }; + PortfolioEnvelopeDto: { + data: components['schemas']['PortfolioResponseDto']; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; + PositionWithPriceDto: { + id: number; + /** @example SBER */ + secid: string; + shortName?: string | null; + /** + * @example share + * @enum {string} + */ + type: 'share' | 'bond'; + /** @example 10 */ + quantity: number; + buyPrice?: number | null; + buyDate?: string | null; + notes?: string | null; + tags?: string[] | null; + currentPrice?: number | null; + totalCost?: number | null; + currentValue?: number | null; + weightPercent: number; + pnl?: number | null; + pnlPercent?: number | null; + dividendIncome?: number | null; + totalReturn?: number | null; + totalReturnPercent?: number | null; + change?: number | null; + changePercent?: number | null; + yieldToMaturity?: number | null; + duration?: number | null; + couponValue?: number | null; + couponPercent?: number | null; + nextCouponDate?: string | null; + matDate?: string | null; + accruedInt?: number | null; + bid?: number | null; + offer?: number | null; + couponPeriod?: number | null; + bondType?: string | null; + offerDate?: string | null; + }; + PortfolioSummaryDto: { + totalInvested: number; + totalValue: number; + totalPnl: number; + totalPnlPercent: number | null; + totalDividends: number; + totalReturn: number; + totalReturnPercent: number | null; + positionCount: number; + weightedYield: number | null; + }; + PortfolioDetailResponseDto: { + id: number; + name: string; + description?: string | null; + /** @default RUB */ + currency: string; + createdAt: string; + updatedAt: string; + positions: components['schemas']['PositionWithPriceDto'][]; + totalValue: number; + analytics: components['schemas']['PortfolioSummaryDto']; + }; + PortfolioDetailEnvelopeDto: { + data: components['schemas']['PortfolioDetailResponseDto']; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; + UpdatePortfolioDto: { + /** @example Мой портфель */ + name?: string; + /** @example Обновлённое описание */ + description?: string; + /** + * @default RUB + * @enum {string} + */ + currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN'; + }; + AddPositionDto: { + /** @example SBER */ + secid: string; + /** @example 10 */ + quantity: number; + /** @example 250.5 */ + buyPrice?: number; + /** @example 2026-06-01 */ + buyDate?: string; + /** @example Покупка на дип */ + notes?: string; + /** + * @example [ + * "DIVIDEND", + * "GROWTH" + * ] + */ + tags?: ( + | 'DIVIDEND' + | 'GROWTH' + | 'DEFENSIVE' + | 'SPECULATIVE' + | 'BOND' + | 'ETF' + | 'GOVERNMENT' + | 'CASH' + )[]; + }; + PositionResponseDto: { + id: number; + /** @example SBER */ + secid: string; + /** @example 10 */ + quantity: number; + notes?: string | null; + tags?: string[] | null; + portfolioId: number; + createdAt: string; + updatedAt: string; + }; + PositionEnvelopeDto: { + data: components['schemas']['PositionResponseDto']; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; + UpdatePositionDto: { + /** @example 15 */ + quantity?: number; + /** @example 260 */ + buyPrice?: number; + /** @example 2026-06-15 */ + buyDate?: string; + /** @example Докупка */ + notes?: string; + /** + * @example [ + * "DIVIDEND" + * ] + */ + tags?: ( + | 'DIVIDEND' + | 'GROWTH' + | 'DEFENSIVE' + | 'SPECULATIVE' + | 'BOND' + | 'ETF' + | 'GOVERNMENT' + | 'CASH' + )[]; + }; + AnalyticsResponseDto: { + positions: components['schemas']['PositionWithPriceDto'][]; + summary: components['schemas']['PortfolioSummaryDto']; + }; + AnalyticsEnvelopeDto: { + data: components['schemas']['AnalyticsResponseDto']; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; + }; responses: never; parameters: never; requestBodies: never; @@ -220,6 +715,132 @@ export interface operations { }; }; }; + AuthController_register: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['RegisterDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['AuthTokenResponseDto']; + }; + }; + }; + }; + AuthController_login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['LoginDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['AuthTokenResponseDto']; + }; + }; + }; + }; + AuthController_refresh: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['AuthTokenResponseDto']; + }; + }; + }; + }; + AuthController_logout: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['AuthLogoutResponseDto']; + }; + }; + }; + }; + AuthController_getProfile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['AuthProfileResponseDto']; + }; + }; + }; + }; + AuthController_updateProfile: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateProfileDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['AuthProfileResponseDto']; + }; + }; + }; + }; SecuritiesController_search: { parameters: { query: { @@ -242,6 +863,49 @@ export interface operations { }; }; }; + SecuritiesController_screener: { + parameters: { + query: { + type: 'share' | 'bond'; + priceMin?: number; + priceMax?: number; + volumeMin?: number; + listLevel?: number; + changePercentMin?: number; + changePercentMax?: number; + capitalizationMin?: number; + yieldMin?: number; + yieldMax?: number; + durationMin?: number; + durationMax?: number; + couponMin?: number; + couponMax?: number; + couponPercentMin?: number; + couponPercentMax?: number; + maturityBefore?: string; + maturityAfter?: string; + bondType?: string; + sortBy?: string; + sortOrder?: string; + page?: number; + pageSize?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ScreenerResponseDto']; + }; + }; + }; + }; SharesController_getShare: { parameters: { query?: never; @@ -427,4 +1091,213 @@ export interface operations { }; }; }; + PortfolioController_findAll: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PortfolioListEnvelopeDto']; + }; + }; + }; + }; + PortfolioController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['CreatePortfolioDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PortfolioEnvelopeDto']; + }; + }; + }; + }; + PortfolioController_findOne: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PortfolioDetailEnvelopeDto']; + }; + }; + }; + }; + PortfolioController_remove: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + data: null; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; + }; + }; + }; + }; + PortfolioController_update: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdatePortfolioDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PortfolioEnvelopeDto']; + }; + }; + }; + }; + PortfolioController_addPosition: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['AddPositionDto']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PositionEnvelopeDto']; + }; + }; + }; + }; + PortfolioController_removePosition: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + positionId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + data: null; + meta: components['schemas']['PortfolioResponseMetaDto']; + }; + }; + }; + }; + }; + PortfolioController_updatePosition: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + positionId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdatePositionDto']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['PositionEnvelopeDto']; + }; + }; + }; + }; + PortfolioController_getAnalytics: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['AnalyticsEnvelopeDto']; + }; + }; + }; + }; } diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index aa3c486..767391b 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -1,776 +1,1401 @@ -openapi: "3.0.3" -info: - title: MoexVibe API - description: | - API для анализа ценных бумаг Московской биржи. - Backend является единственной точкой доступа к MOEX ISS. - version: "1.0.0" - contact: - name: MoexVibe Team - -servers: - - url: http://localhost:3000/api/v1 - description: Local development - - url: https://api.moexvibe.example.com/api/v1 - description: Production - +openapi: 3.0.0 paths: - /health: + /api/v1/health: get: - operationId: healthCheck - tags: [Health] + operationId: HealthController_check summary: Проверка состояния сервиса + parameters: [] responses: - "200": - description: Сервис работает + '200': + description: '' + tags: + - Health + /api/v1/auth/register: + post: + operationId: AuthController_register + summary: Register new user + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterDto' + responses: + '201': + description: '' content: application/json: schema: - $ref: "#/components/schemas/HealthResponse" - - /securities/search: + $ref: '#/components/schemas/AuthTokenResponseDto' + tags: + - Auth + /api/v1/auth/login: + post: + operationId: AuthController_login + summary: Login with email and password + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginDto' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenResponseDto' + tags: + - Auth + /api/v1/auth/refresh: + post: + operationId: AuthController_refresh + summary: Refresh access token + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthTokenResponseDto' + tags: + - Auth + /api/v1/auth/logout: + post: + operationId: AuthController_logout + summary: Logout user + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthLogoutResponseDto' + tags: + - Auth + security: + - bearer: [] + /api/v1/auth/me: get: - operationId: searchSecurities - tags: [Securities] + operationId: AuthController_getProfile + summary: Get current user profile + parameters: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthProfileResponseDto' + tags: + - Auth + security: + - bearer: [] + patch: + operationId: AuthController_updateProfile + summary: Update current user profile + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProfileDto' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AuthProfileResponseDto' + tags: + - Auth + security: + - bearer: [] + /api/v1/securities/search: + get: + operationId: SecuritiesController_search summary: Поиск по инструментам parameters: - name: q - in: query required: true - schema: - type: string - minLength: 1 - maxLength: 100 + in: query description: Поисковый запрос (тикер, название, ISIN) - - name: type - in: query - required: false schema: type: string - enum: [all, share, bond] - default: all - description: Фильтр по типу инструмента - - name: limit - in: query + - name: type required: false + in: query + schema: + default: all + enum: + - all + - share + - bond + type: string + - name: limit + required: false + in: query schema: - type: integer - minimum: 1 - maximum: 100 default: 20 - description: Максимальное количество результатов + type: number responses: - "200": - description: Результаты поиска + '200': + description: '' + tags: + - Securities + /api/v1/securities/screener: + get: + operationId: SecuritiesController_screener + summary: Фильтр ценных бумаг по параметрам + parameters: + - name: type + required: true + in: query + schema: + enum: + - share + - bond + type: string + - name: priceMin + required: false + in: query + schema: + type: number + - name: priceMax + required: false + in: query + schema: + type: number + - name: volumeMin + required: false + in: query + schema: + type: number + - name: listLevel + required: false + in: query + schema: + type: number + - name: changePercentMin + required: false + in: query + schema: + type: number + - name: changePercentMax + required: false + in: query + schema: + type: number + - name: capitalizationMin + required: false + in: query + schema: + type: number + - name: yieldMin + required: false + in: query + schema: + type: number + - name: yieldMax + required: false + in: query + schema: + type: number + - name: durationMin + required: false + in: query + schema: + type: number + - name: durationMax + required: false + in: query + schema: + type: number + - name: couponMin + required: false + in: query + schema: + type: number + - name: couponMax + required: false + in: query + schema: + type: number + - name: couponPercentMin + required: false + in: query + schema: + type: number + - name: couponPercentMax + required: false + in: query + schema: + type: number + - name: maturityBefore + required: false + in: query + schema: + type: string + - name: maturityAfter + required: false + in: query + schema: + type: string + - name: bondType + required: false + in: query + schema: + type: string + - name: sortBy + required: false + in: query + schema: + default: price + type: string + - name: sortOrder + required: false + in: query + schema: + default: asc + type: string + - name: page + required: false + in: query + schema: + default: 1 + type: number + - name: pageSize + required: false + in: query + schema: + default: 20 + type: number + responses: + '200': + description: '' content: application/json: schema: - $ref: "#/components/schemas/SearchResponse" - "400": - $ref: "#/components/responses/BadRequest" - - /securities/shares/{secid}: + $ref: '#/components/schemas/ScreenerResponseDto' + tags: + - Securities + /api/v1/securities/shares/{secid}: get: - operationId: getShare - tags: [Shares] + operationId: SharesController_getShare summary: Получить спецификацию акции parameters: - name: secid - in: path required: true + in: path schema: type: string - description: SECID инструмента (e.g. SBER) responses: - "200": - description: Спецификация акции - content: - application/json: - schema: - $ref: "#/components/schemas/StockResponse" - "404": - $ref: "#/components/responses/NotFound" - - /securities/shares/{secid}/marketdata: + '200': + description: '' + tags: + - Shares + /api/v1/securities/shares/{secid}/marketdata: get: - operationId: getShareMarketData - tags: [Shares] + operationId: SharesController_getMarketData summary: Получить рыночные данные акции parameters: - name: secid - in: path required: true + in: path schema: type: string responses: - "200": - description: Рыночные данные - content: - application/json: - schema: - $ref: "#/components/schemas/StockMarketDataResponse" - "404": - $ref: "#/components/responses/NotFound" - - /securities/shares/{secid}/candles: + '200': + description: '' + tags: + - Shares + /api/v1/securities/shares/{secid}/dividends: get: - operationId: getShareCandles - tags: [Shares] - summary: Получить свечи для графика цены акции + operationId: SharesController_getDividends + summary: Получить дивиденды parameters: - name: secid + required: true in: path - required: true schema: type: string - - name: interval - in: query - required: true - schema: - type: string - enum: ["1h", "24h"] - description: Таймфрейм свечей - - name: from - in: query - required: true - schema: - type: string - format: date - description: Начальная дата (ISO 8601) - - name: till - in: query - required: true - schema: - type: string - format: date - description: Конечная дата (ISO 8601) responses: - "200": - description: Массив свечей - content: - application/json: - schema: - $ref: "#/components/schemas/CandlesResponse" - "400": - $ref: "#/components/responses/BadRequest" - - /securities/shares/{secid}/history: + '200': + description: '' + tags: + - Shares + /api/v1/securities/shares/{secid}/history: get: - operationId: getShareHistory - tags: [Shares] + operationId: SharesController_getHistory summary: Получить дневную историю торгов акции parameters: - name: secid - in: path required: true + in: path schema: type: string - name: from - in: query required: true + in: query schema: type: string - format: date - name: till + required: true in: query - required: true - schema: - type: string - format: date - responses: - "200": - description: Дневная история - content: - application/json: - schema: - $ref: "#/components/schemas/HistoryResponse" - - /securities/shares/{secid}/dividends: - get: - operationId: getShareDividends - tags: [Shares] - summary: Получить историю дивидендных выплат - parameters: - - name: secid - in: path - required: true schema: type: string responses: - "200": - description: Дивиденды - content: - application/json: - schema: - $ref: "#/components/schemas/DividendsResponse" - "404": - $ref: "#/components/responses/NotFound" - - /securities/bonds/{secid}: + '200': + description: '' + tags: + - Shares + /api/v1/securities/bonds/{secid}: get: - operationId: getBond - tags: [Bonds] + operationId: BondsController_getBond summary: Получить спецификацию облигации parameters: - name: secid - in: path required: true + in: path schema: type: string responses: - "200": - description: Спецификация облигации - content: - application/json: - schema: - $ref: "#/components/schemas/BondResponse" - "404": - $ref: "#/components/responses/NotFound" - - /securities/bonds/{secid}/marketdata: + '200': + description: '' + tags: + - Bonds + /api/v1/securities/bonds/{secid}/marketdata: get: - operationId: getBondMarketData - tags: [Bonds] + operationId: BondsController_getMarketData summary: Получить рыночные данные облигации parameters: - name: secid - in: path required: true + in: path schema: type: string responses: - "200": - description: Рыночные данные облигации - content: - application/json: - schema: - $ref: "#/components/schemas/BondMarketDataResponse" - "404": - $ref: "#/components/responses/NotFound" - - /securities/bonds/{secid}/candles: + '200': + description: '' + tags: + - Bonds + /api/v1/securities/bonds/{secid}/history: get: - operationId: getBondCandles - tags: [Bonds] - summary: Получить свечи для графика цены облигации - parameters: - - name: secid - in: path - required: true - schema: - type: string - - name: interval - in: query - required: true - schema: - type: string - enum: ["1h", "24h"] - - name: from - in: query - required: true - schema: - type: string - format: date - - name: till - in: query - required: true - schema: - type: string - format: date - responses: - "200": - description: Массив свечей - content: - application/json: - schema: - $ref: "#/components/schemas/CandlesResponse" - - /securities/bonds/{secid}/history: - get: - operationId: getBondHistory - tags: [Bonds] + operationId: BondsController_getHistory summary: Получить дневную историю торгов облигации parameters: - name: secid - in: path required: true + in: path schema: type: string - name: from - in: query required: true + in: query schema: type: string - format: date - name: till - in: query required: true + in: query schema: type: string - format: date responses: - "200": - description: Дневная история + '200': + description: '' + tags: + - Bonds + /api/v1/securities/shares/{secid}/candles: + get: + operationId: CandlesController_getShareCandles + summary: Получить свечи акции + parameters: + - name: secid + required: true + in: path + schema: + type: string + - name: interval + required: true + in: query + schema: + enum: + - 1h + - 24h + type: string + - name: from + required: true + in: query + schema: + format: date + example: '2025-06-13' + type: string + - name: till + required: true + in: query + schema: + format: date + example: '2026-06-13' + type: string + responses: + '200': + description: '' + tags: + - Candles + /api/v1/securities/bonds/{secid}/candles: + get: + operationId: CandlesController_getBondCandles + summary: Получить свечи облигации + parameters: + - name: secid + required: true + in: path + schema: + type: string + - name: interval + required: true + in: query + schema: + enum: + - 1h + - 24h + type: string + - name: from + required: true + in: query + schema: + format: date + example: '2025-06-13' + type: string + - name: till + required: true + in: query + schema: + format: date + example: '2026-06-13' + type: string + responses: + '200': + description: '' + tags: + - Candles + /api/v1/portfolios: + get: + operationId: PortfolioController_findAll + summary: Get all portfolios for current user + parameters: [] + responses: + '200': + description: '' content: application/json: schema: - $ref: "#/components/schemas/BondHistoryResponse" - + $ref: '#/components/schemas/PortfolioListEnvelopeDto' + tags: + - Portfolios + security: + - bearer: [] + post: + operationId: PortfolioController_create + summary: Create a new portfolio + parameters: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePortfolioDto' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioEnvelopeDto' + tags: + - Portfolios + security: + - bearer: [] + /api/v1/portfolios/{id}: + get: + operationId: PortfolioController_findOne + summary: Get portfolio details with positions and prices + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioDetailEnvelopeDto' + tags: + - Portfolios + security: + - bearer: [] + patch: + operationId: PortfolioController_update + summary: Update portfolio + parameters: + - name: id + required: true + in: path + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePortfolioDto' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PortfolioEnvelopeDto' + tags: + - Portfolios + security: + - bearer: [] + delete: + operationId: PortfolioController_remove + summary: Delete portfolio + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + properties: + data: + type: 'null' + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta + tags: + - Portfolios + security: + - bearer: [] + /api/v1/portfolios/{id}/positions: + post: + operationId: PortfolioController_addPosition + summary: Add position to portfolio + parameters: + - name: id + required: true + in: path + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AddPositionDto' + responses: + '201': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PositionEnvelopeDto' + tags: + - Portfolios + security: + - bearer: [] + /api/v1/portfolios/{id}/positions/{positionId}: + patch: + operationId: PortfolioController_updatePosition + summary: Update position + parameters: + - name: id + required: true + in: path + schema: + type: number + - name: positionId + required: true + in: path + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePositionDto' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/PositionEnvelopeDto' + tags: + - Portfolios + security: + - bearer: [] + delete: + operationId: PortfolioController_removePosition + summary: Remove position from portfolio + parameters: + - name: id + required: true + in: path + schema: + type: number + - name: positionId + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + properties: + data: + type: 'null' + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta + tags: + - Portfolios + security: + - bearer: [] + /api/v1/portfolios/{id}/analytics: + get: + operationId: PortfolioController_getAnalytics + summary: Get portfolio analytics with PnL + parameters: + - name: id + required: true + in: path + schema: + type: number + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/AnalyticsEnvelopeDto' + tags: + - Portfolios + security: + - bearer: [] +info: + title: MoexVibe API + description: '' + version: 1.0.0 + contact: {} +tags: [] +servers: [] components: + securitySchemes: + bearer: + scheme: bearer + bearerFormat: JWT + type: http schemas: - # ── Health ── - HealthResponse: + RegisterDto: type: object properties: - status: + email: type: string - example: "ok" - timestamp: + example: user@example.com + password: type: string - format: date-time - uptime: + example: securePass123 + name: + type: string + example: John + required: + - email + - password + AuthUserDto: + type: object + properties: + id: type: number - required: [status, timestamp, uptime] - - # ── Api Response Wrapper ── - ApiResponse: + email: + type: string + name: + type: string + nullable: true + role: + type: string + required: + - id + - email + - name + - role + AuthTokenDataDto: type: object properties: - data: {} + user: + $ref: '#/components/schemas/AuthUserDto' + accessToken: + type: string + required: + - user + - accessToken + AuthResponseMetaDto: + type: object + properties: + cachedAt: + type: string + nullable: true + fromCache: + type: boolean + required: + - cachedAt + - fromCache + AuthTokenResponseDto: + type: object + properties: + data: + $ref: '#/components/schemas/AuthTokenDataDto' meta: - type: object - properties: - cachedAt: - type: string - format: date-time - nullable: true - fromCache: - type: boolean - required: [data] - - # ── Search ── - SearchResult: + $ref: '#/components/schemas/AuthResponseMetaDto' + required: + - data + - meta + LoginDto: + type: object + properties: + email: + type: string + example: user@example.com + password: + type: string + example: securePass123 + required: + - email + - password + LogoutDataDto: + type: object + properties: + message: + type: string + required: + - message + AuthLogoutResponseDto: + type: object + properties: + data: + $ref: '#/components/schemas/LogoutDataDto' + meta: + $ref: '#/components/schemas/AuthResponseMetaDto' + required: + - data + - meta + AuthProfileResponseDto: + type: object + properties: + data: + $ref: '#/components/schemas/AuthUserDto' + meta: + $ref: '#/components/schemas/AuthResponseMetaDto' + required: + - data + - meta + UpdateProfileDto: + type: object + properties: + name: + type: string + example: John Doe + ScreenerItemDto: type: object properties: secid: type: string - example: "SBER" - isin: - type: string - example: "RU0009029540" + example: SBER shortName: type: string - example: "Сбербанк" + example: Сбербанк + isin: + type: string + example: RU0009029540 type: type: string - enum: [share, bond] - listLevel: - type: integer - example: 1 - currency: - type: string - nullable: true - example: "RUB" + enum: + - share + - bond price: type: number nullable: true example: 322.35 - required: [secid, isin, shortName, type, listLevel] - - SearchResponse: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/SearchResult" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - # ── Stock ── - StockMarketData: - type: object - properties: - price: - type: number - example: 322.35 change: type: number + nullable: true example: 1.15 changePercent: type: number + nullable: true example: 0.36 - open: - type: number - example: 321.30 - high: - type: number - example: 322.66 - low: - type: number - nullable: true - example: 321.20 volume: - type: integer + type: number example: 1925163 - value: - type: number - example: 620184479 - issueCapitalization: - type: number - example: 6958336818320 - updatedAt: - type: string - format: date-time - example: "2026-06-13T18:03:11Z" - required: [price, change, changePercent, open, volume, value, updatedAt] - - Stock: - type: object - properties: - secid: - type: string - example: "SBER" - isin: - type: string - example: "RU0009029540" - name: - type: string - example: "Сбербанк России ПАО ао" - shortName: - type: string - example: "Сбербанк" - latName: - type: string - nullable: true - example: "Sberbank" listLevel: - type: integer + type: number example: 1 - issueSize: - type: integer - example: 21586948000 - faceValue: + capitalization: type: number - example: 3 - faceUnit: - type: string - example: "RUB" - type: - type: string - example: "common_share" - marketData: - $ref: "#/components/schemas/StockMarketData" - required: [secid, isin, name, shortName, listLevel, type, marketData] - - StockResponse: - type: object - properties: - data: - $ref: "#/components/schemas/Stock" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - StockMarketDataResponse: - type: object - properties: - data: - $ref: "#/components/schemas/StockMarketData" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - # ── Bond ── - BondMarketData: - type: object - properties: - price: - type: number - example: 100.45 - description: Цена в % от номинала + nullable: true + example: 6958336818320 yieldToMaturity: type: number nullable: true example: 12.71 - yieldAtWaprice: - type: number - nullable: true duration: type: number nullable: true - accruedInt: - type: number - example: 29.48 + example: 4.5 couponValue: type: number + nullable: true example: 40.64 couponPercent: type: number nullable: true example: 8.15 - nextCouponDate: + accruedInt: + type: number + nullable: true + example: 29.48 + matDate: type: string - format: date nullable: true - example: "2026-08-05" - open: - type: number - high: - type: number + example: '2027-02-03' + bondType: + type: string nullable: true - low: + example: ОФЗ-ПД + required: + - secid + - shortName + - isin + - type + - volume + - listLevel + ScreenerResultDto: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/ScreenerItemDto' + total: type: number + page: + type: number + pageSize: + type: number + totalPages: + type: number + required: + - items + - total + - page + - pageSize + - totalPages + ScreenerResponseMetaDto: + type: object + properties: + cachedAt: + type: string nullable: true - volume: - type: integer + fromCache: + type: boolean + required: + - cachedAt + - fromCache + ScreenerResponseDto: + type: object + properties: + data: + $ref: '#/components/schemas/ScreenerResultDto' + meta: + $ref: '#/components/schemas/ScreenerResponseMetaDto' + required: + - data + - meta + PortfolioResponseMetaDto: + type: object + properties: + cachedAt: + type: string + nullable: true + fromCache: + type: boolean + required: + - cachedAt + - fromCache + PortfolioListResponseDto: + type: object + properties: + id: + type: number + name: + type: string + description: + type: string + nullable: true + currency: + type: string + default: RUB + createdAt: + type: string updatedAt: type: string - format: date-time - required: [price, accruedInt, couponValue, volume, updatedAt] - - Bond: + totalValue: + type: number + description: Total market value of all positions + positionCount: + type: number + description: Total number of positions + shareCount: + type: number + description: Number of share positions + bondCount: + type: number + description: Number of bond positions + required: + - id + - name + - currency + - createdAt + - updatedAt + - totalValue + - positionCount + - shareCount + - bondCount + PortfolioListEnvelopeDto: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/PortfolioListResponseDto' + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta + CreatePortfolioDto: + type: object + properties: + name: + type: string + example: Мой портфель + description: + type: string + example: Описание портфеля + currency: + type: string + default: RUB + enum: + - RUB + - USD + - EUR + - CNY + - KZT + - BYN + required: + - name + PortfolioResponseDto: + type: object + properties: + id: + type: number + name: + type: string + description: + type: string + nullable: true + currency: + type: string + default: RUB + createdAt: + type: string + updatedAt: + type: string + required: + - id + - name + - currency + - createdAt + - updatedAt + PortfolioEnvelopeDto: + type: object + properties: + data: + $ref: '#/components/schemas/PortfolioResponseDto' + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta + PositionWithPriceDto: + type: object + properties: + id: + type: number + secid: + type: string + example: SBER + shortName: + type: string + nullable: true + type: + type: string + example: share + enum: + - share + - bond + quantity: + type: number + example: 10 + buyPrice: + type: number + nullable: true + buyDate: + type: string + nullable: true + notes: + type: string + nullable: true + tags: + nullable: true + type: array + items: + type: string + currentPrice: + type: number + nullable: true + totalCost: + type: number + nullable: true + currentValue: + type: number + nullable: true + weightPercent: + type: number + pnl: + type: number + nullable: true + pnlPercent: + type: number + nullable: true + dividendIncome: + type: number + nullable: true + totalReturn: + type: number + nullable: true + totalReturnPercent: + type: number + nullable: true + change: + type: number + nullable: true + changePercent: + type: number + nullable: true + yieldToMaturity: + type: number + nullable: true + duration: + type: number + nullable: true + couponValue: + type: number + nullable: true + couponPercent: + type: number + nullable: true + nextCouponDate: + type: string + nullable: true + matDate: + type: string + nullable: true + accruedInt: + type: number + nullable: true + bid: + type: number + nullable: true + offer: + type: number + nullable: true + couponPeriod: + type: number + nullable: true + bondType: + type: string + nullable: true + offerDate: + type: string + nullable: true + required: + - id + - secid + - type + - quantity + - weightPercent + PortfolioSummaryDto: + type: object + properties: + totalInvested: + type: number + totalValue: + type: number + totalPnl: + type: number + totalPnlPercent: + type: number + nullable: true + totalDividends: + type: number + totalReturn: + type: number + totalReturnPercent: + type: number + nullable: true + positionCount: + type: number + weightedYield: + type: number + nullable: true + required: + - totalInvested + - totalValue + - totalPnl + - totalPnlPercent + - totalDividends + - totalReturn + - totalReturnPercent + - positionCount + - weightedYield + PortfolioDetailResponseDto: + type: object + properties: + id: + type: number + name: + type: string + description: + type: string + nullable: true + currency: + type: string + default: RUB + createdAt: + type: string + updatedAt: + type: string + positions: + type: array + items: + $ref: '#/components/schemas/PositionWithPriceDto' + totalValue: + type: number + analytics: + $ref: '#/components/schemas/PortfolioSummaryDto' + required: + - id + - name + - currency + - createdAt + - updatedAt + - positions + - totalValue + - analytics + PortfolioDetailEnvelopeDto: + type: object + properties: + data: + $ref: '#/components/schemas/PortfolioDetailResponseDto' + meta: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta + UpdatePortfolioDto: + type: object + properties: + name: + type: string + example: Мой портфель + description: + type: string + example: Обновлённое описание + currency: + type: string + default: RUB + enum: + - RUB + - USD + - EUR + - CNY + - KZT + - BYN + AddPositionDto: type: object properties: secid: type: string - isin: - type: string - name: - type: string - shortName: - type: string - latName: - type: string - nullable: true - listLevel: - type: integer - issueSize: - type: integer - faceValue: + example: SBER + quantity: type: number - faceUnit: - type: string - matDate: - type: string - format: date - example: "2027-02-03" - couponValue: + example: 10 + buyPrice: type: number - example: 40.64 - couponPercent: - type: number - nullable: true - example: 8.15 - couponPeriod: - type: integer - example: 182 - nextCoupon: + example: 250.5 + buyDate: type: string - format: date - example: "2026-08-05" - accruedInt: - type: number - example: 29.48 - bondType: + example: '2026-06-01' + notes: type: string - example: "Фикс с известным купоном" - bondSubType: - type: string - example: "До погашения" - offerDate: - type: string - format: date - nullable: true - buybackDate: - type: string - format: date - nullable: true - marketData: - $ref: "#/components/schemas/BondMarketData" - required: [secid, isin, name, shortName, listLevel, matDate, couponValue, - couponPeriod, accruedInt, bondType, marketData] - - BondResponse: + example: Покупка на дип + tags: + type: array + example: + - DIVIDEND + - GROWTH + items: + type: string + enum: + - DIVIDEND + - GROWTH + - DEFENSIVE + - SPECULATIVE + - BOND + - ETF + - GOVERNMENT + - CASH + required: + - secid + - quantity + PositionResponseDto: type: object properties: - data: - $ref: "#/components/schemas/Bond" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - BondMarketDataResponse: - type: object - properties: - data: - $ref: "#/components/schemas/BondMarketData" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - # ── Candle ── - Candle: - type: object - properties: - open: + id: type: number - example: 280.00 - high: - type: number - example: 280.41 - low: - type: number - example: 271.80 - close: - type: number - example: 272.25 - volume: - type: integer - example: 43086870 - value: - type: number - example: 11853565984.9 - begin: + secid: type: string - format: date-time - example: "2025-01-03T00:00:00Z" - end: + example: SBER + quantity: + type: number + example: 10 + notes: type: string - format: date-time - example: "2025-01-03T23:59:59Z" - required: [open, high, low, close, volume, value, begin, end] - - CandlesResponse: - type: object - properties: - data: + nullable: true + tags: + nullable: true type: array items: - $ref: "#/components/schemas/Candle" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - # ── History ── - HistoryEntry: - type: object - properties: - date: + type: string + portfolioId: + type: number + createdAt: type: string - format: date - open: - type: number - high: - type: number - low: - type: number - close: - type: number - volume: - type: integer - value: - type: number - required: [date, open, high, low, close, volume, value] - - HistoryResponse: + updatedAt: + type: string + required: + - id + - secid + - quantity + - portfolioId + - createdAt + - updatedAt + PositionEnvelopeDto: type: object properties: data: - type: array - items: - $ref: "#/components/schemas/HistoryEntry" + $ref: '#/components/schemas/PositionResponseDto' meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - BondHistoryEntry: + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta + UpdatePositionDto: type: object properties: - date: + quantity: + type: number + example: 15 + buyPrice: + type: number + example: 260 + buyDate: type: string - format: date - closePrice: - type: number - yieldClose: - type: number - nullable: true - duration: - type: number - nullable: true - required: [date, closePrice] - - BondHistoryResponse: + example: '2026-06-15' + notes: + type: string + example: Докупка + tags: + type: array + example: + - DIVIDEND + items: + type: string + enum: + - DIVIDEND + - GROWTH + - DEFENSIVE + - SPECULATIVE + - BOND + - ETF + - GOVERNMENT + - CASH + AnalyticsResponseDto: + type: object + properties: + positions: + type: array + items: + $ref: '#/components/schemas/PositionWithPriceDto' + summary: + $ref: '#/components/schemas/PortfolioSummaryDto' + required: + - positions + - summary + AnalyticsEnvelopeDto: type: object properties: data: - type: array - items: - $ref: "#/components/schemas/BondHistoryEntry" + $ref: '#/components/schemas/AnalyticsResponseDto' meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - # ── Dividend ── - Dividend: - type: object - properties: - registryCloseDate: - type: string - format: date - example: "2025-07-18" - value: - type: number - example: 34.84 - currency: - type: string - example: "RUB" - required: [registryCloseDate, value, currency] - - DividendsResponse: - type: object - properties: - data: - type: array - items: - $ref: "#/components/schemas/Dividend" - meta: - $ref: "#/components/schemas/ApiResponse/properties/meta" - - # ── Error ── - ErrorResponse: - type: object - properties: - statusCode: - type: integer - example: 404 - message: - type: string - example: "Instrument SBER_NOT_FOUND not found" - error: - type: string - example: "Not Found" - timestamp: - type: string - format: date-time - path: - type: string - example: "/api/v1/securities/shares/SBER_NOT_FOUND" - required: [statusCode, message, error, timestamp, path] - - responses: - BadRequest: - description: Неверный запрос - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - NotFound: - description: Инструмент не найден - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - -tags: - - name: Health - description: Мониторинг состояния сервиса - - name: Securities - description: Поиск инструментов - - name: Shares - description: Акции - - name: Bonds - description: Облигации + $ref: '#/components/schemas/PortfolioResponseMetaDto' + required: + - data + - meta diff --git a/docs/superpowers/plans/2026-06-14-quality-gate-contract-docs.md b/docs/superpowers/plans/2026-06-14-quality-gate-contract-docs.md new file mode 100644 index 0000000..f010472 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-quality-gate-contract-docs.md @@ -0,0 +1,1401 @@ +# Стабилизация Quality Gate, API-контракта и документации 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:** Сделать стандартные проверки MoexVibe детерминированными, синхронизировать OpenAPI-артефакты и обновить документацию под фактическое состояние репозитория. + +**Architecture:** Разделяем offline unit tests и opt-in live MOEX integration tests. Машинный API-контракт берём из NestJS Swagger JSON, затем синхронизируем `docs/openapi/openapi.yaml` и `apps/frontend/src/api/types.ts`. README, AGENTS и Docusaurus остаются onboarding-документацией и описывают текущий код, а не исторические планы. + +**Tech Stack:** npm workspaces, NestJS 10, Vitest 1 для backend, Vitest 4 для frontend, Docusaurus 3, Swagger/OpenAPI, openapi-typescript. + +--- + +## Файловая структура + +### Создать + +- `apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts` — opt-in live MOEX smoke tests. +- `apps/backend/src/openapi-artifacts.spec.ts` — offline проверка, что checked-in OpenAPI artifacts содержат актуальные paths. + +### Изменить + +- `apps/backend/package.json` — исключить integration specs из default backend tests и добавить opt-in integration command. +- `apps/backend/src/modules/moex-client/moex-client.service.spec.ts` — заменить live MOEX calls на mocked Axios unit tests. +- `apps/backend/src/modules/securities/securities.service.spec.ts` — заменить live MOEX calls на mocked `MoexClientService` и `CacheService`. +- `apps/backend/src/modules/candles/candles.service.spec.ts` — заменить live MOEX calls на mocked dependencies. +- `apps/backend/src/modules/shares/shares.service.spec.ts` — заменить live MOEX calls на mocked dependencies. +- `apps/backend/src/modules/bonds/bonds.service.spec.ts` — заменить live MOEX calls на mocked dependencies. +- `apps/backend/src/modules/securities/screener.service.spec.ts` — убрать неиспользуемый `moexClient` или начать явно проверять его вызовы. +- `apps/frontend/src/api/types.ts` — перегенерировать из текущего Swagger JSON. +- `docs/openapi/openapi.yaml` — обновить snapshot из текущего Swagger JSON. +- `README.md` — актуализировать scripts, tests и docs workspace. +- `AGENTS.md` — актуализировать workspace, команды, frontend tests, Husky и CI. +- `apps/docs/docs/intro.md` — сделать Docusaurus docs home на `/`. +- `apps/docs/docs/development/commands.md` — актуализировать root/backend/frontend/docs commands. +- `apps/docs/docs/development/testing.md` — описать backend, frontend и opt-in MOEX integration tests. +- `apps/docs/docs/development/codegen.md` — описать актуальную генерацию `types.ts` и `openapi.yaml`. +- `apps/docs/docs/frontend/overview.md` — добавить auth, portfolios, screener и test helpers. +- `apps/docs/docs/frontend/routes.md` — добавить текущие routes. +- `apps/docs/docs/frontend/api-client.md` — добавить auth, portfolio и screener API modules. +- `apps/docs/docs/backend/api.md` — добавить screener и portfolio endpoints. +- `apps/docs/docs/backend/portfolio.md` — исправить `PATCH /api/v1/portfolios/:id/patch` на `PATCH /api/v1/portfolios/:id`. + +--- + +## Task 1: Разделить backend unit tests и live MOEX integration tests + +**Files:** + +- Modify: `apps/backend/package.json` +- Modify: `apps/backend/src/modules/moex-client/moex-client.service.spec.ts` +- Create: `apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts` + +- [ ] **Step 1: Зафиксировать красное состояние default backend tests** + +Run: + +```bash +npm run test:backend +``` + +Expected: FAIL. В выводе есть `Vitest caught ... unhandled errors` и `DataCloneError` вокруг Axios `transformRequest`. + +- [ ] **Step 2: Обновить backend scripts** + +В `apps/backend/package.json` заменить scripts `test` и `test:watch`, добавить `test:integration`: + +```json +{ + "scripts": { + "postinstall": "prisma generate", + "build": "nest build", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,test}/**/*.ts\"", + "test": "VITE_CJS_IGNORE_WARNING=1 vitest run --exclude \"src/**/*.integration.spec.ts\"", + "test:watch": "vitest --exclude \"src/**/*.integration.spec.ts\"", + "test:integration": "MOEX_LIVE_TESTS=1 VITE_CJS_IGNORE_WARNING=1 vitest run \"src/**/*.integration.spec.ts\"" + } +} +``` + +- [ ] **Step 3: Создать opt-in live MOEX integration spec** + +Создать `apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts`: + +```typescript +import 'reflect-metadata'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { MoexClientService } from './moex-client.service'; +import configuration from '../../config/configuration'; + +describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')('MoexClientService live MOEX integration', () => { + let service: MoexClientService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ load: [configuration] })], + providers: [MoexClientService], + }).compile(); + + service = module.get(MoexClientService); + }); + + it('возвращает результаты поиска для SBER из live MOEX', async () => { + const results = await service.searchSecurities('SBER'); + + expect(results.length).toBeGreaterThan(0); + expect(results[0].secid).toBeDefined(); + }, 15000); + + it('возвращает рыночные данные SBER из live MOEX', async () => { + const data = await service.getShareMarketData('SBER'); + + expect(data).toBeDefined(); + expect(data!.secid).toBe('SBER'); + }, 15000); +}); +``` + +- [ ] **Step 4: Заменить `moex-client.service.spec.ts` на offline unit tests** + +Заменить содержимое `apps/backend/src/modules/moex-client/moex-client.service.spec.ts`: + +```typescript +import 'reflect-metadata'; +import axios from 'axios'; +import { ConfigService } from '@nestjs/config'; +import { MoexClientService } from './moex-client.service'; + +vi.mock('axios', () => ({ + default: { + create: vi.fn(), + }, +})); + +describe('MoexClientService', () => { + let service: MoexClientService; + let getMock: ReturnType; + + beforeEach(() => { + getMock = vi.fn(); + vi.mocked(axios.create).mockReturnValue({ get: getMock } as never); + + service = new MoexClientService({ + get: vi.fn((key: string, fallback?: unknown) => { + const values: Record = { + 'app.moex.baseUrl': 'https://iss.moex.test/iss', + 'app.moex.circuitBreakerThreshold': 5, + 'app.moex.circuitBreakerResetSeconds': 30, + 'app.moex.rateLimit': 10, + }; + return values[key] ?? fallback; + }), + } as unknown as ConfigService); + }); + + it('создаётся с настроенным MOEX client', () => { + expect(service).toBeDefined(); + expect(axios.create).toHaveBeenCalledWith({ + baseURL: 'https://iss.moex.test/iss', + timeout: 10000, + paramsSerializer: { indexes: null }, + }); + }); + + it('нормализует результаты поиска из ISS table format', async () => { + getMock.mockResolvedValueOnce({ + data: { + securities: { + columns: [ + 'secid', + 'isin', + 'name', + 'shortName', + 'latName', + 'listLevel', + 'issuesize', + 'facevalue', + 'faceunit', + 'issuedate', + 'typename', + 'group', + 'type', + 'isqualifiedinvestors', + 'morningsession', + 'eveningsession', + ], + data: [ + [ + 'SBER', + 'RU0009029540', + 'Сбербанк России ПАО ао', + 'Сбербанк', + 'Sberbank', + '1', + '21586948000', + '3', + 'SUR', + '2007-07-20', + 'Акция обыкновенная', + 'stock_shares', + 'common_share', + '0', + '1', + '1', + ], + ], + }, + }, + }); + + const results = await service.searchSecurities('SBER'); + + expect(getMock).toHaveBeenCalledWith('/securities.json', { + params: { q: 'SBER', 'iss.meta': 'off' }, + }); + expect(results).toEqual([ + { + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбербанк России ПАО ао', + shortName: 'Сбербанк', + latName: 'Sberbank', + listLevel: 1, + issueSize: 21586948000, + faceValue: 3, + faceUnit: 'SUR', + issueDate: '2007-07-20', + typeName: 'Акция обыкновенная', + group: 'stock_shares', + type: 'common_share', + isQualifiedInvestors: false, + morningSession: true, + eveningSession: true, + }, + ]); + }); + + it('нормализует market data акции без live MOEX запроса', async () => { + getMock.mockResolvedValueOnce({ + data: { + securities: { + columns: ['SECID', 'BOARDID', 'SHORTNAME', 'PREVPRICE'], + data: [['SBER', 'TQBR', 'Сбербанк', '320.10']], + }, + marketdata: { + columns: [ + 'SECID', + 'BOARDID', + 'BID', + 'OFFER', + 'OPEN', + 'LOW', + 'HIGH', + 'LAST', + 'LASTCHANGE', + 'LASTCHANGEPRCNT', + 'VOLTODAY', + 'VALTODAY', + 'WAPRICE', + 'NUMTRADES', + 'ISSUECAPITALIZATION', + 'TRADINGSTATUS', + 'UPDATETIME', + ], + data: [ + [ + 'SBER', + 'TQBR', + '321', + '322', + '320', + '319', + '323', + '322.35', + '1.15', + '0.36', + '1925163', + '620184479', + '321.9', + '12345', + '6958336818320', + 'T', + '10:30:00', + ], + ], + }, + }, + }); + + const data = await service.getShareMarketData('SBER'); + + expect(getMock).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER.json', { + params: { boards: 'TQBR', 'iss.meta': 'off' }, + }); + expect(data).toMatchObject({ + secid: 'SBER', + boardid: 'TQBR', + shortName: 'Сбербанк', + last: 322.35, + lastChange: 1.15, + lastChangePrcnt: 0.36, + volume: 1925163, + value: 620184479, + issueCapitalization: 6958336818320, + tradingStatus: 'T', + updateTime: '10:30:00', + }); + }); +}); +``` + +- [ ] **Step 5: Проверить offline unit spec** + +Run: + +```bash +npm run test -w apps/backend -- src/modules/moex-client/moex-client.service.spec.ts +``` + +Expected: PASS. В выводе нет `DataCloneError`. + +- [ ] **Step 6: Проверить, что live spec не попадает в default tests** + +Run: + +```bash +npm run test:backend +``` + +Expected: всё ещё может падать на других live service specs, но `moex-client.service.integration.spec.ts` не должен запускать live MOEX checks без `test:integration`. + +- [ ] **Step 7: Commit** + +```bash +git add apps/backend/package.json apps/backend/src/modules/moex-client/moex-client.service.spec.ts apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts +git commit -m "test: split moex live integration checks" +``` + +--- + +## Task 2: Перевести backend service specs на mocked dependencies и починить lint + +**Files:** + +- Modify: `apps/backend/src/modules/securities/securities.service.spec.ts` +- Modify: `apps/backend/src/modules/candles/candles.service.spec.ts` +- Modify: `apps/backend/src/modules/shares/shares.service.spec.ts` +- Modify: `apps/backend/src/modules/bonds/bonds.service.spec.ts` +- Modify: `apps/backend/src/modules/securities/screener.service.spec.ts` + +- [ ] **Step 1: Зафиксировать красное состояние lint** + +Run: + +```bash +npm run lint +``` + +Expected: FAIL с `moexClient is assigned a value but never used` в `screener.service.spec.ts`. + +- [ ] **Step 2: Заменить `securities.service.spec.ts`** + +Заменить содержимое `apps/backend/src/modules/securities/securities.service.spec.ts`: + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { SecuritiesService } from './securities.service'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import { SecurityType } from './dto/search-query.dto'; + +describe('SecuritiesService', () => { + let service: SecuritiesService; + let moexClient: Pick; + let cache: Pick; + + beforeEach(async () => { + moexClient = { + searchSecurities: vi.fn(), + } as unknown as Pick; + cache = { + getOrFetch: vi.fn(async (_prefix, _parts, fetchFn) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: '2026-06-14T00:00:00.000Z', + })), + } as unknown as Pick; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SecuritiesService, + { provide: MoexClientService, useValue: moexClient }, + { provide: CacheService, useValue: cache }, + ], + }).compile(); + + service = module.get(SecuritiesService); + }); + + it('возвращает только поддерживаемые инструменты и нормализует валюту SUR в RUB', async () => { + vi.mocked(moexClient.searchSecurities).mockResolvedValue([ + { + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбербанк России ПАО ао', + shortName: 'Сбербанк', + latName: null, + listLevel: 1, + issueSize: 21586948000, + faceValue: 3, + faceUnit: 'SUR', + issueDate: '2007-07-20', + typeName: 'Акция обыкновенная', + group: 'stock_shares', + type: 'common_share', + isQualifiedInvestors: false, + morningSession: true, + eveningSession: true, + }, + { + secid: 'SU26238RMFS5', + isin: 'RU000A106ZJ4', + name: 'ОФЗ 26238', + shortName: 'ОФЗ 26238', + latName: null, + listLevel: 1, + issueSize: 500000000, + faceValue: 1000, + faceUnit: 'SUR', + issueDate: '2021-05-15', + typeName: 'ОФЗ', + group: 'stock_bonds', + type: 'ofz_bond', + isQualifiedInvestors: false, + morningSession: false, + eveningSession: false, + }, + { + secid: 'FUT', + isin: '', + name: 'Фьючерс', + shortName: 'Фьючерс', + latName: null, + listLevel: 0, + issueSize: 0, + faceValue: 0, + faceUnit: '', + issueDate: '', + typeName: 'Фьючерс', + group: 'futures', + type: 'futures', + isQualifiedInvestors: false, + morningSession: false, + eveningSession: false, + }, + ]); + + const results = await service.search('SBER', SecurityType.ALL, 10); + + expect(results).toEqual([ + { + secid: 'SBER', + isin: 'RU0009029540', + shortName: 'Сбербанк', + type: 'share', + listLevel: 1, + currency: 'RUB', + price: null, + }, + { + secid: 'SU26238RMFS5', + isin: 'RU000A106ZJ4', + shortName: 'ОФЗ 26238', + type: 'bond', + listLevel: 1, + currency: 'RUB', + price: null, + }, + ]); + }); + + it('фильтрует search results по типу и limit без live MOEX', async () => { + vi.mocked(moexClient.searchSecurities).mockResolvedValue([ + { + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбербанк России ПАО ао', + shortName: 'Сбербанк', + latName: null, + listLevel: 1, + issueSize: 21586948000, + faceValue: 3, + faceUnit: 'SUR', + issueDate: '', + typeName: '', + group: 'stock_shares', + type: 'common_share', + isQualifiedInvestors: false, + morningSession: false, + eveningSession: false, + }, + { + secid: 'GAZP', + isin: 'RU0007661625', + name: 'Газпром', + shortName: 'Газпром', + latName: null, + listLevel: 1, + issueSize: 0, + faceValue: 5, + faceUnit: 'SUR', + issueDate: '', + typeName: '', + group: 'stock_shares', + type: 'common_share', + isQualifiedInvestors: false, + morningSession: false, + eveningSession: false, + }, + ]); + + const results = await service.search('S', SecurityType.SHARE, 1); + + expect(results).toHaveLength(1); + expect(results[0].secid).toBe('SBER'); + expect(cache.getOrFetch).toHaveBeenCalledWith( + 'search', + ['s'], + expect.any(Function), + 'searchTtl', + ); + }); +}); +``` + +- [ ] **Step 3: Заменить `candles.service.spec.ts`** + +Заменить содержимое `apps/backend/src/modules/candles/candles.service.spec.ts`: + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { CandlesService } from './candles.service'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import { CandleInterval } from './dto/candles-query.dto'; + +describe('CandlesService', () => { + let service: CandlesService; + let moexClient: Pick; + let cache: Pick; + + beforeEach(async () => { + moexClient = { + getCandles: vi.fn(), + } as unknown as Pick; + cache = { + getOrFetch: vi.fn(async (_prefix, _parts, fetchFn) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: '2026-06-14T00:00:00.000Z', + })), + } as unknown as Pick; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + CandlesService, + { provide: MoexClientService, useValue: moexClient }, + { provide: CacheService, useValue: cache }, + ], + }).compile(); + + service = module.get(CandlesService); + }); + + it('мапит дневные свечи акции и использует MOEX interval 24', async () => { + vi.mocked(moexClient.getCandles).mockResolvedValue([ + { + open: 320, + high: 323, + low: 319, + close: 322.35, + volume: 1925163, + value: 620184479, + begin: '2026-06-01 00:00:00', + end: '2026-06-01 23:59:59', + }, + ]); + + const result = await service.getCandles( + 'shares', + 'SBER', + CandleInterval.DAY, + '2026-06-01', + '2026-06-14', + ); + + expect(moexClient.getCandles).toHaveBeenCalledWith( + 'stock', + 'shares', + 'SBER', + 24, + '2026-06-01', + '2026-06-14', + ); + expect(result).toEqual({ + data: [ + { + open: 320, + high: 323, + low: 319, + close: 322.35, + volume: 1925163, + value: 620184479, + begin: '2026-06-01 00:00:00', + end: '2026-06-01 23:59:59', + }, + ], + meta: { fromCache: false, cachedAt: '2026-06-14T00:00:00.000Z' }, + }); + }); + + it('мапит часовые свечи облигации и использует MOEX interval 60', async () => { + vi.mocked(moexClient.getCandles).mockResolvedValue([ + { + open: 98, + high: 98.5, + low: 97.9, + close: 98.2, + volume: 1000, + value: 982000, + begin: '2026-06-01 10:00:00', + end: '2026-06-01 10:59:59', + }, + ]); + + const result = await service.getCandles( + 'bonds', + 'SU26238RMFS5', + CandleInterval.HOUR, + '2026-06-01', + '2026-06-14', + ); + + expect(moexClient.getCandles).toHaveBeenCalledWith( + 'stock', + 'bonds', + 'SU26238RMFS5', + 60, + '2026-06-01', + '2026-06-14', + ); + expect(result.data[0].close).toBe(98.2); + }); +}); +``` + +- [ ] **Step 4: Заменить `shares.service.spec.ts`** + +Заменить содержимое `apps/backend/src/modules/shares/shares.service.spec.ts`: + +```typescript +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { SharesService } from './shares.service'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; + +describe('SharesService', () => { + let service: SharesService; + let moexClient: Pick; + let cache: Pick; + + beforeEach(async () => { + moexClient = { + getSecurityDescription: vi.fn(), + getShareMarketData: vi.fn(), + } as unknown as Pick; + cache = { + getOrFetch: vi.fn(async (_prefix, _parts, fetchFn) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: '2026-06-14T00:00:00.000Z', + })), + } as unknown as Pick; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SharesService, + { provide: MoexClientService, useValue: moexClient }, + { provide: CacheService, useValue: cache }, + ], + }).compile(); + + service = module.get(SharesService); + }); + + it('возвращает спецификацию акции и market data без live MOEX', async () => { + vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({ + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбербанк России ПАО ао', + shortName: 'Сбербанк', + latName: null, + listLevel: 1, + issueSize: 21586948000, + faceValue: 3, + faceUnit: 'SUR', + issueDate: '2007-07-20', + typeName: 'Акция обыкновенная', + group: 'stock_shares', + type: 'common_share', + isQualifiedInvestors: false, + morningSession: true, + eveningSession: true, + }); + vi.mocked(moexClient.getShareMarketData).mockResolvedValue({ + secid: 'SBER', + boardid: 'TQBR', + shortName: 'Сбербанк', + bid: 321, + offer: 322, + open: 320, + low: 319, + high: 323, + last: 322.35, + lastChange: 1.15, + lastChangePrcnt: 0.36, + volume: 1925163, + value: 620184479, + waprice: 321.9, + numtrades: 12345, + issueCapitalization: 6958336818320, + tradingStatus: 'T', + updateTime: '10:30:00', + }); + + const share = await service.getShare('SBER'); + + expect(share).toMatchObject({ + secid: 'SBER', + isin: 'RU0009029540', + faceUnit: 'RUB', + marketData: { + price: 322.35, + change: 1.15, + changePercent: 0.36, + open: 320, + high: 323, + low: 319, + volume: 1925163, + value: 620184479, + issueCapitalization: 6958336818320, + }, + }); + }); + + it('выбрасывает NotFoundException для неакции', async () => { + vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({ + secid: 'SU26238RMFS5', + isin: 'RU000A106ZJ4', + name: 'ОФЗ', + shortName: 'ОФЗ', + latName: null, + listLevel: 1, + issueSize: 500000000, + faceValue: 1000, + faceUnit: 'SUR', + issueDate: '', + typeName: 'ОФЗ', + group: 'stock_bonds', + type: 'ofz_bond', + isQualifiedInvestors: false, + morningSession: false, + eveningSession: false, + }); + + await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(NotFoundException); + }); +}); +``` + +- [ ] **Step 5: Заменить `bonds.service.spec.ts`** + +Заменить содержимое `apps/backend/src/modules/bonds/bonds.service.spec.ts`: + +```typescript +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { BondsService } from './bonds.service'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; + +describe('BondsService', () => { + let service: BondsService; + let moexClient: Pick; + let cache: Pick; + + beforeEach(async () => { + moexClient = { + getBondData: vi.fn(), + getBondMarketData: vi.fn(), + } as unknown as Pick; + cache = { + getOrFetch: vi.fn(async (_prefix, _parts, fetchFn) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: '2026-06-14T00:00:00.000Z', + })), + } as unknown as Pick; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BondsService, + { provide: MoexClientService, useValue: moexClient }, + { provide: CacheService, useValue: cache }, + ], + }).compile(); + + service = module.get(BondsService); + }); + + it('возвращает спецификацию облигации и market data без live MOEX', async () => { + vi.mocked(moexClient.getBondData).mockResolvedValue({ + secid: 'SU26238RMFS5', + boardid: 'TQCB', + shortName: 'ОФЗ 26238', + prevWaprice: 98.1, + yieldAtPrevWaprice: 12.3, + couponValue: 34.9, + nextCoupon: '2026-12-01', + accruedInt: 12.45, + prevPrice: 98, + lotSize: 1, + faceValue: 1000, + matDate: '2041-05-15', + couponPeriod: 182, + issueSize: 500000000, + isin: 'RU000A106ZJ4', + couponPercent: 6.98, + offerDate: null, + buybackDate: null, + bondType: 'ОФЗ-ПД', + bondSubType: '', + listLevel: 1, + }); + vi.mocked(moexClient.getBondMarketData).mockResolvedValue({ + secid: 'SU26238RMFS5', + bid: 98.1, + offer: 98.4, + open: 98, + low: 97.9, + high: 98.6, + last: 98.45, + yield: 12.1, + waprice: 98.2, + yieldAtWaprice: 12.2, + duration: 8.34, + volume: 1500000, + value: 1476750000, + numtrades: 100, + tradingStatus: 'T', + updateTime: '10:30:00', + }); + + const result = await service.getBond('SU26238RMFS5'); + + expect(result).toMatchObject({ + data: { + secid: 'SU26238RMFS5', + isin: 'RU000A106ZJ4', + faceValue: 1000, + faceUnit: 'RUB', + marketData: { + price: 98.45, + yieldToMaturity: 12.1, + duration: 8.34, + accruedInt: 12.45, + volume: 1500000, + }, + }, + meta: { fromCache: false, cachedAt: '2026-06-14T00:00:00.000Z' }, + }); + }); + + it('выбрасывает NotFoundException, если MOEX не вернул bond data', async () => { + vi.mocked(moexClient.getBondData).mockResolvedValue(null); + + await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(NotFoundException); + }); +}); +``` + +- [ ] **Step 6: Обновить `screener.service.spec.ts` без неиспользуемого `moexClient`** + +В `apps/backend/src/modules/securities/screener.service.spec.ts` удалить объявление и присваивание `moexClient`, если тесты продолжают полностью подставлять данные через `cache.getOrFetch`: + +```typescript +describe('ScreenerService', () => { + let service: ScreenerService; + let cache: CacheService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ScreenerService, + { + provide: MoexClientService, + useValue: { + getShareMarketDataBatch: vi.fn(), + getBondPositionDataBatch: vi.fn(), + }, + }, + { + provide: CacheService, + useValue: { + getOrFetch: vi.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ScreenerService); + cache = module.get(CacheService); + }); +}); +``` + +Оставить существующие `screen` test cases ниже этого `beforeEach`. + +- [ ] **Step 7: Проверить backend lint и backend tests** + +Run: + +```bash +npm run lint +npm run test:backend +``` + +Expected: оба command exits 0. В backend test output нет `DataCloneError`. + +- [ ] **Step 8: Commit** + +```bash +git add apps/backend/src/modules/securities/securities.service.spec.ts apps/backend/src/modules/candles/candles.service.spec.ts apps/backend/src/modules/shares/shares.service.spec.ts apps/backend/src/modules/bonds/bonds.service.spec.ts apps/backend/src/modules/securities/screener.service.spec.ts +git commit -m "test: make backend service specs deterministic" +``` + +--- + +## Task 3: Добавить offline проверку OpenAPI artifacts + +**Files:** + +- Create: `apps/backend/src/openapi-artifacts.spec.ts` +- Modify later in Task 4: `apps/frontend/src/api/types.ts` +- Modify later in Task 4: `docs/openapi/openapi.yaml` + +- [ ] **Step 1: Написать failing test для checked-in OpenAPI artifacts** + +Создать `apps/backend/src/openapi-artifacts.spec.ts`: + +```typescript +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +describe('checked-in OpenAPI artifacts', () => { + const rootDir = resolve(process.cwd(), '../..'); + const frontendTypes = readFileSync(join(rootDir, 'apps/frontend/src/api/types.ts'), 'utf8'); + const openapiYaml = readFileSync(join(rootDir, 'docs/openapi/openapi.yaml'), 'utf8'); + + const requiredPaths = [ + '/api/v1/auth/register', + '/api/v1/auth/login', + '/api/v1/auth/refresh', + '/api/v1/auth/logout', + '/api/v1/auth/me', + '/api/v1/securities/screener', + '/api/v1/portfolios', + '/api/v1/portfolios/{id}', + '/api/v1/portfolios/{id}/positions', + '/api/v1/portfolios/{id}/positions/{positionId}', + '/api/v1/portfolios/{id}/analytics', + ]; + + it('frontend generated types include current protected domains', () => { + for (const path of requiredPaths) { + expect(frontendTypes).toContain(`'${path}'`); + } + }); + + it('static OpenAPI YAML snapshot includes current protected domains', () => { + for (const path of requiredPaths) { + expect(openapiYaml).toContain(`${path}:`); + } + }); +}); +``` + +- [ ] **Step 2: Запустить test и убедиться, что он падает по ожидаемой причине** + +Run: + +```bash +npm run test -w apps/backend -- src/openapi-artifacts.spec.ts +``` + +Expected: FAIL. В выводе есть missing `'/api/v1/auth/register'` или другой path из `requiredPaths`. + +- [ ] **Step 3: Commit только failing artifact test** + +```bash +git add apps/backend/src/openapi-artifacts.spec.ts +git commit -m "test: cover checked-in openapi artifacts" +``` + +--- + +## Task 4: Синхронизировать Swagger JSON, frontend types и static OpenAPI YAML + +**Files:** + +- Modify: `apps/frontend/src/api/types.ts` +- Modify: `docs/openapi/openapi.yaml` +- Optional Modify: backend controller DTO metadata if `src/openapi-artifacts.spec.ts` still fails after regeneration. + +- [ ] **Step 1: Запустить backend для codegen** + +Run in a long-running terminal: + +```bash +npm run dev:backend +``` + +Expected: backend starts on `http://localhost:3000`, Swagger UI is available at `http://localhost:3000/api/docs`. + +- [ ] **Step 2: Проверить Swagger JSON содержит текущие paths** + +Run in a second terminal: + +```bash +node -e "fetch('http://localhost:3000/api/docs-json').then(r => r.json()).then(j => { const paths = Object.keys(j.paths); for (const p of ['/api/v1/auth/register','/api/v1/securities/screener','/api/v1/portfolios','/api/v1/portfolios/{id}/analytics']) { if (!paths.includes(p)) throw new Error('Missing path ' + p); } console.log('Swagger paths OK'); })" +``` + +Expected: prints `Swagger paths OK`. + +- [ ] **Step 3: Если Swagger JSON не содержит path, добавить metadata без runtime изменений** + +Если Step 2 падает из-за missing path, проверить соответствующий controller. Для `PortfolioController` базовый минимум должен выглядеть так: + +```typescript +@ApiTags('Portfolios') +@ApiBearerAuth() +@Controller('portfolios') +export class PortfolioController { + @Get() + @ApiOperation({ summary: 'Get all portfolios for current user' }) + @ApiOkResponse({ type: PortfolioListResponseDto, isArray: true }) + async findAll(@CurrentUser() user: { sub: number }) { + const portfolios = await this.portfolioService.findAll(user.sub); + return { data: portfolios, meta: { cachedAt: null, fromCache: false } }; + } +} +``` + +Для `SecuritiesController` screener endpoint должен иметь `@ApiOkResponse({ type: ScreenerResultDto })`, он уже есть в текущем коде. Для auth routes path обычно появляется от `@Controller('auth')` и method decorators даже без response DTO. + +- [ ] **Step 4: Перегенерировать frontend OpenAPI types** + +Run: + +```bash +npm run codegen -w apps/frontend +``` + +Expected: `apps/frontend/src/api/types.ts` changes and includes auth, screener and portfolio paths. + +- [ ] **Step 5: Перегенерировать static YAML snapshot** + +Run: + +```bash +node -e "fetch('http://localhost:3000/api/docs-json').then(r => r.json()).then(j => require('node:fs').writeFileSync('/tmp/moex-vibe-openapi.json', JSON.stringify(j, null, 2)))" +node -e "const fs = require('node:fs'); const yaml = require('js-yaml'); const json = JSON.parse(fs.readFileSync('/tmp/moex-vibe-openapi.json', 'utf8')); fs.writeFileSync('docs/openapi/openapi.yaml', yaml.dump(json, { lineWidth: 120, noRefs: true }));" +``` + +Expected: `docs/openapi/openapi.yaml` changes and includes `/api/v1/auth/register`, `/api/v1/securities/screener`, `/api/v1/portfolios`. + +- [ ] **Step 6: Проверить artifact test теперь зелёный** + +Run: + +```bash +npm run test -w apps/backend -- src/openapi-artifacts.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Проверить backend/frontend build после codegen** + +Run: + +```bash +npm run build:backend +npm run build:frontend +``` + +Expected: both commands exit 0. + +- [ ] **Step 8: Commit** + +```bash +git add apps/frontend/src/api/types.ts docs/openapi/openapi.yaml apps/backend/src/openapi-artifacts.spec.ts apps/backend/src/modules +git commit -m "docs: refresh openapi contract artifacts" +``` + +--- + +## Task 5: Обновить README, AGENTS и Docusaurus docs на русском + +**Files:** + +- Modify: `README.md` +- Modify: `AGENTS.md` +- Modify: `apps/docs/docs/intro.md` +- Modify: `apps/docs/docs/development/commands.md` +- Modify: `apps/docs/docs/development/testing.md` +- Modify: `apps/docs/docs/development/codegen.md` +- Modify: `apps/docs/docs/frontend/overview.md` +- Modify: `apps/docs/docs/frontend/routes.md` +- Modify: `apps/docs/docs/frontend/api-client.md` +- Modify: `apps/docs/docs/backend/api.md` +- Modify: `apps/docs/docs/backend/portfolio.md` + +- [ ] **Step 1: Зафиксировать текущий docs warning** + +Run: + +```bash +npm run build:docs +``` + +Expected: command exits 0, but output includes Docusaurus broken links to `/`. + +- [ ] **Step 2: Сделать intro docs home на `/`** + +В начало `apps/docs/docs/intro.md` добавить front matter: + +```markdown +--- +slug: / +--- + +# MoexVibe +``` + +Остальной текст страницы оставить и обновить структуру репозитория, чтобы в `apps/` были `backend`, `frontend`, `docs`. + +- [ ] **Step 3: Обновить root command table в `apps/docs/docs/development/commands.md`** + +Заменить секцию `## Root Workspace` на: + +```markdown +## Root Workspace + +| Command | Description | +|---|---| +| `npm run dev:backend` | Запуск NestJS в режиме watch на `:3000` | +| `npm run dev:frontend` | Vite dev-сервер на `:5173`, проксирует `/api` на backend | +| `npm run dev:docs` | Docusaurus dev-сервер документации | +| `npm run build:backend` | `nest build` | +| `npm run build:frontend` | `tsc -b && vite build` | +| `npm run build:docs` | `docusaurus build` | +| `npm run test:backend` | Offline backend unit tests через Vitest | +| `npm run test:frontend` | Frontend tests через Vitest + Testing Library | +| `npm run lint` | ESLint для backend и frontend | +| `npm run format` | Prettier для всех `*.{ts,tsx}` | +| `npm run format:check` | Проверка Prettier для всех `*.{ts,tsx}` | +``` + +В `## Backend Workspace` добавить: + +```markdown +| `npm run test:integration -w apps/backend` | Opt-in live MOEX integration tests, требуется network access | +``` + +В `## Frontend Workspace` добавить: + +```markdown +| `npm run lint -w apps/frontend` | ESLint для `src/**/*.{ts,tsx}` | +| `npm run test -w apps/frontend` | Frontend Vitest suite | +``` + +Добавить `## Docs Workspace`: + +```markdown +## Docs Workspace + +| Command | Description | +|---|---| +| `npm run dev -w apps/docs` | Docusaurus dev-server | +| `npm run build -w apps/docs` | Production build документации | +| `npm run serve -w apps/docs` | Локальная проверка production build | +``` + +- [ ] **Step 4: Обновить `apps/docs/docs/development/testing.md`** + +Заменить финальную секцию `## Frontend Tests` на: + +````markdown +## Frontend Tests + +Фреймворк: **Vitest 4** + **React Testing Library** + **MSW**. + +Запуск: + +```bash +npm run test:frontend +# или +npm run test -w apps/frontend +``` + +Тесты покрывают API-клиент, auth context, hooks, базовые pages и shared components. + +## Live MOEX Integration Tests + +Live MOEX checks вынесены из default backend suite. + +```bash +npm run test:integration -w apps/backend +``` + +Эта команда opt-in: она требует network access и может падать при недоступности MOEX или сетевых +ограничениях окружения. +```` + +- [ ] **Step 5: Обновить `apps/docs/docs/frontend/routes.md`** + +Заменить route table на: + +```markdown +| Path | Component | Access | Description | +|---|---|---|---| +| `/` | `HomePage` | Public | Главная страница | +| `/stocks/:secid` | `StockPage` | Public | Страница акции | +| `/bonds/:secid` | `BondPage` | Public | Страница облигации | +| `/screener` | `ScreenerPage` | Public | Скринер ценных бумаг | +| `/login` | `LoginPage` | Public | Вход | +| `/register` | `RegisterPage` | Public | Регистрация | +| `/profile` | `ProfilePage` | Protected | Профиль текущего пользователя | +| `/portfolios` | `PortfoliosListPage` | Protected | Список портфелей | +| `/portfolios/:id` | `PortfolioDetailPage` | Protected | Детальная страница портфеля | +``` + +Обновить JSX snippet, чтобы он соответствовал `apps/frontend/src/routes.tsx`. + +- [ ] **Step 6: Обновить `apps/docs/docs/frontend/api-client.md`** + +Добавить в таблицу API functions: + +```markdown +| `register(data)` | POST | `/api/v1/auth/register` | +| `login(data)` | POST | `/api/v1/auth/login` | +| `refresh()` | POST | `/api/v1/auth/refresh` | +| `logout()` | POST | `/api/v1/auth/logout` | +| `getProfile()` | GET | `/api/v1/auth/me` | +| `updateProfile(data)` | PATCH | `/api/v1/auth/me` | +| `screenSecurities(query)` | GET | `/api/v1/securities/screener` | +| `getPortfolios()` | GET | `/api/v1/portfolios` | +| `createPortfolio(data)` | POST | `/api/v1/portfolios` | +| `getPortfolio(id)` | GET | `/api/v1/portfolios/:id` | +| `updatePortfolio(id, data)` | PATCH | `/api/v1/portfolios/:id` | +| `deletePortfolio(id)` | DELETE | `/api/v1/portfolios/:id` | +| `addPosition(portfolioId, data)` | POST | `/api/v1/portfolios/:id/positions` | +| `updatePosition(portfolioId, positionId, data)` | PATCH | `/api/v1/portfolios/:id/positions/:positionId` | +| `removePosition(portfolioId, positionId)` | DELETE | `/api/v1/portfolios/:id/positions/:positionId` | +| `getPortfolioAnalytics(portfolioId)` | GET | `/api/v1/portfolios/:id/analytics` | +``` + +- [ ] **Step 7: Обновить `apps/docs/docs/backend/portfolio.md`** + +В API table заменить строку update: + +```markdown +| `/api/v1/portfolios/:id` | PATCH | Update portfolio (name, description, currency) | +``` + +- [ ] **Step 8: Обновить README и AGENTS** + +В `README.md` добавить docs workspace и frontend tests: + +````markdown +## Tests + +```bash +npm run test:backend +npm run test:frontend +``` + +Live MOEX integration checks are opt-in: + +```bash +npm run test:integration -w apps/backend +``` +```` + +В `AGENTS.md` обновить: + +```markdown +npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/frontend` (React + Vite), `apps/docs` (Docusaurus). +``` + +И строки: + +```markdown +| `npm run test:frontend` | Frontend Vitest suite | +| `npm run build:docs` | `docusaurus build` | +| `npm run dev:docs` | Docusaurus dev-сервер | +| `npm run lint` | ESLint для backend и frontend | +``` + +Заменить утверждения: + +```markdown +- Тесты фронтенда есть: Vitest + Testing Library + MSW. +- CI находится в `.gitea/workflows/ci.yml`. +- Pre-commit checks настроены через Husky и lint-staged. +``` + +- [ ] **Step 9: Проверить docs build** + +Run: + +```bash +npm run build:docs +``` + +Expected: command exits 0. В выводе нет Docusaurus broken links to `/`. Warning про `/Users/ksv741/.config` может остаться, потому что это внешняя update-check настройка вне репозитория. + +- [ ] **Step 10: Commit** + +```bash +git add README.md AGENTS.md apps/docs/docs/intro.md apps/docs/docs/development/commands.md apps/docs/docs/development/testing.md apps/docs/docs/development/codegen.md apps/docs/docs/frontend/overview.md apps/docs/docs/frontend/routes.md apps/docs/docs/frontend/api-client.md apps/docs/docs/backend/api.md apps/docs/docs/backend/portfolio.md +git commit -m "docs: refresh project documentation" +``` + +--- + +## Task 6: Финальная проверка quality gate + +**Files:** + +- No direct edits expected. +- Verification over repository root. + +- [ ] **Step 1: Запустить полный набор проверок** + +Run: + +```bash +npm run lint +npm run test:backend +npm run test:frontend +npm run build:backend +npm run build:frontend +npm run build:docs +npm run format:check +``` + +Expected: all commands exit 0. `npm run build:docs` не сообщает Docusaurus broken links на `/`. + +- [ ] **Step 2: Проверить git status** + +Run: + +```bash +git status --short +``` + +Expected: empty output. + +- [ ] **Step 3: Если format changed files, сделать отдельный commit** + +Run only if formatting changed files: + +```bash +git add . +git commit -m "chore: apply formatting after quality gate refresh" +``` + +Expected: commit created only when `git status --short` showed formatting changes. + +--- + +## Self-Review плана + +- Spec coverage: Task 1 и Task 2 стабилизируют default checks; Task 3 и Task 4 покрывают OpenAPI artifacts; Task 5 покрывает README, AGENTS и Docusaurus; Task 6 покрывает финальную verification matrix. +- Placeholder scan: placeholder markers и незавершённые инструкции отсутствуют. +- Type consistency: test snippets используют существующие `MoexClientService`, `CacheService`, `SecurityType`, `CandleInterval` и DTO paths. +- Scope check: план не включает feature changes, глубокий refactor сервисов или rewrite frontend API client. diff --git a/docs/superpowers/specs/2026-06-14-quality-gate-contract-docs-design.md b/docs/superpowers/specs/2026-06-14-quality-gate-contract-docs-design.md new file mode 100644 index 0000000..97c01e8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-quality-gate-contract-docs-design.md @@ -0,0 +1,333 @@ +# Дизайн стабилизации quality gate, API-контракта и документации + +## Статус + +Одобрено для спецификации 2026-06-14. + +## PRD + +### Проблема + +В MoexVibe накопился координационный технический долг между тестами, сгенерированными контрактами +и документацией. Приложение всё ещё собирается, но стандартный backend quality gate сейчас нельзя +считать надёжным: + +- `npm run lint` падает из-за неиспользуемой переменной в backend-тесте. +- `npm run test:backend` падает, потому что часть backend-тестов обращается к живому MOEX API через + настоящий `MoexClientService`. +- Ошибки live MOEX-запросов проявляются как Vitest `DataCloneError`, потому что `AxiosError` + содержит функции в конфигурации запроса, которые нельзя клонировать между worker'ами. +- `docs/openapi/openapi.yaml` и `apps/frontend/src/api/types.ts` не содержат актуальные эндпоинты + `auth`, `portfolios` и `securities/screener`. +- README, AGENTS и страницы Docusaurus местами описывают старое состояние репозитория. +- `npm run build:docs` успешно генерирует статические файлы, но выводит предупреждения Docusaurus о + broken link на `/`. + +Из-за этого следующие задачи делать медленнее: разработчику неочевидно, какой вывод команд важен, +какой API-контракт актуален и почему backend-тесты падают: из-за поведения приложения или из-за +сетевой зависимости. + +### Цели + +1. Сделать стандартные проверки детерминированными и пригодными для локальной разработки и CI. +2. Сохранить live MOEX-проверки, но вынести их из стандартного unit-test пути. +3. Вернуть понятный источник правды для OpenAPI-контракта. +4. Перегенерировать или синхронизировать frontend OpenAPI-типы с текущими backend routes. +5. Привести README, AGENTS и Docusaurus-документацию к текущему состоянию репозитория. +6. Убрать actionable предупреждения Docusaurus о broken links из `npm run build:docs`. + +### Не входит в задачу + +- Пользовательские feature-изменения. +- Глубокий рефакторинг `PortfolioService` или `MoexClientService`. +- Переход frontend API-клиента на полностью сгенерированный клиент. +- Redis, изменение схемы БД, redesign auth-модели или portfolio analytics. +- Миграция CI-провайдера. + +## Текущие находки + +### Проходящие проверки + +- `npm run test:frontend` проходит: 22 файла, 95 тестов. +- `npm run format:check` проходит. +- `npm run build:backend` проходит. +- `npm run build:frontend` проходит. +- `npm run build:docs` успешно генерирует статические файлы. + +### Падающие или шумные проверки + +- `npm run lint` падает в `apps/backend/src/modules/securities/screener.service.spec.ts`, потому + что `moexClient` присваивается, но не используется. +- `npm run test:backend` падает с unhandled Vitest errors. Затронутые specs создают настоящий + `MoexClientService` и ходят в MOEX: + - `apps/backend/src/modules/moex-client/moex-client.service.spec.ts` + - `apps/backend/src/modules/securities/securities.service.spec.ts` + - `apps/backend/src/modules/candles/candles.service.spec.ts` + - похожие service specs для shares и bonds тоже зависят от live MOEX-доступа. +- `npm run build:docs` предупреждает, что многие страницы ссылаются на `/`. + +### Устаревшая документация и контрактные артефакты + +- `AGENTS.md` говорит, что `npm run lint` проверяет только backend, а frontend-тестов нет. +- `apps/docs/docs/development/testing.md` говорит, что frontend-тестов нет. +- `apps/docs/docs/development/commands.md` не описывает `test:frontend`, docs scripts и frontend + lint. +- `apps/docs/docs/frontend/routes.md` не описывает `/login`, `/register`, `/profile`, + `/portfolios`, `/portfolios/:id` и `/screener`. +- `apps/docs/docs/frontend/overview.md` не описывает auth, portfolio, screener и test-директории. +- `apps/docs/docs/backend/api.md` не содержит portfolio и screener endpoints. +- `apps/docs/docs/backend/portfolio.md` документирует `PATCH /api/v1/portfolios/:id/patch`, хотя + controller реализует `PATCH /api/v1/portfolios/:id`. +- `docs/openapi/openapi.yaml` и `apps/frontend/src/api/types.ts` содержат только ранние paths для + health, search, shares, bonds и candles. + +## Доменная модель + +### Quality Gate + +Quality gate: это повторяемая команда, которую разработчик может запускать без внешних зависимостей, +если сама команда явно не говорит обратного. + +Стандартные проверки: + +- `npm run lint` +- `npm run test:backend` +- `npm run test:frontend` +- `npm run build:backend` +- `npm run build:frontend` +- `npm run build:docs` +- `npm run format:check` + +Opt-in проверки: + +- Live MOEX integration checks. Они могут требовать network access и не должны запускаться в + стандартных unit tests или CI jobs без явного запроса. + +### Источник API-контракта + +Авторитетный backend contract: NestJS Swagger document, который генерируется из controllers и DTO +decorators по `/api/docs-json`. + +Сгенерированные или синхронизированные артефакты: + +- `docs/openapi/openapi.yaml`: checked-in человекочитаемый snapshot. +- `apps/frontend/src/api/types.ts`: сгенерированные TypeScript path и schema types. +- Docusaurus API pages: поясняющая документация, но не канонический machine contract. + +### Источник документации + +Документация должна описывать текущее состояние репозитория, а не исторический план реализации. +Superpowers specs и plans остаются историей проекта. README, AGENTS и Docusaurus pages являются +актуальной onboarding-поверхностью. + +## ADR + +### Решение + +Разделить backend tests на детерминированные unit tests и opt-in live MOEX integration tests. + +### Обоснование + +Стандартная backend test command сейчас смешивает unit-поведение и доступность внешней сети. Это +делает failures неоднозначными и порождает шумные Vitest serialization errors, когда Axios +возвращает rejection с non-cloneable configuration fields. Unit tests должны проверять логику +приложения на контролируемых fixtures. Live MOEX tests полезны, но должны быть отдельной явно +названной командой с понятным требованием к окружению. + +### Последствия + +- `npm run test:backend` становится стабильной offline-командой. +- Live MOEX coverage остаётся доступным через отдельную integration command. +- Часть существующих specs изменится с "real MOEX smoke test" на "service behavior with mocked + `MoexClientService`". +- Contract drift станет видимым, потому что OpenAPI snapshots и frontend generated types будут + обновлены в рамках этой работы. + +## Backend Architecture + +### Граница unit tests + +Service tests для `SharesService`, `BondsService`, `CandlesService` и `SecuritiesService` должны +mock'ать `MoexClientService` и `CacheService`. + +Mocked data должны проверять поведение, важное для MoexVibe: + +- нормализованные share data возвращаются из cached или fetched MOEX client data; +- нормализованные bond data корректно обрабатывают отсутствующие market fields как nullable values; +- candles мапятся в public response shape; +- search и screener используют детерминированные fixture rows; +- cache metadata остаётся представленной через `{ data, meta }`, где этого требуют service + contracts. + +### Граница live integration tests + +Live MOEX checks должны быть изолированы в `*.integration.spec.ts` files или эквивалентном явном +test path. Они должны запускаться только через отдельную команду, например +`npm run test:integration -w apps/backend`, и должны документировать, что для них требуется network +access. + +Integration command не должна входить в стандартный `npm run test:backend`. + +### OpenAPI decorators + +Существующие controllers должны отдавать достаточно Swagger metadata для generated path types: + +- Auth routes: register, login, refresh, logout, me, profile update. +- Securities routes: search и screener. +- Portfolio routes: list, create, detail, update, delete, position mutations, analytics. +- Существующие shares, bonds, candles и health routes. + +В реализации нужно предпочитать существующие DTO и response DTO. Если для response нет DTO и +добавление полного DTO слишком раздувает первый проход, можно использовать минимальные response +decorators без изменения runtime behavior. + +## Frontend Architecture + +### Generated Types + +`apps/frontend/src/api/types.ts` должен быть перегенерирован из текущего backend Swagger JSON после +того, как backend Swagger metadata будет покрывать актуальные routes. + +Существующие hand-written `responses.ts` и API wrapper modules остаются на месте в этом эпике. Цель: +свежесть контракта, а не полный rewrite клиента. + +### Tests + +Frontend tests уже существуют и проходят. Этот эпик не должен переписывать frontend testing +architecture. Если API response types изменятся, frontend tests нужно обновлять только там, где +перегенерированный contract выявит реальное несоответствие. + +## OpenAPI Contract Scope + +Синхронизированный contract должен включать минимум эти paths под `/api/v1`: + +- `GET /health` +- `POST /auth/register` +- `POST /auth/login` +- `POST /auth/refresh` +- `POST /auth/logout` +- `GET /auth/me` +- `PATCH /auth/me` +- `GET /securities/search` +- `GET /securities/screener` +- `GET /securities/shares/{secid}` +- `GET /securities/shares/{secid}/marketdata` +- `GET /securities/shares/{secid}/dividends` +- `GET /securities/shares/{secid}/history` +- `GET /securities/shares/{secid}/candles` +- `GET /securities/bonds/{secid}` +- `GET /securities/bonds/{secid}/marketdata` +- `GET /securities/bonds/{secid}/history` +- `GET /securities/bonds/{secid}/candles` +- `GET /portfolios` +- `POST /portfolios` +- `GET /portfolios/{id}` +- `PATCH /portfolios/{id}` +- `DELETE /portfolios/{id}` +- `POST /portfolios/{id}/positions` +- `PATCH /portfolios/{id}/positions/{positionId}` +- `DELETE /portfolios/{id}/positions/{positionId}` +- `GET /portfolios/{id}/analytics` + +## Documentation Architecture + +### README + +Обновить README так, чтобы quickstart и test sections упоминали backend, frontend и docs +workspaces. + +### AGENTS + +Обновить AGENTS: + +- `apps/docs` является частью workspace. +- `npm run lint` запускает backend и frontend lint. +- frontend tests существуют. +- pre-commit checks существуют через Husky и lint-staged. +- CI существует в `.gitea/workflows/ci.yml`. + +### Docusaurus + +Обновить текущие onboarding pages: + +- development commands; +- testing; +- code generation; +- frontend overview; +- frontend routes; +- frontend API client; +- backend API; +- backend portfolio. + +Исправить Docusaurus config или docs links так, чтобы `npm run build:docs` больше не сообщал о +broken links на `/`. Отдельное update-check warning про permissions в `/Users/ksv741/.config` +является внешним к репозиторию и в этот эпик не входит. + +## Этапы реализации + +### Этап 1: стабилизировать стандартные проверки + +1. Исправить неиспользуемую backend test variable, которая ломает lint. +2. Перевести стандартные backend service specs с live MOEX calls на mocked dependencies. +3. Вынести или добавить live MOEX smoke coverage под opt-in integration command. +4. Проверить `npm run lint` и `npm run test:backend`. + +### Этап 2: обновить contract artifacts + +1. Добавить или завершить Swagger metadata для актуальных routes. +2. Перегенерировать `apps/frontend/src/api/types.ts`. +3. Синхронизировать `docs/openapi/openapi.yaml` с текущим contract. +4. Проверить, что generated paths включают auth, screener и portfolio routes. + +### Этап 3: обновить документацию + +1. Обновить README и AGENTS. +2. Обновить Docusaurus development, frontend, backend и portfolio pages. +3. Исправить Docusaurus broken `/` link warning. +4. Проверить `npm run build:docs`. + +### Этап 4: полная проверка + +Запустить: + +```bash +npm run lint +npm run test:backend +npm run test:frontend +npm run build:backend +npm run build:frontend +npm run build:docs +npm run format:check +``` + +## Acceptance Criteria + +- `npm run lint` завершается с exit code 0. +- `npm run test:backend` завершается с exit code 0 без live MOEX/network dependency. +- `npm run test:frontend` завершается с exit code 0. +- `npm run build:backend` завершается с exit code 0. +- `npm run build:frontend` завершается с exit code 0. +- `npm run build:docs` завершается с exit code 0 и больше не сообщает о Docusaurus broken links на + `/`. +- `npm run format:check` завершается с exit code 0. +- `apps/frontend/src/api/types.ts` содержит актуальные auth, screener и portfolio paths. +- `docs/openapi/openapi.yaml` содержит актуальные auth, screener и portfolio paths. +- README, AGENTS и Docusaurus docs больше не утверждают, что frontend tests отсутствуют. +- Portfolio docs используют `PATCH /api/v1/portfolios/:id`, что соответствует controller. + +## Риски + +- Swagger decorators могут показать DTO gaps, которые раньше были скрыты hand-written frontend + types. Первый проход должен оставаться сфокусированным на свежести paths и schemas; redesign + API-клиента откладывается. +- Live MOEX tests могут по-прежнему падать в окружениях с network restrictions. Это допустимо только + для opt-in integration command, но не для default backend test command. +- Regenerating OpenAPI artifacts может дать большой diff. Generated changes нужно ревьюить отдельно + от hand-written docs changes. + +## Self-Review спецификации + +- Placeholder scan: placeholder markers и незавершённые sections отсутствуют. +- Internal consistency: default tests остаются offline, live MOEX checks являются opt-in. +- Scope check: scope ограничен quality gates, contract snapshots и актуальностью docs. +- Ambiguity check: acceptance criteria называют конкретные commands и contract paths.