Compare commits

...

10 Commits

Author SHA1 Message Date
117f182851 docs: refresh project documentation
Some checks failed
CI / lint (pull_request) Successful in 2m14s
CI / test (pull_request) Successful in 1m52s
CI / build (pull_request) Failing after 2m15s
CI / test (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / build (push) Has been cancelled
2026-06-15 20:02:50 +03:00
c062850e83 docs: refine openapi null response schema 2026-06-15 19:54:31 +03:00
835686d886 docs: improve openapi schema metadata 2026-06-15 05:58:45 +03:00
143b4758c3 docs: refresh openapi contract artifacts 2026-06-15 05:31:55 +03:00
44e13cc67e test: cover checked-in openapi artifacts 2026-06-15 05:05:13 +03:00
974c83d67e test: make backend service specs deterministic 2026-06-15 04:56:53 +03:00
c994b6a2fb test: split moex live integration checks 2026-06-15 04:47:13 +03:00
bc2d2af141 docs: add quality gate implementation plan 2026-06-14 22:01:36 +03:00
2e79e69403 docs: translate quality gate spec to russian 2026-06-14 21:50:58 +03:00
ee88b29181 docs: add quality gate contract docs spec 2026-06-14 21:46:38 +03:00
37 changed files with 5156 additions and 800 deletions

View File

@ -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:backend` | Запуск NestJS в режиме watch на :3000 |
| `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :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:backend` | `nest build` |
| `npm run build:frontend` | `tsc -b && vite 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 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 format` | Prettier для всех `*.{ts,tsx}` |
| `npm run codegen -w apps/frontend` | `openapi-typescript` из локального Swagger → `src/api/types.ts` | | `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` Один тест: `npx vitest run path/to/test.spec.ts -w apps/backend`
## Переменные окружения ## Переменные окружения
@ -46,7 +51,7 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
## Архитектура ## Архитектура
- **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую. - **Бэкенд** — единственный клиент 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 открыт). - `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт).
- In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002). - In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002).
- Аутентификация: JWT access token (15m, в памяти) + refresh token (7d, httpOnly cookie, bcrypt hash в БД). Глобальный `JwtAuthGuard` (`@Public()` для открытых эндпоинтов). - Аутентификация: 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, точки с запятой. - Prettier: одинарные кавычки, trailing commas, printWidth 100, точки с запятой.
- Бэкенд: `const`, PascalCase для модулей/контроллеров/сервисов, DTO в `dto/` внутри каждого модуля. - Бэкенд: `const`, PascalCase для модулей/контроллеров/сервисов, DTO в `dto/` внутри каждого модуля.
- Бэкенд использует SWC через `unplugin-swc` (vitest config). - Бэкенд использует SWC через `unplugin-swc` (vitest config).
- Тесты фронтенда отсутствуют. - Тесты фронтенда есть: Vitest + Testing Library + MSW.
- CI/CD в репозитории нет. - CI находится в `.gitea/workflows/ci.yml`.
- Pre-commit checks настроены через Husky и lint-staged.

View File

@ -6,6 +6,7 @@
- **Backend:** NestJS, TypeScript, OpenAPI (Swagger) - **Backend:** NestJS, TypeScript, OpenAPI (Swagger)
- **Frontend:** React, TypeScript, Vite, TanStack Query, lightweight-charts - **Frontend:** React, TypeScript, Vite, TanStack Query, lightweight-charts
- **Docs:** Docusaurus
- **Infrastructure:** Docker, docker-compose - **Infrastructure:** Docker, docker-compose
## Quick Start ## Quick Start
@ -36,6 +37,13 @@ docker compose up --build
```bash ```bash
npm run test:backend 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 ## Project Structure
@ -44,6 +52,7 @@ npm run test:backend
apps/ apps/
backend/ — NestJS API (single point of access to MOEX ISS) backend/ — NestJS API (single point of access to MOEX ISS)
frontend/ — React SPA with Vite frontend/ — React SPA with Vite
docs/ — Docusaurus documentation site
docs/ docs/
architecture/ — ADR documents and diagrams architecture/ — ADR documents and diagrams
openapi/ — OpenAPI specification openapi/ — OpenAPI specification

View File

@ -8,8 +8,9 @@
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint \"{src,test}/**/*.ts\"", "lint": "eslint \"{src,test}/**/*.ts\"",
"test": "VITE_CJS_IGNORE_WARNING=1 vitest run", "test": "VITE_CJS_IGNORE_WARNING=1 vitest run --exclude \"src/**/*.integration.spec.ts\"",
"test:watch": "vitest" "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": { "dependencies": {
"@libsql/client": "^0.17.3", "@libsql/client": "^0.17.3",

View File

@ -1,10 +1,21 @@
import { Controller, Post, Get, Patch, Body, Req, Res, HttpCode, HttpStatus } from '@nestjs/common'; 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 { Request, Response } from 'express';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { UpdateProfileDto } from './dto/update-profile.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 { CurrentUser } from './decorators/current-user.decorator';
import { Public } from './decorators/public.decorator'; import { Public } from './decorators/public.decorator';
import { JwtPayload } from './interfaces/jwt-payload.interface'; import { JwtPayload } from './interfaces/jwt-payload.interface';
@ -26,6 +37,7 @@ export class AuthController {
@Public() @Public()
@Post('register') @Post('register')
@ApiOperation({ summary: 'Register new user' }) @ApiOperation({ summary: 'Register new user' })
@ApiCreatedResponse({ type: AuthTokenResponseDto })
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) { async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.register(dto); const result = await this.authService.register(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
@ -41,6 +53,7 @@ export class AuthController {
@Public() @Public()
@Post('login') @Post('login')
@ApiOperation({ summary: 'Login with email and password' }) @ApiOperation({ summary: 'Login with email and password' })
@ApiCreatedResponse({ type: AuthTokenResponseDto })
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) { async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
const result = await this.authService.login(dto); const result = await this.authService.login(dto);
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
@ -57,6 +70,7 @@ export class AuthController {
@Post('refresh') @Post('refresh')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access token' }) @ApiOperation({ summary: 'Refresh access token' })
@ApiOkResponse({ type: AuthTokenResponseDto })
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) { async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
const token = req.cookies?.[REFRESH_COOKIE]; const token = req.cookies?.[REFRESH_COOKIE];
const result = await this.authService.refresh(token); const result = await this.authService.refresh(token);
@ -74,6 +88,7 @@ export class AuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'Logout user' }) @ApiOperation({ summary: 'Logout user' })
@ApiOkResponse({ type: AuthLogoutResponseDto })
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) { async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
await this.authService.logout(user.sub); await this.authService.logout(user.sub);
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' }); res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
@ -86,6 +101,7 @@ export class AuthController {
@Get('me') @Get('me')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'Get current user profile' }) @ApiOperation({ summary: 'Get current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto })
async getProfile(@CurrentUser() user: JwtPayload) { async getProfile(@CurrentUser() user: JwtPayload) {
const profile = await this.authService.getProfile(user.sub); const profile = await this.authService.getProfile(user.sub);
return { return {
@ -97,6 +113,7 @@ export class AuthController {
@Patch('me') @Patch('me')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'Update current user profile' }) @ApiOperation({ summary: 'Update current user profile' })
@ApiOkResponse({ type: AuthProfileResponseDto })
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) { async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
const profile = await this.authService.updateProfile(user.sub, dto); const profile = await this.authService.updateProfile(user.sub, dto);
return { return {

View File

@ -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;
}

View File

@ -1,41 +1,147 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { BondsService } from './bonds.service'; import { BondsService } from './bonds.service';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
describe('BondsService', () => { describe('BondsService', () => {
let service: BondsService; let service: BondsService;
let moexClient: Pick<MoexClientService, 'getBondData' | 'getBondMarketData'>;
let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { 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({ const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [ providers: [
BondsService, BondsService,
MoexClientService, { provide: MoexClientService, useValue: moexClient },
{ { provide: CacheService, useValue: cache },
provide: 'CACHE_MANAGER',
useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
], ],
}).compile(); }).compile();
service = module.get<BondsService>(BondsService); service = module.get<BondsService>(BondsService);
}); });
it('should be defined', () => { it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => {
expect(service).toBeDefined(); 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 () => { it('throws NotFoundException when bond data is missing', async () => {
const result = await service.getBond('SU26207RMFS9'); vi.mocked(moexClient.getBondData).mockResolvedValue(null);
expect(result.data.secid).toBe('SU26207RMFS9');
expect(result.data.marketData).toBeDefined(); await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(NotFoundException);
}, 15000); expect(cache.getOrFetch).toHaveBeenCalledTimes(1);
expect(moexClient.getBondMarketData).not.toHaveBeenCalled();
});
}); });

View File

@ -1,40 +1,51 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { CandlesService } from './candles.service'; import { CandlesService } from './candles.service';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
import { CandleInterval } from './dto/candles-query.dto'; import { CandleInterval } from './dto/candles-query.dto';
describe('CandlesService', () => { describe('CandlesService', () => {
let service: CandlesService; let service: CandlesService;
let moexClient: Pick<MoexClientService, 'getCandles'>;
let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { 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({ const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [ providers: [
CandlesService, CandlesService,
MoexClientService, { provide: MoexClientService, useValue: moexClient },
{ { provide: CacheService, useValue: cache },
provide: 'CACHE_MANAGER',
useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
], ],
}).compile(); }).compile();
service = module.get<CandlesService>(CandlesService); service = module.get<CandlesService>(CandlesService);
}); });
it('should be defined', () => { it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => {
expect(service).toBeDefined(); 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( const result = await service.getCandles(
'shares', 'shares',
'SBER', 'SBER',
@ -42,7 +53,65 @@ describe('CandlesService', () => {
'2026-05-01', '2026-05-01',
'2026-06-01', '2026-06-01',
); );
expect(result.data.length).toBeGreaterThan(0);
expect(result.data[0].open).toBeDefined(); expect(cache.getOrFetch).toHaveBeenCalledWith(
}, 15000); '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',
);
});
}); });

View File

@ -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>(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);
},
);

View File

@ -1,38 +1,186 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { Test, TestingModule } from '@nestjs/testing'; import axios from 'axios';
import { ConfigModule } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { MoexClientService } from './moex-client.service'; import { MoexClientService } from './moex-client.service';
import configuration from '../../config/configuration';
vi.mock('axios', () => ({
default: {
create: vi.fn(),
},
}));
describe('MoexClientService', () => { describe('MoexClientService', () => {
let service: MoexClientService; let service: MoexClientService;
let getMock: ReturnType<typeof vi.fn>;
beforeEach(async () => { beforeEach(() => {
const module: TestingModule = await Test.createTestingModule({ getMock = vi.fn();
imports: [ConfigModule.forRoot({ load: [configuration] })], vi.mocked(axios.create).mockReturnValue({ get: getMock } as never);
providers: [MoexClientService],
}).compile();
service = module.get<MoexClientService>(MoexClientService); service = new MoexClientService({
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'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(service).toBeDefined();
expect(axios.create).toHaveBeenCalledWith({
baseURL: 'https://iss.moex.test/iss',
timeout: 10000,
paramsSerializer: { indexes: null },
});
}); });
describe('searchSecurities', () => { it('нормализует результаты поиска из ISS table format', async () => {
it('should return results for SBER query', async () => { getMock.mockResolvedValueOnce({
const results = await service.searchSecurities('SBER'); data: {
expect(results.length).toBeGreaterThan(0); securities: {
expect(results[0].secid).toBeDefined(); columns: [
}, 15000); '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('нормализует market data акции без live MOEX запроса', async () => {
it('should return market data for SBER', async () => { getMock.mockResolvedValueOnce({
const data = await service.getShareMarketData('SBER'); data: {
expect(data).toBeDefined(); securities: {
expect(data!.secid).toBe('SBER'); columns: ['SECID', 'BOARDID', 'SHORTNAME', 'PREVPRICE'],
}, 15000); 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',
});
}); });
}); });

View File

@ -51,7 +51,7 @@ export class AddPositionDto {
@MaxLength(500) @MaxLength(500)
notes?: string; notes?: string;
@ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS }) @ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS, isArray: true })
@IsArray() @IsArray()
@IsIn(TAGS, { each: true }) @IsIn(TAGS, { each: true })
@IsOptional() @IsOptional()

View File

@ -1,19 +1,19 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { EnrichedPosition } from '../portfolio.service'; import { PositionWithPriceDto } from './position-with-price.dto';
export class PortfolioSummaryDto { export class PortfolioSummaryDto {
@ApiProperty() totalInvested!: number; @ApiProperty() totalInvested!: number;
@ApiProperty() totalValue!: number; @ApiProperty() totalValue!: number;
@ApiProperty() totalPnl!: number; @ApiProperty() totalPnl!: number;
@ApiProperty() totalPnlPercent!: number | null; @ApiProperty({ type: Number, nullable: true }) totalPnlPercent!: number | null;
@ApiProperty() totalDividends!: number; @ApiProperty() totalDividends!: number;
@ApiProperty() totalReturn!: number; @ApiProperty() totalReturn!: number;
@ApiProperty() totalReturnPercent!: number | null; @ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null;
@ApiProperty() positionCount!: number; @ApiProperty() positionCount!: number;
@ApiProperty() weightedYield!: number | null; @ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null;
} }
export class AnalyticsResponseDto { export class AnalyticsResponseDto {
@ApiProperty({ type: [Object] }) positions!: EnrichedPosition[]; @ApiProperty({ type: [PositionWithPriceDto] }) positions!: PositionWithPriceDto[];
@ApiProperty() summary!: PortfolioSummaryDto; @ApiProperty() summary!: PortfolioSummaryDto;
} }

View File

@ -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;
}

View File

@ -1,45 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PortfolioSummaryDto } from './analytics-response.dto'; import { PortfolioSummaryDto } from './analytics-response.dto';
import { PositionWithPriceDto } from './position-with-price.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;
}
export class PortfolioResponseDto { export class PortfolioResponseDto {
@ApiProperty() id!: number; @ApiProperty() id!: number;
@ApiProperty() name!: string; @ApiProperty() name!: string;
@ApiPropertyOptional() description!: string | null; @ApiPropertyOptional({ type: String, nullable: true }) description!: string | null;
@ApiProperty({ default: 'RUB' }) currency!: string; @ApiProperty({ default: 'RUB' }) currency!: string;
@ApiProperty() createdAt!: string; @ApiProperty() createdAt!: string;
@ApiProperty() updatedAt!: string; @ApiProperty() updatedAt!: string;

View File

@ -4,8 +4,8 @@ export class PositionResponseDto {
@ApiProperty() id!: number; @ApiProperty() id!: number;
@ApiProperty({ example: 'SBER' }) secid!: string; @ApiProperty({ example: 'SBER' }) secid!: string;
@ApiProperty({ example: 10 }) quantity!: number; @ApiProperty({ example: 10 }) quantity!: number;
@ApiPropertyOptional() notes!: string | null; @ApiPropertyOptional({ type: String, nullable: true }) notes!: string | null;
@ApiPropertyOptional() tags!: string[] | null; @ApiPropertyOptional({ type: String, isArray: true, nullable: true }) tags!: string[] | null;
@ApiProperty() portfolioId!: number; @ApiProperty() portfolioId!: number;
@ApiProperty() createdAt!: string; @ApiProperty() createdAt!: string;
@ApiProperty() updatedAt!: string; @ApiProperty() updatedAt!: string;

View File

@ -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;
}

View File

@ -45,7 +45,7 @@ export class UpdatePositionDto {
@MaxLength(500) @MaxLength(500)
notes?: string; notes?: string;
@ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS }) @ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS, isArray: true })
@IsArray() @IsArray()
@IsIn(TAGS, { each: true }) @IsIn(TAGS, { each: true })
@IsOptional() @IsOptional()

View File

@ -1,23 +1,51 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common'; import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse } from '@nestjs/swagger'; import {
import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto'; ApiTags,
import { PortfolioDetailResponseDto } from './dto/portfolio-response.dto'; ApiOperation,
ApiBearerAuth,
ApiOkResponse,
ApiCreatedResponse,
ApiExtraModels,
getSchemaPath,
} from '@nestjs/swagger';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { CreatePortfolioDto } from './dto/create-portfolio.dto';
import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
import { AddPositionDto } from './dto/add-position.dto'; import { AddPositionDto } from './dto/add-position.dto';
import { UpdatePositionDto } from './dto/update-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; 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') @ApiTags('Portfolios')
@ApiBearerAuth() @ApiBearerAuth()
@ApiExtraModels(PortfolioResponseMetaDto)
@Controller('portfolios') @Controller('portfolios')
export class PortfolioController { export class PortfolioController {
constructor(private readonly portfolioService: PortfolioService) {} constructor(private readonly portfolioService: PortfolioService) {}
@Get() @Get()
@ApiOperation({ summary: 'Get all portfolios for current user' }) @ApiOperation({ summary: 'Get all portfolios for current user' })
@ApiOkResponse({ type: PortfolioListResponseDto, isArray: true }) @ApiOkResponse({ type: PortfolioListEnvelopeDto })
async findAll(@CurrentUser() user: { sub: number }) { async findAll(@CurrentUser() user: { sub: number }) {
const portfolios = await this.portfolioService.findAll(user.sub); const portfolios = await this.portfolioService.findAll(user.sub);
return { data: portfolios, meta: { cachedAt: null, fromCache: false } }; return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
@ -25,6 +53,7 @@ export class PortfolioController {
@Post() @Post()
@ApiOperation({ summary: 'Create a new portfolio' }) @ApiOperation({ summary: 'Create a new portfolio' })
@ApiCreatedResponse({ type: PortfolioEnvelopeDto })
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) { async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
const portfolio = await this.portfolioService.create(user.sub, dto); const portfolio = await this.portfolioService.create(user.sub, dto);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } }; return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
@ -32,7 +61,7 @@ export class PortfolioController {
@Get(':id') @Get(':id')
@ApiOperation({ summary: 'Get portfolio details with positions and prices' }) @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) { async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
const portfolio = await this.portfolioService.findOne(user.sub, id); const portfolio = await this.portfolioService.findOne(user.sub, id);
return { data: portfolio, meta: { cachedAt: null, fromCache: false } }; return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
@ -40,6 +69,7 @@ export class PortfolioController {
@Patch(':id') @Patch(':id')
@ApiOperation({ summary: 'Update portfolio' }) @ApiOperation({ summary: 'Update portfolio' })
@ApiOkResponse({ type: PortfolioEnvelopeDto })
async update( async update(
@CurrentUser() user: { sub: number }, @CurrentUser() user: { sub: number },
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@ -51,6 +81,7 @@ export class PortfolioController {
@Delete(':id') @Delete(':id')
@ApiOperation({ summary: 'Delete portfolio' }) @ApiOperation({ summary: 'Delete portfolio' })
@ApiOkResponse({ schema: nullDataEnvelopeSchema })
async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
await this.portfolioService.remove(user.sub, id); await this.portfolioService.remove(user.sub, id);
return { data: null, meta: { cachedAt: null, fromCache: false } }; return { data: null, meta: { cachedAt: null, fromCache: false } };
@ -58,6 +89,7 @@ export class PortfolioController {
@Post(':id/positions') @Post(':id/positions')
@ApiOperation({ summary: 'Add position to portfolio' }) @ApiOperation({ summary: 'Add position to portfolio' })
@ApiCreatedResponse({ type: PositionEnvelopeDto })
async addPosition( async addPosition(
@CurrentUser() user: { sub: number }, @CurrentUser() user: { sub: number },
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@ -69,6 +101,7 @@ export class PortfolioController {
@Patch(':id/positions/:positionId') @Patch(':id/positions/:positionId')
@ApiOperation({ summary: 'Update position' }) @ApiOperation({ summary: 'Update position' })
@ApiOkResponse({ type: PositionEnvelopeDto })
async updatePosition( async updatePosition(
@CurrentUser() user: { sub: number }, @CurrentUser() user: { sub: number },
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
@ -81,6 +114,7 @@ export class PortfolioController {
@Get(':id/analytics') @Get(':id/analytics')
@ApiOperation({ summary: 'Get portfolio analytics with PnL' }) @ApiOperation({ summary: 'Get portfolio analytics with PnL' })
@ApiOkResponse({ type: AnalyticsEnvelopeDto })
async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) { async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
const result = await this.portfolioService.getAnalytics(user.sub, id); const result = await this.portfolioService.getAnalytics(user.sub, id);
return { data: result, meta: { cachedAt: null, fromCache: false } }; return { data: result, meta: { cachedAt: null, fromCache: false } };
@ -88,6 +122,7 @@ export class PortfolioController {
@Delete(':id/positions/:positionId') @Delete(':id/positions/:positionId')
@ApiOperation({ summary: 'Remove position from portfolio' }) @ApiOperation({ summary: 'Remove position from portfolio' })
@ApiOkResponse({ schema: nullDataEnvelopeSchema })
async removePosition( async removePosition(
@CurrentUser() user: { sub: number }, @CurrentUser() user: { sub: number },
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,

View File

@ -13,13 +13,13 @@ export class ScreenerItemDto {
@ApiProperty({ enum: ['share', 'bond'] }) @ApiProperty({ enum: ['share', 'bond'] })
type!: 'share' | 'bond'; type!: 'share' | 'bond';
@ApiPropertyOptional({ example: 322.35 }) @ApiPropertyOptional({ type: Number, nullable: true, example: 322.35 })
price!: number | null; price!: number | null;
@ApiPropertyOptional({ example: 1.15 }) @ApiPropertyOptional({ type: Number, nullable: true, example: 1.15 })
change!: number | null; change!: number | null;
@ApiPropertyOptional({ example: 0.36 }) @ApiPropertyOptional({ type: Number, nullable: true, example: 0.36 })
changePercent!: number | null; changePercent!: number | null;
@ApiProperty({ example: 1925163 }) @ApiProperty({ example: 1925163 })
@ -28,28 +28,28 @@ export class ScreenerItemDto {
@ApiProperty({ example: 1 }) @ApiProperty({ example: 1 })
listLevel!: number; listLevel!: number;
@ApiPropertyOptional({ example: 6958336818320 }) @ApiPropertyOptional({ type: Number, nullable: true, example: 6958336818320 })
capitalization!: number | null; capitalization!: number | null;
@ApiPropertyOptional({ example: 12.71 }) @ApiPropertyOptional({ type: Number, nullable: true, example: 12.71 })
yieldToMaturity!: number | null; yieldToMaturity!: number | null;
@ApiPropertyOptional({ example: 4.5 }) @ApiPropertyOptional({ type: Number, nullable: true, example: 4.5 })
duration!: number | null; duration!: number | null;
@ApiPropertyOptional({ example: 40.64 }) @ApiPropertyOptional({ type: Number, nullable: true, example: 40.64 })
couponValue!: number | null; couponValue!: number | null;
@ApiPropertyOptional({ example: 8.15 }) @ApiPropertyOptional({ type: Number, nullable: true, example: 8.15 })
couponPercent!: number | null; couponPercent!: number | null;
@ApiPropertyOptional({ example: 29.48 }) @ApiPropertyOptional({ type: Number, nullable: true, example: 29.48 })
accruedInt!: number | null; accruedInt!: number | null;
@ApiPropertyOptional({ example: '2027-02-03' }) @ApiPropertyOptional({ type: String, nullable: true, example: '2027-02-03' })
matDate!: string | null; matDate!: string | null;
@ApiPropertyOptional({ example: 'ОФЗ-ПД' }) @ApiPropertyOptional({ type: String, nullable: true, example: 'ОФЗ-ПД' })
bondType!: string | null; bondType!: string | null;
} }
@ -69,3 +69,19 @@ export class ScreenerResultDto {
@ApiProperty() @ApiProperty()
totalPages!: number; 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;
}

View File

@ -6,7 +6,6 @@ import { ScreenerType } from './dto/screener-query.dto';
describe('ScreenerService', () => { describe('ScreenerService', () => {
let service: ScreenerService; let service: ScreenerService;
let moexClient: MoexClientService;
let cache: CacheService; let cache: CacheService;
beforeEach(async () => { beforeEach(async () => {
@ -30,7 +29,6 @@ describe('ScreenerService', () => {
}).compile(); }).compile();
service = module.get<ScreenerService>(ScreenerService); service = module.get<ScreenerService>(ScreenerService);
moexClient = module.get<MoexClientService>(MoexClientService);
cache = module.get<CacheService>(CacheService); cache = module.get<CacheService>(CacheService);
}); });

View File

@ -4,7 +4,7 @@ import { SecuritiesService } from './securities.service';
import { ScreenerService } from './screener.service'; import { ScreenerService } from './screener.service';
import { SearchQueryDto, SecurityType } from './dto/search-query.dto'; import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
import { ScreenerQueryDto } from './dto/screener-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') @ApiTags('Securities')
@Controller('securities') @Controller('securities')
@ -27,7 +27,7 @@ export class SecuritiesController {
@Get('screener') @Get('screener')
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' }) @ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
@ApiOkResponse({ type: ScreenerResultDto }) @ApiOkResponse({ type: ScreenerResponseDto })
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) { async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
const result = await this.screenerService.screen(query); const result = await this.screenerService.screen(query);
return { data: result, meta: { cachedAt: null, fromCache: false } }; return { data: result, meta: { cachedAt: null, fromCache: false } };

View File

@ -1,38 +1,174 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { SecuritiesService } from './securities.service'; import { SecuritiesService } from './securities.service';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
import { SecurityType } from './dto/search-query.dto'; import { SecurityType } from './dto/search-query.dto';
describe('SecuritiesService', () => { describe('SecuritiesService', () => {
let service: SecuritiesService; let service: SecuritiesService;
let moexClient: Pick<MoexClientService, 'searchSecurities'>;
let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { 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({ const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [ providers: [
SecuritiesService, SecuritiesService,
MoexClientService, { provide: MoexClientService, useValue: moexClient },
{ { provide: CacheService, useValue: cache },
provide: 'CACHE_MANAGER',
useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
], ],
}).compile(); }).compile();
service = module.get<SecuritiesService>(SecuritiesService); service = module.get<SecuritiesService>(SecuritiesService);
}); });
it('should return search results for SBER', async () => { it('returns supported securities only and normalizes SUR currency to RUB', async () => {
const results = await service.search('SBER', SecurityType.ALL, 5); vi.mocked(moexClient.searchSecurities).mockResolvedValue([
expect(results.length).toBeGreaterThan(0); {
expect(results[0].secid).toBeDefined(); secid: 'SBER',
}, 15000); 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();
});
}); });

View File

@ -1,41 +1,135 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { SharesService } from './shares.service'; import { SharesService } from './shares.service';
import { MoexClientService } from '../moex-client/moex-client.service'; import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
describe('SharesService', () => { describe('SharesService', () => {
let service: SharesService; let service: SharesService;
let moexClient: Pick<MoexClientService, 'getSecurityDescription' | 'getShareMarketData'>;
let cache: Pick<CacheService, 'getOrFetch'>;
beforeEach(async () => { 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({ const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [ providers: [
SharesService, SharesService,
MoexClientService, { provide: MoexClientService, useValue: moexClient },
{ { provide: CacheService, useValue: cache },
provide: 'CACHE_MANAGER',
useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
], ],
}).compile(); }).compile();
service = module.get<SharesService>(SharesService); service = module.get<SharesService>(SharesService);
}); });
it('should be defined', () => { it('returns normalized SBER share spec and market data without live MOEX dependency', async () => {
expect(service).toBeDefined(); 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 () => { it('throws NotFoundException for non-share security', async () => {
const share = await service.getShare('SBER'); vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
expect(share.secid).toBe('SBER'); secid: 'SU26238RMFS5',
expect(share.marketData).toBeDefined(); isin: 'RU000A1038V6',
}, 15000); 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();
});
}); });

View File

@ -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' });
}
});
});

View File

@ -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 ## Shares
### `GET /securities/shares/:secid` ### `GET /securities/shares/:secid`
@ -354,3 +373,22 @@
"meta": { "fromCache": false, "cachedAt": null } "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.

View File

@ -1,6 +1,7 @@
# Portfolio Module # Portfolio Module
The Portfolio module allows users to create and manage virtual investment portfolios for tracking purposes. Portfolio module позволяет пользователям создавать и вести виртуальные инвестиционные портфели для
аналитики и отслеживания позиций.
## Overview ## Overview
@ -10,19 +11,19 @@ The Portfolio module allows users to create and manage virtual investment portfo
## API Endpoints ## API Endpoints
All endpoints require JWT authentication (`JwtAuthGuard`). Все endpoints требуют JWT authentication (`JwtAuthGuard`).
| Endpoint | Method | Description | | Endpoint | Method | Description |
|---|---|---| |---|---|---|
| `/api/v1/portfolios` | GET | List user's portfolios | | `/api/v1/portfolios` | GET | Список портфелей пользователя |
| `/api/v1/portfolios` | POST | Create portfolio | | `/api/v1/portfolios` | POST | Создать портфель |
| `/api/v1/portfolios/:id` | GET | Portfolio detail with enriched positions and analytics summary | | `/api/v1/portfolios/:id` | GET | Детали портфеля с обогащёнными позициями и analytics summary |
| `/api/v1/portfolios/:id/analytics` | GET | Detailed portfolio analytics with PnL | | `/api/v1/portfolios/:id/analytics` | GET | Детальная аналитика портфеля и PnL |
| `/api/v1/portfolios/:id/patch` | PATCH | Update portfolio (name, description, currency) | | `/api/v1/portfolios/:id` | PATCH | Обновить портфель (name, description, currency) |
| `/api/v1/portfolios/:id` | DELETE | Delete portfolio (cascade deletes positions) | | `/api/v1/portfolios/:id` | DELETE | Удалить портфель вместе с позициями |
| `/api/v1/portfolios/:id/positions` | POST | Add position (accepts buyPrice, buyDate) | | `/api/v1/portfolios/:id/positions` | POST | Добавить позицию (buyPrice, buyDate, notes, tags) |
| `/api/v1/portfolios/:id/positions/:posId` | PATCH | Update position (quantity, buyPrice, buyDate, notes) | | `/api/v1/portfolios/:id/positions/:positionId` | PATCH | Обновить позицию |
| `/api/v1/portfolios/:id/positions/:posId` | DELETE | Remove position | | `/api/v1/portfolios/:id/positions/:positionId` | DELETE | Удалить позицию |
## Domain Model ## Domain Model

View File

@ -24,6 +24,19 @@ openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts
### Output ### Output
- `apps/frontend/src/api/types.ts` — сгенерированные типы `paths` и `operations` - `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 ### Manual Types

View File

@ -5,13 +5,16 @@
| Command | Description | | Command | Description |
|---|---| |---|---|
| `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 | | `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:backend` | `nest build` |
| `npm run build:frontend` | `tsc -b && vite build` (в две фазы) | | `npm run build:frontend` | `tsc -b && vite build` |
| `npm run test:backend` | `vitest run` (SWC, не ts-jest) | | `npm run build:docs` | `docusaurus build` |
| `npm run lint` | ESLint только для бэкенда | | `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` | Prettier для всех `*.{ts,tsx}` |
| `npm run format:check` | Prettier check для всех `*.{ts,tsx}` | | `npm run format:check` | Проверка Prettier для всех `*.{ts,tsx}` |
## Backend Workspace ## Backend Workspace
@ -23,6 +26,7 @@
| `npm run lint -w apps/backend` | ESLint для `{src,test}/**/*.ts` | | `npm run lint -w apps/backend` | ESLint для `{src,test}/**/*.ts` |
| `npm run test -w apps/backend` | `vitest run` | | `npm run test -w apps/backend` | `vitest run` |
| `npm run test:watch -w apps/backend` | `vitest` | | `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 ## Frontend Workspace
@ -32,6 +36,16 @@
| `npm run build -w apps/frontend` | `tsc -b && vite build` | | `npm run build -w apps/frontend` | `tsc -b && vite build` |
| `npm run preview -w apps/frontend` | `vite preview` | | `npm run preview -w apps/frontend` | `vite preview` |
| `npm run codegen -w apps/frontend` | `openapi-typescript` из Swagger → `src/api/types.ts` | | `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 ## Single Test

View File

@ -18,7 +18,7 @@ Prettier (`.prettierrc`):
## Linting ## Linting
ESLint только для бэкенда (`apps/backend`). ESLint запускается для backend (`apps/backend`) и frontend (`apps/frontend`).
Плагины: Плагины:
- `@typescript-eslint/eslint-plugin` - `@typescript-eslint/eslint-plugin`

View File

@ -39,4 +39,25 @@ npm run test:watch -w apps/backend
## Frontend Tests ## 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 или сетевых
ограничениях окружения.

View File

@ -8,6 +8,7 @@
async function request<T>( async function request<T>(
path: string, path: string,
params?: Record<string, string>, params?: Record<string, string>,
options?: { method?: string; body?: unknown; skipAuth?: boolean },
): Promise<{ data: T; meta: ApiResponseMeta }> ): Promise<{ data: T; meta: ApiResponseMeta }>
``` ```
@ -20,7 +21,14 @@ async function request<T>(
| Function | Method | Path | | Function | Method | Path |
|---|---|---| |---|---|---|
| `getHealth()` | GET | `/api/v1/health` | | `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` | | `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` | | `getShare(secid)` | GET | `/api/v1/securities/shares/:secid` |
| `getShareMarketData(secid)` | GET | `/api/v1/securities/shares/:secid/marketdata` | | `getShareMarketData(secid)` | GET | `/api/v1/securities/shares/:secid/marketdata` |
| `getShareDividends(secid)` | GET | `/api/v1/securities/shares/:secid/dividends` | | `getShareDividends(secid)` | GET | `/api/v1/securities/shares/:secid/dividends` |
@ -30,6 +38,15 @@ async function request<T>(
| `getBondHistory(secid, from, till)` | GET | `/api/v1/securities/bonds/:secid/history` | | `getBondHistory(secid, from, till)` | GET | `/api/v1/securities/bonds/:secid/history` |
| `getShareCandles(secid, interval, from, till)` | GET | `/api/v1/securities/shares/:secid/candles` | | `getShareCandles(secid, interval, from, till)` | GET | `/api/v1/securities/shares/:secid/candles` |
| `getBondCandles(secid, interval, from, till)` | GET | `/api/v1/securities/bonds/: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 ## Types
@ -41,5 +58,8 @@ async function request<T>(
- `BondResponse` — спецификация облигации + `BondMarketData` - `BondResponse` — спецификация облигации + `BondMarketData`
- `CandleItem`, `DividendItem`, `ShareHistoryItem`, `BondHistoryItem` - `CandleItem`, `DividendItem`, `ShareHistoryItem`, `BondHistoryItem`
- `SearchResultItem`, `HealthResponse` - `SearchResultItem`, `HealthResponse`
- `UserResponse`, `AuthResponse`
- `Portfolio`, `PortfolioDetail`, `Position`, `PortfolioSummary`, `AnalyticsResponse`
- `ScreenerItem`, `ScreenerResult`
Codegen-типы из OpenAPI в `api/types.ts` (генерируются через `npm run codegen`). Codegen-типы из OpenAPI в `api/types.ts` (генерируются через `npm run codegen`).

View File

@ -9,6 +9,7 @@ React SPA, собранная с Vite.
- TanStack Query v5 - TanStack Query v5
- lightweight-charts v4 - lightweight-charts v4
- openapi-fetch (с рукописными типами `responses.ts`) - openapi-fetch (с рукописными типами `responses.ts`)
- Vitest + Testing Library + MSW
- Vite 5 - Vite 5
## Source Layout ## Source Layout
@ -19,10 +20,22 @@ apps/frontend/src/
├── App.tsx # BrowserRouter ├── App.tsx # BrowserRouter
├── routes.tsx # Маршруты ├── routes.tsx # Маршруты
├── api/ ├── api/
│ ├── auth.ts # Auth API helpers
│ ├── client.ts # HTTP-клиент (fetch) │ ├── client.ts # HTTP-клиент (fetch)
│ ├── portfolio.ts # Portfolio API helpers
│ ├── responses.ts # Типы ответов (ручные) │ ├── responses.ts # Типы ответов (ручные)
│ ├── screener.ts # Screener API helpers
│ └── types.ts # Типы из openapi-typescript │ └── types.ts # Типы из openapi-typescript
├── context/
│ └── AuthContext.tsx
├── hooks/ ├── hooks/
│ ├── useAuth.ts
│ ├── usePortfolio.ts
│ ├── usePortfolioAnalytics.ts
│ ├── usePortfolioMutations.ts
│ ├── usePortfolios.ts
│ ├── usePositionMutations.ts
│ ├── useScreener.ts
│ ├── useSearch.ts │ ├── useSearch.ts
│ ├── useStock.ts │ ├── useStock.ts
│ ├── useStockCandles.ts │ ├── useStockCandles.ts
@ -31,14 +44,24 @@ apps/frontend/src/
│ └── useBondCandles.ts │ └── useBondCandles.ts
├── components/ ├── components/
│ ├── Layout.tsx │ ├── Layout.tsx
│ ├── ProtectedRoute.tsx
│ ├── SearchBar.tsx │ ├── SearchBar.tsx
│ ├── PriceChart.tsx │ ├── PriceChart.tsx
│ ├── StockDetails.tsx │ ├── StockDetails.tsx
│ └── BondDetails.tsx │ ├── BondDetails.tsx
│ └── portfolios/
├── pages/ ├── pages/
│ ├── HomePage.tsx │ ├── HomePage.tsx
│ ├── StockPage.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 └── styles.css
``` ```

View File

@ -2,11 +2,17 @@
Определены в `apps/frontend/src/routes.tsx`. Определены в `apps/frontend/src/routes.tsx`.
| Path | Component | Description | | Path | Component | Access | Description |
|---|---|---| |---|---|---|---|
| `/` | `HomePage` | Главная страница с приветствием | | `/` | `HomePage` | Public | Главная страница |
| `/stocks/:secid` | `StockPage` | Страница акции | | `/stocks/:secid` | `StockPage` | Public | Страница акции |
| `/bonds/:secid` | `BondPage` | Страница облигации | | `/bonds/:secid` | `BondPage` | Public | Страница облигации |
| `/screener` | `ScreenerPage` | Public | Скринер ценных бумаг |
| `/login` | `LoginPage` | Public | Вход |
| `/register` | `RegisterPage` | Public | Регистрация |
| `/profile` | `ProfilePage` | Protected | Профиль текущего пользователя |
| `/portfolios` | `PortfoliosListPage` | Protected | Список портфелей |
| `/portfolios/:id` | `PortfolioDetailPage` | Protected | Детальная страница портфеля |
Все страницы обёрнуты в `Layout`, который содержит: Все страницы обёрнуты в `Layout`, который содержит:
@ -22,6 +28,33 @@
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/stocks/:secid" element={<StockPage />} /> <Route path="/stocks/:secid" element={<StockPage />} />
<Route path="/bonds/:secid" element={<BondPage />} /> <Route path="/bonds/:secid" element={<BondPage />} />
<Route path="/screener" element={<ScreenerPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/profile"
element={
<ProtectedRoute>
<ProfilePage />
</ProtectedRoute>
}
/>
<Route
path="/portfolios"
element={
<ProtectedRoute>
<PortfoliosListPage />
</ProtectedRoute>
}
/>
<Route
path="/portfolios/:id"
element={
<ProtectedRoute>
<PortfolioDetailPage />
</ProtectedRoute>
}
/>
</Route> </Route>
</Routes> </Routes>
``` ```

View File

@ -1,3 +1,7 @@
---
slug: /
---
# MoexVibe # MoexVibe
Веб-приложение для анализа ценных бумаг Московской биржи (MOEX). Веб-приложение для анализа ценных бумаг Московской биржи (MOEX).
@ -15,7 +19,8 @@
moex-vibe/ moex-vibe/
├── apps/ ├── apps/
│ ├── backend/ # NestJS API (единственная точка доступа к MOEX) │ ├── backend/ # NestJS API (единственная точка доступа к MOEX)
│ └── frontend/ # React SPA │ ├── frontend/ # React SPA
│ └── docs/ # Docusaurus documentation site
├── docs/ ├── docs/
│ ├── architecture/ # ADR и диаграммы │ ├── architecture/ # ADR и диаграммы
│ ├── openapi/ # OpenAPI-спецификация │ ├── openapi/ # OpenAPI-спецификация
@ -31,6 +36,6 @@ moex-vibe/
## Key Principles ## Key Principles
- Backend — единственный клиент MOEX. Frontend никогда не обращается к MOEX напрямую. - 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: `/api/v1`. Swagger: `/api/docs`.
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`. - Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.

View File

@ -21,6 +21,92 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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': { '/api/v1/securities/search': {
parameters: { parameters: {
query?: never; query?: never;
@ -38,6 +124,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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}': { '/api/v1/securities/shares/{secid}': {
parameters: { parameters: {
query?: never; query?: never;
@ -191,10 +294,402 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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<string, never>; export type webhooks = Record<string, never>;
export interface components { 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; responses: never;
parameters: never; parameters: never;
requestBodies: 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: { SecuritiesController_search: {
parameters: { parameters: {
query: { 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: { SharesController_getShare: {
parameters: { parameters: {
query?: never; 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'];
};
};
};
};
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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.