Compare commits
10 Commits
96f003852d
...
117f182851
| Author | SHA1 | Date | |
|---|---|---|---|
| 117f182851 | |||
| c062850e83 | |||
| 835686d886 | |||
| 143b4758c3 | |||
| 44e13cc67e | |||
| 974c83d67e | |||
| c994b6a2fb | |||
| bc2d2af141 | |||
| 2e79e69403 | |||
| ee88b29181 |
16
AGENTS.md
16
AGENTS.md
@ -2,7 +2,7 @@
|
||||
|
||||
## Репозиторий
|
||||
|
||||
npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/frontend` (React + Vite).
|
||||
npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/frontend` (React + Vite), `apps/docs` (Docusaurus).
|
||||
|
||||
## Обязательный подход к разработке
|
||||
|
||||
@ -16,13 +16,18 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
|
||||
|---|---|
|
||||
| `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 |
|
||||
| `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 |
|
||||
| `npm run dev:docs` | Docusaurus dev-сервер |
|
||||
| `npm run build:backend` | `nest build` |
|
||||
| `npm run build:frontend` | `tsc -b && vite build` (в две фазы) |
|
||||
| `npm run build:docs` | `docusaurus build` |
|
||||
| `npm run test:backend` | `vitest run` (SWC, не ts-jest) |
|
||||
| `npm run lint` | ESLint только для бэкенда |
|
||||
| `npm run test:frontend` | Frontend Vitest suite |
|
||||
| `npm run lint` | ESLint для backend и frontend |
|
||||
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
|
||||
| `npm run codegen -w apps/frontend` | `openapi-typescript` из локального Swagger → `src/api/types.ts` |
|
||||
|
||||
Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
|
||||
|
||||
Один тест: `npx vitest run path/to/test.spec.ts -w apps/backend`
|
||||
|
||||
## Переменные окружения
|
||||
@ -46,7 +51,7 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
|
||||
## Архитектура
|
||||
|
||||
- **Бэкенд** — единственный клиент MOEX. Фронтенд никогда не обращается к MOEX напрямую.
|
||||
- Feature-модули: `PrismaModule` (глобальный), `MoexClientModule` (глобальный), `CacheModule` (глобальный), `AuthModule`, `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `HealthModule`.
|
||||
- Feature-модули: `PrismaModule` (глобальный), `MoexClientModule` (глобальный), `CacheModule` (глобальный), `AuthModule`, `SharesModule`, `BondsModule`, `SecuritiesModule`, `CandlesModule`, `PortfolioModule`, `HealthModule`.
|
||||
- `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт).
|
||||
- In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002).
|
||||
- Аутентификация: JWT access token (15m, в памяти) + refresh token (7d, httpOnly cookie, bcrypt hash в БД). Глобальный `JwtAuthGuard` (`@Public()` для открытых эндпоинтов).
|
||||
@ -70,5 +75,6 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr
|
||||
- Prettier: одинарные кавычки, trailing commas, printWidth 100, точки с запятой.
|
||||
- Бэкенд: `const`, PascalCase для модулей/контроллеров/сервисов, DTO в `dto/` внутри каждого модуля.
|
||||
- Бэкенд использует SWC через `unplugin-swc` (vitest config).
|
||||
- Тесты фронтенда отсутствуют.
|
||||
- CI/CD в репозитории нет.
|
||||
- Тесты фронтенда есть: Vitest + Testing Library + MSW.
|
||||
- CI находится в `.gitea/workflows/ci.yml`.
|
||||
- Pre-commit checks настроены через Husky и lint-staged.
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
- **Backend:** NestJS, TypeScript, OpenAPI (Swagger)
|
||||
- **Frontend:** React, TypeScript, Vite, TanStack Query, lightweight-charts
|
||||
- **Docs:** Docusaurus
|
||||
- **Infrastructure:** Docker, docker-compose
|
||||
|
||||
## Quick Start
|
||||
@ -36,6 +37,13 @@ docker compose up --build
|
||||
|
||||
```bash
|
||||
npm run test:backend
|
||||
npm run test:frontend
|
||||
```
|
||||
|
||||
Live MOEX integration checks are opt-in:
|
||||
|
||||
```bash
|
||||
npm run test:integration -w apps/backend
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
@ -44,6 +52,7 @@ npm run test:backend
|
||||
apps/
|
||||
backend/ — NestJS API (single point of access to MOEX ISS)
|
||||
frontend/ — React SPA with Vite
|
||||
docs/ — Docusaurus documentation site
|
||||
docs/
|
||||
architecture/ — ADR documents and diagrams
|
||||
openapi/ — OpenAPI specification
|
||||
|
||||
@ -8,8 +8,9 @@
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,test}/**/*.ts\"",
|
||||
"test": "VITE_CJS_IGNORE_WARNING=1 vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test": "VITE_CJS_IGNORE_WARNING=1 vitest run --exclude \"src/**/*.integration.spec.ts\"",
|
||||
"test:watch": "vitest --exclude \"src/**/*.integration.spec.ts\"",
|
||||
"test:integration": "MOEX_LIVE_TESTS=1 VITE_CJS_IGNORE_WARNING=1 vitest run \"src/**/*.integration.spec.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@libsql/client": "^0.17.3",
|
||||
|
||||
@ -1,10 +1,21 @@
|
||||
import { Controller, Post, Get, Patch, Body, Req, Res, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiOkResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||
import {
|
||||
AuthLogoutResponseDto,
|
||||
AuthProfileResponseDto,
|
||||
AuthTokenResponseDto,
|
||||
} from './dto/auth-response.dto';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||
@ -26,6 +37,7 @@ export class AuthController {
|
||||
@Public()
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register new user' })
|
||||
@ApiCreatedResponse({ type: AuthTokenResponseDto })
|
||||
async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) {
|
||||
const result = await this.authService.register(dto);
|
||||
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||
@ -41,6 +53,7 @@ export class AuthController {
|
||||
@Public()
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
@ApiCreatedResponse({ type: AuthTokenResponseDto })
|
||||
async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) {
|
||||
const result = await this.authService.login(dto);
|
||||
res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS);
|
||||
@ -57,6 +70,7 @@ export class AuthController {
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Refresh access token' })
|
||||
@ApiOkResponse({ type: AuthTokenResponseDto })
|
||||
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
const token = req.cookies?.[REFRESH_COOKIE];
|
||||
const result = await this.authService.refresh(token);
|
||||
@ -74,6 +88,7 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Logout user' })
|
||||
@ApiOkResponse({ type: AuthLogoutResponseDto })
|
||||
async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) {
|
||||
await this.authService.logout(user.sub);
|
||||
res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' });
|
||||
@ -86,6 +101,7 @@ export class AuthController {
|
||||
@Get('me')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Get current user profile' })
|
||||
@ApiOkResponse({ type: AuthProfileResponseDto })
|
||||
async getProfile(@CurrentUser() user: JwtPayload) {
|
||||
const profile = await this.authService.getProfile(user.sub);
|
||||
return {
|
||||
@ -97,6 +113,7 @@ export class AuthController {
|
||||
@Patch('me')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Update current user profile' })
|
||||
@ApiOkResponse({ type: AuthProfileResponseDto })
|
||||
async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) {
|
||||
const profile = await this.authService.updateProfile(user.sub, dto);
|
||||
return {
|
||||
|
||||
60
apps/backend/src/modules/auth/dto/auth-response.dto.ts
Normal file
60
apps/backend/src/modules/auth/dto/auth-response.dto.ts
Normal 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;
|
||||
}
|
||||
@ -1,41 +1,147 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { BondsService } from './bonds.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import configuration from '../../config/configuration';
|
||||
|
||||
describe('BondsService', () => {
|
||||
let service: BondsService;
|
||||
let moexClient: Pick<MoexClientService, 'getBondData' | 'getBondMarketData'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexClient = {
|
||||
getBondData: vi.fn(),
|
||||
getBondMarketData: vi.fn(),
|
||||
};
|
||||
cache = {
|
||||
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
})),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [
|
||||
BondsService,
|
||||
MoexClientService,
|
||||
{
|
||||
provide: 'CACHE_MANAGER',
|
||||
useValue: {
|
||||
get: () => undefined,
|
||||
set: () => Promise.resolve(),
|
||||
del: () => Promise.resolve(),
|
||||
},
|
||||
},
|
||||
CacheService,
|
||||
{ provide: MoexClientService, useValue: moexClient },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<BondsService>(BondsService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => {
|
||||
vi.mocked(moexClient.getBondData).mockResolvedValue({
|
||||
secid: 'SU26238RMFS5',
|
||||
boardid: 'TQCB',
|
||||
shortName: 'ОФЗ 26238',
|
||||
prevWaprice: 73.2,
|
||||
yieldAtPrevWaprice: 14.1,
|
||||
couponValue: 35.4,
|
||||
nextCoupon: '2026-06-24',
|
||||
accruedInt: 34.1,
|
||||
prevPrice: 73,
|
||||
lotSize: 1,
|
||||
faceValue: 1000,
|
||||
matDate: '2041-05-15',
|
||||
couponPeriod: 182,
|
||||
issueSize: 150000000,
|
||||
isin: 'RU000A1038V6',
|
||||
couponPercent: 7.1,
|
||||
offerDate: null,
|
||||
buybackDate: null,
|
||||
bondType: 'ofz',
|
||||
bondSubType: 'fixed',
|
||||
listLevel: 1,
|
||||
});
|
||||
vi.mocked(moexClient.getBondMarketData).mockResolvedValue({
|
||||
secid: 'SU26238RMFS5',
|
||||
bid: 72.9,
|
||||
offer: 73.1,
|
||||
open: 72.8,
|
||||
low: 72.5,
|
||||
high: 73.4,
|
||||
last: 73.05,
|
||||
yield: 14.2,
|
||||
waprice: 73,
|
||||
yieldAtWaprice: 14.15,
|
||||
duration: 2250,
|
||||
volume: 10000,
|
||||
value: 7305000,
|
||||
numtrades: 450,
|
||||
tradingStatus: 'T',
|
||||
updateTime: '18:45:00',
|
||||
});
|
||||
|
||||
it('should return OFZ bond data for SU26207RMFS9', async () => {
|
||||
const result = await service.getBond('SU26207RMFS9');
|
||||
expect(result.data.secid).toBe('SU26207RMFS9');
|
||||
expect(result.data.marketData).toBeDefined();
|
||||
}, 15000);
|
||||
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('throws NotFoundException when bond data is missing', async () => {
|
||||
vi.mocked(moexClient.getBondData).mockResolvedValue(null);
|
||||
|
||||
await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(cache.getOrFetch).toHaveBeenCalledTimes(1);
|
||||
expect(moexClient.getBondMarketData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,40 +1,51 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { CandlesService } from './candles.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import configuration from '../../config/configuration';
|
||||
import { CandleInterval } from './dto/candles-query.dto';
|
||||
|
||||
describe('CandlesService', () => {
|
||||
let service: CandlesService;
|
||||
let moexClient: Pick<MoexClientService, 'getCandles'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexClient = {
|
||||
getCandles: vi.fn(),
|
||||
};
|
||||
cache = {
|
||||
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
})),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [
|
||||
CandlesService,
|
||||
MoexClientService,
|
||||
{
|
||||
provide: 'CACHE_MANAGER',
|
||||
useValue: {
|
||||
get: () => undefined,
|
||||
set: () => Promise.resolve(),
|
||||
del: () => Promise.resolve(),
|
||||
},
|
||||
},
|
||||
CacheService,
|
||||
{ provide: MoexClientService, useValue: moexClient },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CandlesService>(CandlesService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => {
|
||||
vi.mocked(moexClient.getCandles).mockResolvedValue([
|
||||
{
|
||||
open: 320,
|
||||
high: 325,
|
||||
low: 318,
|
||||
close: 323,
|
||||
volume: 1500000,
|
||||
value: 480000000,
|
||||
begin: '2026-05-01 00:00:00',
|
||||
end: '2026-05-01 23:59:59',
|
||||
},
|
||||
]);
|
||||
|
||||
it('should return daily candles for SBER', async () => {
|
||||
const result = await service.getCandles(
|
||||
'shares',
|
||||
'SBER',
|
||||
@ -42,7 +53,65 @@ describe('CandlesService', () => {
|
||||
'2026-05-01',
|
||||
'2026-06-01',
|
||||
);
|
||||
expect(result.data.length).toBeGreaterThan(0);
|
||||
expect(result.data[0].open).toBeDefined();
|
||||
}, 15000);
|
||||
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'candles',
|
||||
['shares', 'SBER', '24', '2026-05-01', '2026-06-01'],
|
||||
expect.any(Function),
|
||||
'candlesTtl',
|
||||
);
|
||||
expect(moexClient.getCandles).toHaveBeenCalledWith(
|
||||
'stock',
|
||||
'shares',
|
||||
'SBER',
|
||||
24,
|
||||
'2026-05-01',
|
||||
'2026-06-01',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
data: [
|
||||
{
|
||||
open: 320,
|
||||
high: 325,
|
||||
low: 318,
|
||||
close: 323,
|
||||
volume: 1500000,
|
||||
value: 480000000,
|
||||
begin: '2026-05-01 00:00:00',
|
||||
end: '2026-05-01 23:59:59',
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => {
|
||||
vi.mocked(moexClient.getCandles).mockResolvedValue([]);
|
||||
|
||||
await service.getCandles(
|
||||
'bonds',
|
||||
'SU26238RMFS5',
|
||||
CandleInterval.HOUR,
|
||||
'2026-05-01',
|
||||
'2026-06-01',
|
||||
);
|
||||
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'candles',
|
||||
['bonds', 'SU26238RMFS5', '60', '2026-05-01', '2026-06-01'],
|
||||
expect.any(Function),
|
||||
'candlesTtl',
|
||||
);
|
||||
expect(moexClient.getCandles).toHaveBeenCalledWith(
|
||||
'stock',
|
||||
'bonds',
|
||||
'SU26238RMFS5',
|
||||
60,
|
||||
'2026-05-01',
|
||||
'2026-06-01',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
@ -1,38 +1,186 @@
|
||||
import 'reflect-metadata';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MoexClientService } from './moex-client.service';
|
||||
import configuration from '../../config/configuration';
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('MoexClientService', () => {
|
||||
let service: MoexClientService;
|
||||
let getMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [MoexClientService],
|
||||
}).compile();
|
||||
beforeEach(() => {
|
||||
getMock = vi.fn();
|
||||
vi.mocked(axios.create).mockReturnValue({ get: getMock } as never);
|
||||
|
||||
service = module.get<MoexClientService>(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(axios.create).toHaveBeenCalledWith({
|
||||
baseURL: 'https://iss.moex.test/iss',
|
||||
timeout: 10000,
|
||||
paramsSerializer: { indexes: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('нормализует результаты поиска из ISS table format', async () => {
|
||||
getMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
securities: {
|
||||
columns: [
|
||||
'secid',
|
||||
'isin',
|
||||
'name',
|
||||
'shortName',
|
||||
'latName',
|
||||
'listLevel',
|
||||
'issuesize',
|
||||
'facevalue',
|
||||
'faceunit',
|
||||
'issuedate',
|
||||
'typename',
|
||||
'group',
|
||||
'type',
|
||||
'isqualifiedinvestors',
|
||||
'morningsession',
|
||||
'eveningsession',
|
||||
],
|
||||
data: [
|
||||
[
|
||||
'SBER',
|
||||
'RU0009029540',
|
||||
'Сбербанк России ПАО ао',
|
||||
'Сбербанк',
|
||||
'Sberbank',
|
||||
'1',
|
||||
'21586948000',
|
||||
'3',
|
||||
'SUR',
|
||||
'2007-07-20',
|
||||
'Акция обыкновенная',
|
||||
'stock_shares',
|
||||
'common_share',
|
||||
'0',
|
||||
'1',
|
||||
'1',
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('searchSecurities', () => {
|
||||
it('should return results for SBER query', async () => {
|
||||
const results = await service.searchSecurities('SBER');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].secid).toBeDefined();
|
||||
}, 15000);
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/securities.json', {
|
||||
params: { q: 'SBER', 'iss.meta': 'off' },
|
||||
});
|
||||
expect(results).toEqual([
|
||||
{
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
name: 'Сбербанк России ПАО ао',
|
||||
shortName: 'Сбербанк',
|
||||
latName: 'Sberbank',
|
||||
listLevel: 1,
|
||||
issueSize: 21586948000,
|
||||
faceValue: 3,
|
||||
faceUnit: 'SUR',
|
||||
issueDate: '2007-07-20',
|
||||
typeName: 'Акция обыкновенная',
|
||||
group: 'stock_shares',
|
||||
type: 'common_share',
|
||||
isQualifiedInvestors: false,
|
||||
morningSession: true,
|
||||
eveningSession: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('нормализует market data акции без live MOEX запроса', async () => {
|
||||
getMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
securities: {
|
||||
columns: ['SECID', 'BOARDID', 'SHORTNAME', 'PREVPRICE'],
|
||||
data: [['SBER', 'TQBR', 'Сбербанк', '320.10']],
|
||||
},
|
||||
marketdata: {
|
||||
columns: [
|
||||
'SECID',
|
||||
'BOARDID',
|
||||
'BID',
|
||||
'OFFER',
|
||||
'OPEN',
|
||||
'LOW',
|
||||
'HIGH',
|
||||
'LAST',
|
||||
'LASTCHANGE',
|
||||
'LASTCHANGEPRCNT',
|
||||
'VOLTODAY',
|
||||
'VALTODAY',
|
||||
'WAPRICE',
|
||||
'NUMTRADES',
|
||||
'ISSUECAPITALIZATION',
|
||||
'TRADINGSTATUS',
|
||||
'UPDATETIME',
|
||||
],
|
||||
data: [
|
||||
[
|
||||
'SBER',
|
||||
'TQBR',
|
||||
'321',
|
||||
'322',
|
||||
'320',
|
||||
'319',
|
||||
'323',
|
||||
'322.35',
|
||||
'1.15',
|
||||
'0.36',
|
||||
'1925163',
|
||||
'620184479',
|
||||
'321.9',
|
||||
'12345',
|
||||
'6958336818320',
|
||||
'T',
|
||||
'10:30:00',
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('getShareMarketData', () => {
|
||||
it('should return market data for SBER', async () => {
|
||||
const data = await service.getShareMarketData('SBER');
|
||||
expect(data).toBeDefined();
|
||||
expect(data!.secid).toBe('SBER');
|
||||
}, 15000);
|
||||
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -51,7 +51,7 @@ export class AddPositionDto {
|
||||
@MaxLength(500)
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS })
|
||||
@ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS, isArray: true })
|
||||
@IsArray()
|
||||
@IsIn(TAGS, { each: true })
|
||||
@IsOptional()
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { EnrichedPosition } from '../portfolio.service';
|
||||
import { PositionWithPriceDto } from './position-with-price.dto';
|
||||
|
||||
export class PortfolioSummaryDto {
|
||||
@ApiProperty() totalInvested!: number;
|
||||
@ApiProperty() totalValue!: number;
|
||||
@ApiProperty() totalPnl!: number;
|
||||
@ApiProperty() totalPnlPercent!: number | null;
|
||||
@ApiProperty({ type: Number, nullable: true }) totalPnlPercent!: number | null;
|
||||
@ApiProperty() totalDividends!: number;
|
||||
@ApiProperty() totalReturn!: number;
|
||||
@ApiProperty() totalReturnPercent!: number | null;
|
||||
@ApiProperty({ type: Number, nullable: true }) totalReturnPercent!: number | null;
|
||||
@ApiProperty() positionCount!: number;
|
||||
@ApiProperty() weightedYield!: number | null;
|
||||
@ApiProperty({ type: Number, nullable: true }) weightedYield!: number | null;
|
||||
}
|
||||
|
||||
export class AnalyticsResponseDto {
|
||||
@ApiProperty({ type: [Object] }) positions!: EnrichedPosition[];
|
||||
@ApiProperty({ type: [PositionWithPriceDto] }) positions!: PositionWithPriceDto[];
|
||||
@ApiProperty() summary!: PortfolioSummaryDto;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -1,45 +1,11 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PortfolioSummaryDto } from './analytics-response.dto';
|
||||
|
||||
class PositionWithPriceDto {
|
||||
@ApiProperty() id!: number;
|
||||
@ApiProperty({ example: 'SBER' }) secid!: string;
|
||||
@ApiPropertyOptional() shortName!: string | null;
|
||||
@ApiProperty({ example: 'share', enum: ['share', 'bond'] }) type!: string;
|
||||
@ApiProperty({ example: 10 }) quantity!: number;
|
||||
@ApiPropertyOptional() buyPrice!: number | null;
|
||||
@ApiPropertyOptional() buyDate!: string | null;
|
||||
@ApiPropertyOptional() notes!: string | null;
|
||||
@ApiPropertyOptional() tags!: string[] | null;
|
||||
@ApiPropertyOptional() currentPrice!: number | null;
|
||||
@ApiPropertyOptional() totalCost!: number | null;
|
||||
@ApiPropertyOptional() currentValue!: number | null;
|
||||
@ApiProperty() weightPercent!: number;
|
||||
@ApiPropertyOptional() pnl!: number | null;
|
||||
@ApiPropertyOptional() pnlPercent!: number | null;
|
||||
@ApiPropertyOptional() dividendIncome!: number | null;
|
||||
@ApiPropertyOptional() totalReturn!: number | null;
|
||||
@ApiPropertyOptional() totalReturnPercent!: number | null;
|
||||
@ApiPropertyOptional() change!: number | null;
|
||||
@ApiPropertyOptional() changePercent!: number | null;
|
||||
@ApiPropertyOptional() yieldToMaturity!: number | null;
|
||||
@ApiPropertyOptional() duration!: number | null;
|
||||
@ApiPropertyOptional() couponValue!: number | null;
|
||||
@ApiPropertyOptional() couponPercent!: number | null;
|
||||
@ApiPropertyOptional() nextCouponDate!: string | null;
|
||||
@ApiPropertyOptional() matDate!: string | null;
|
||||
@ApiPropertyOptional() accruedInt!: number | null;
|
||||
@ApiPropertyOptional() bid!: number | null;
|
||||
@ApiPropertyOptional() offer!: number | null;
|
||||
@ApiPropertyOptional() couponPeriod!: number | null;
|
||||
@ApiPropertyOptional() bondType!: string | null;
|
||||
@ApiPropertyOptional() offerDate!: string | null;
|
||||
}
|
||||
import { PositionWithPriceDto } from './position-with-price.dto';
|
||||
|
||||
export class PortfolioResponseDto {
|
||||
@ApiProperty() id!: number;
|
||||
@ApiProperty() name!: string;
|
||||
@ApiPropertyOptional() description!: string | null;
|
||||
@ApiPropertyOptional({ type: String, nullable: true }) description!: string | null;
|
||||
@ApiProperty({ default: 'RUB' }) currency!: string;
|
||||
@ApiProperty() createdAt!: string;
|
||||
@ApiProperty() updatedAt!: string;
|
||||
|
||||
@ -4,8 +4,8 @@ export class PositionResponseDto {
|
||||
@ApiProperty() id!: number;
|
||||
@ApiProperty({ example: 'SBER' }) secid!: string;
|
||||
@ApiProperty({ example: 10 }) quantity!: number;
|
||||
@ApiPropertyOptional() notes!: string | null;
|
||||
@ApiPropertyOptional() tags!: string[] | null;
|
||||
@ApiPropertyOptional({ type: String, nullable: true }) notes!: string | null;
|
||||
@ApiPropertyOptional({ type: String, isArray: true, nullable: true }) tags!: string[] | null;
|
||||
@ApiProperty() portfolioId!: number;
|
||||
@ApiProperty() createdAt!: string;
|
||||
@ApiProperty() updatedAt!: string;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -45,7 +45,7 @@ export class UpdatePositionDto {
|
||||
@MaxLength(500)
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS })
|
||||
@ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS, isArray: true })
|
||||
@IsArray()
|
||||
@IsIn(TAGS, { each: true })
|
||||
@IsOptional()
|
||||
|
||||
@ -1,23 +1,51 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiOkResponse } from '@nestjs/swagger';
|
||||
import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto';
|
||||
import { PortfolioDetailResponseDto } from './dto/portfolio-response.dto';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiOkResponse,
|
||||
ApiCreatedResponse,
|
||||
ApiExtraModels,
|
||||
getSchemaPath,
|
||||
} from '@nestjs/swagger';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { CreatePortfolioDto } from './dto/create-portfolio.dto';
|
||||
import { UpdatePortfolioDto } from './dto/update-portfolio.dto';
|
||||
import { AddPositionDto } from './dto/add-position.dto';
|
||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import {
|
||||
AnalyticsEnvelopeDto,
|
||||
PortfolioDetailEnvelopeDto,
|
||||
PortfolioEnvelopeDto,
|
||||
PortfolioListEnvelopeDto,
|
||||
PortfolioResponseMetaDto,
|
||||
PositionEnvelopeDto,
|
||||
} from './dto/portfolio-envelope.dto';
|
||||
|
||||
const nullDataEnvelopeSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'null',
|
||||
},
|
||||
meta: {
|
||||
$ref: getSchemaPath(PortfolioResponseMetaDto),
|
||||
},
|
||||
},
|
||||
required: ['data', 'meta'],
|
||||
};
|
||||
|
||||
@ApiTags('Portfolios')
|
||||
@ApiBearerAuth()
|
||||
@ApiExtraModels(PortfolioResponseMetaDto)
|
||||
@Controller('portfolios')
|
||||
export class PortfolioController {
|
||||
constructor(private readonly portfolioService: PortfolioService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all portfolios for current user' })
|
||||
@ApiOkResponse({ type: PortfolioListResponseDto, isArray: true })
|
||||
@ApiOkResponse({ type: PortfolioListEnvelopeDto })
|
||||
async findAll(@CurrentUser() user: { sub: number }) {
|
||||
const portfolios = await this.portfolioService.findAll(user.sub);
|
||||
return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
|
||||
@ -25,6 +53,7 @@ export class PortfolioController {
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new portfolio' })
|
||||
@ApiCreatedResponse({ type: PortfolioEnvelopeDto })
|
||||
async create(@CurrentUser() user: { sub: number }, @Body() dto: CreatePortfolioDto) {
|
||||
const portfolio = await this.portfolioService.create(user.sub, dto);
|
||||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
||||
@ -32,7 +61,7 @@ export class PortfolioController {
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get portfolio details with positions and prices' })
|
||||
@ApiOkResponse({ type: PortfolioDetailResponseDto })
|
||||
@ApiOkResponse({ type: PortfolioDetailEnvelopeDto })
|
||||
async findOne(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||
const portfolio = await this.portfolioService.findOne(user.sub, id);
|
||||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
||||
@ -40,6 +69,7 @@ export class PortfolioController {
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update portfolio' })
|
||||
@ApiOkResponse({ type: PortfolioEnvelopeDto })
|
||||
async update(
|
||||
@CurrentUser() user: { sub: number },
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@ -51,6 +81,7 @@ export class PortfolioController {
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete portfolio' })
|
||||
@ApiOkResponse({ schema: nullDataEnvelopeSchema })
|
||||
async remove(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||
await this.portfolioService.remove(user.sub, id);
|
||||
return { data: null, meta: { cachedAt: null, fromCache: false } };
|
||||
@ -58,6 +89,7 @@ export class PortfolioController {
|
||||
|
||||
@Post(':id/positions')
|
||||
@ApiOperation({ summary: 'Add position to portfolio' })
|
||||
@ApiCreatedResponse({ type: PositionEnvelopeDto })
|
||||
async addPosition(
|
||||
@CurrentUser() user: { sub: number },
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@ -69,6 +101,7 @@ export class PortfolioController {
|
||||
|
||||
@Patch(':id/positions/:positionId')
|
||||
@ApiOperation({ summary: 'Update position' })
|
||||
@ApiOkResponse({ type: PositionEnvelopeDto })
|
||||
async updatePosition(
|
||||
@CurrentUser() user: { sub: number },
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@ -81,6 +114,7 @@ export class PortfolioController {
|
||||
|
||||
@Get(':id/analytics')
|
||||
@ApiOperation({ summary: 'Get portfolio analytics with PnL' })
|
||||
@ApiOkResponse({ type: AnalyticsEnvelopeDto })
|
||||
async getAnalytics(@CurrentUser() user: { sub: number }, @Param('id', ParseIntPipe) id: number) {
|
||||
const result = await this.portfolioService.getAnalytics(user.sub, id);
|
||||
return { data: result, meta: { cachedAt: null, fromCache: false } };
|
||||
@ -88,6 +122,7 @@ export class PortfolioController {
|
||||
|
||||
@Delete(':id/positions/:positionId')
|
||||
@ApiOperation({ summary: 'Remove position from portfolio' })
|
||||
@ApiOkResponse({ schema: nullDataEnvelopeSchema })
|
||||
async removePosition(
|
||||
@CurrentUser() user: { sub: number },
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
|
||||
@ -13,13 +13,13 @@ export class ScreenerItemDto {
|
||||
@ApiProperty({ enum: ['share', 'bond'] })
|
||||
type!: 'share' | 'bond';
|
||||
|
||||
@ApiPropertyOptional({ example: 322.35 })
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 322.35 })
|
||||
price!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 1.15 })
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 1.15 })
|
||||
change!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 0.36 })
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 0.36 })
|
||||
changePercent!: number | null;
|
||||
|
||||
@ApiProperty({ example: 1925163 })
|
||||
@ -28,28 +28,28 @@ export class ScreenerItemDto {
|
||||
@ApiProperty({ example: 1 })
|
||||
listLevel!: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 6958336818320 })
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 6958336818320 })
|
||||
capitalization!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 12.71 })
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 12.71 })
|
||||
yieldToMaturity!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 4.5 })
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 4.5 })
|
||||
duration!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 40.64 })
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 40.64 })
|
||||
couponValue!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 8.15 })
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 8.15 })
|
||||
couponPercent!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 29.48 })
|
||||
@ApiPropertyOptional({ type: Number, nullable: true, example: 29.48 })
|
||||
accruedInt!: number | null;
|
||||
|
||||
@ApiPropertyOptional({ example: '2027-02-03' })
|
||||
@ApiPropertyOptional({ type: String, nullable: true, example: '2027-02-03' })
|
||||
matDate!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 'ОФЗ-ПД' })
|
||||
@ApiPropertyOptional({ type: String, nullable: true, example: 'ОФЗ-ПД' })
|
||||
bondType!: string | null;
|
||||
}
|
||||
|
||||
@ -69,3 +69,19 @@ export class ScreenerResultDto {
|
||||
@ApiProperty()
|
||||
totalPages!: number;
|
||||
}
|
||||
|
||||
class ScreenerResponseMetaDto {
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
cachedAt!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
fromCache!: boolean;
|
||||
}
|
||||
|
||||
export class ScreenerResponseDto {
|
||||
@ApiProperty({ type: ScreenerResultDto })
|
||||
data!: ScreenerResultDto;
|
||||
|
||||
@ApiProperty({ type: ScreenerResponseMetaDto })
|
||||
meta!: ScreenerResponseMetaDto;
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@ import { ScreenerType } from './dto/screener-query.dto';
|
||||
|
||||
describe('ScreenerService', () => {
|
||||
let service: ScreenerService;
|
||||
let moexClient: MoexClientService;
|
||||
let cache: CacheService;
|
||||
|
||||
beforeEach(async () => {
|
||||
@ -30,7 +29,6 @@ describe('ScreenerService', () => {
|
||||
}).compile();
|
||||
|
||||
service = module.get<ScreenerService>(ScreenerService);
|
||||
moexClient = module.get<MoexClientService>(MoexClientService);
|
||||
cache = module.get<CacheService>(CacheService);
|
||||
});
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import { SecuritiesService } from './securities.service';
|
||||
import { ScreenerService } from './screener.service';
|
||||
import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
|
||||
import { ScreenerQueryDto } from './dto/screener-query.dto';
|
||||
import { ScreenerResultDto } from './dto/screener-response.dto';
|
||||
import { ScreenerResponseDto } from './dto/screener-response.dto';
|
||||
|
||||
@ApiTags('Securities')
|
||||
@Controller('securities')
|
||||
@ -27,7 +27,7 @@ export class SecuritiesController {
|
||||
|
||||
@Get('screener')
|
||||
@ApiOperation({ summary: 'Фильтр ценных бумаг по параметрам' })
|
||||
@ApiOkResponse({ type: ScreenerResultDto })
|
||||
@ApiOkResponse({ type: ScreenerResponseDto })
|
||||
async screener(@Query(ValidationPipe) query: ScreenerQueryDto) {
|
||||
const result = await this.screenerService.screen(query);
|
||||
return { data: result, meta: { cachedAt: null, fromCache: false } };
|
||||
|
||||
@ -1,38 +1,174 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import configuration from '../../config/configuration';
|
||||
import { SecurityType } from './dto/search-query.dto';
|
||||
|
||||
describe('SecuritiesService', () => {
|
||||
let service: SecuritiesService;
|
||||
let moexClient: Pick<MoexClientService, 'searchSecurities'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexClient = {
|
||||
searchSecurities: vi.fn(),
|
||||
};
|
||||
cache = {
|
||||
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
})),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [
|
||||
SecuritiesService,
|
||||
MoexClientService,
|
||||
{
|
||||
provide: 'CACHE_MANAGER',
|
||||
useValue: {
|
||||
get: () => undefined,
|
||||
set: () => Promise.resolve(),
|
||||
del: () => Promise.resolve(),
|
||||
},
|
||||
},
|
||||
CacheService,
|
||||
{ provide: MoexClientService, useValue: moexClient },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SecuritiesService>(SecuritiesService);
|
||||
});
|
||||
|
||||
it('should return search results for SBER', async () => {
|
||||
const results = await service.search('SBER', SecurityType.ALL, 5);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].secid).toBeDefined();
|
||||
}, 15000);
|
||||
it('returns supported securities only and normalizes SUR currency to RUB', async () => {
|
||||
vi.mocked(moexClient.searchSecurities).mockResolvedValue([
|
||||
{
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
name: 'Сбербанк России ПАО ао',
|
||||
shortName: 'Сбербанк',
|
||||
latName: 'Sberbank',
|
||||
listLevel: 1,
|
||||
issueSize: 21586948000,
|
||||
faceValue: 3,
|
||||
faceUnit: 'SUR',
|
||||
issueDate: '2007-07-20',
|
||||
typeName: 'Акция обыкновенная',
|
||||
group: 'stock_shares',
|
||||
type: 'common_share',
|
||||
isQualifiedInvestors: false,
|
||||
morningSession: true,
|
||||
eveningSession: true,
|
||||
},
|
||||
{
|
||||
secid: 'SU26238RMFS5',
|
||||
isin: 'RU000A1038V6',
|
||||
name: 'ОФЗ 26238',
|
||||
shortName: 'ОФЗ 26238',
|
||||
latName: null,
|
||||
listLevel: 1,
|
||||
issueSize: 100000000,
|
||||
faceValue: 1000,
|
||||
faceUnit: 'RUB',
|
||||
issueDate: '2021-06-23',
|
||||
typeName: 'Государственная облигация',
|
||||
group: 'stock_bonds',
|
||||
type: 'ofz_bond',
|
||||
isQualifiedInvestors: false,
|
||||
morningSession: false,
|
||||
eveningSession: false,
|
||||
},
|
||||
{
|
||||
secid: 'SiM6',
|
||||
isin: '',
|
||||
name: 'USD/RUB Futures',
|
||||
shortName: 'SiM6',
|
||||
latName: null,
|
||||
listLevel: 0,
|
||||
issueSize: 0,
|
||||
faceValue: 0,
|
||||
faceUnit: '',
|
||||
issueDate: '',
|
||||
typeName: 'Фьючерс',
|
||||
group: 'futures',
|
||||
type: 'futures',
|
||||
isQualifiedInvestors: false,
|
||||
morningSession: false,
|
||||
eveningSession: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const results = await service.search('SbEr', SecurityType.ALL, 10);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
shortName: 'Сбербанк',
|
||||
type: 'share',
|
||||
listLevel: 1,
|
||||
currency: 'RUB',
|
||||
price: null,
|
||||
},
|
||||
{
|
||||
secid: 'SU26238RMFS5',
|
||||
isin: 'RU000A1038V6',
|
||||
shortName: 'ОФЗ 26238',
|
||||
type: 'bond',
|
||||
listLevel: 1,
|
||||
currency: 'RUB',
|
||||
price: null,
|
||||
},
|
||||
]);
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'search',
|
||||
['sber'],
|
||||
expect.any(Function),
|
||||
'searchTtl',
|
||||
);
|
||||
expect(moexClient.searchSecurities).toHaveBeenCalledWith('SbEr');
|
||||
});
|
||||
|
||||
it('filters by type and applies limit without live MOEX dependency', async () => {
|
||||
vi.mocked(cache.getOrFetch).mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
shortName: 'Сбербанк',
|
||||
type: 'share',
|
||||
listLevel: 1,
|
||||
currency: 'RUB',
|
||||
price: null,
|
||||
},
|
||||
{
|
||||
secid: 'GAZP',
|
||||
isin: 'RU0007661625',
|
||||
shortName: 'Газпром',
|
||||
type: 'share',
|
||||
listLevel: 1,
|
||||
currency: 'RUB',
|
||||
price: null,
|
||||
},
|
||||
{
|
||||
secid: 'SU26238RMFS5',
|
||||
isin: 'RU000A1038V6',
|
||||
shortName: 'ОФЗ 26238',
|
||||
type: 'bond',
|
||||
listLevel: 1,
|
||||
currency: 'RUB',
|
||||
price: null,
|
||||
},
|
||||
],
|
||||
fromCache: true,
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const results = await service.search('ru', SecurityType.SHARE, 1);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
shortName: 'Сбербанк',
|
||||
type: 'share',
|
||||
listLevel: 1,
|
||||
currency: 'RUB',
|
||||
price: null,
|
||||
},
|
||||
]);
|
||||
expect(moexClient.searchSecurities).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,41 +1,135 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { SharesService } from './shares.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import configuration from '../../config/configuration';
|
||||
|
||||
describe('SharesService', () => {
|
||||
let service: SharesService;
|
||||
let moexClient: Pick<MoexClientService, 'getSecurityDescription' | 'getShareMarketData'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexClient = {
|
||||
getSecurityDescription: vi.fn(),
|
||||
getShareMarketData: vi.fn(),
|
||||
};
|
||||
cache = {
|
||||
getOrFetch: vi.fn(async (_keyPrefix, _keyParts, fetchFn) => ({
|
||||
data: await fetchFn(),
|
||||
fromCache: false,
|
||||
cachedAt: '2026-06-15T00:00:00.000Z',
|
||||
})),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [
|
||||
SharesService,
|
||||
MoexClientService,
|
||||
{
|
||||
provide: 'CACHE_MANAGER',
|
||||
useValue: {
|
||||
get: () => undefined,
|
||||
set: () => Promise.resolve(),
|
||||
del: () => Promise.resolve(),
|
||||
},
|
||||
},
|
||||
CacheService,
|
||||
{ provide: MoexClientService, useValue: moexClient },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SharesService>(SharesService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
it('returns normalized SBER share spec and market data without live MOEX dependency', async () => {
|
||||
vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
name: 'Сбербанк России ПАО ао',
|
||||
shortName: 'Сбербанк',
|
||||
latName: 'Sberbank',
|
||||
listLevel: 1,
|
||||
issueSize: 21586948000,
|
||||
faceValue: 3,
|
||||
faceUnit: 'SUR',
|
||||
issueDate: '2007-07-20',
|
||||
typeName: 'Акция обыкновенная',
|
||||
group: 'stock_shares',
|
||||
type: 'common_share',
|
||||
isQualifiedInvestors: false,
|
||||
morningSession: true,
|
||||
eveningSession: true,
|
||||
});
|
||||
vi.mocked(moexClient.getShareMarketData).mockResolvedValue({
|
||||
secid: 'SBER',
|
||||
boardid: 'TQBR',
|
||||
shortName: 'Сбербанк',
|
||||
bid: 320,
|
||||
offer: 321,
|
||||
open: 318,
|
||||
low: 317,
|
||||
high: 325,
|
||||
last: 323,
|
||||
lastChange: 4,
|
||||
lastChangePrcnt: 1.25,
|
||||
volume: 1500000,
|
||||
value: 480000000,
|
||||
waprice: 321,
|
||||
numtrades: 4200,
|
||||
issueCapitalization: 6900000000000,
|
||||
tradingStatus: 'T',
|
||||
updateTime: '18:45:00',
|
||||
});
|
||||
|
||||
it('should return SBER share data', async () => {
|
||||
const share = await service.getShare('SBER');
|
||||
expect(share.secid).toBe('SBER');
|
||||
expect(share.marketData).toBeDefined();
|
||||
}, 15000);
|
||||
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('throws NotFoundException for non-share security', async () => {
|
||||
vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
|
||||
secid: 'SU26238RMFS5',
|
||||
isin: 'RU000A1038V6',
|
||||
name: 'ОФЗ 26238',
|
||||
shortName: 'ОФЗ 26238',
|
||||
latName: null,
|
||||
listLevel: 1,
|
||||
issueSize: 100000000,
|
||||
faceValue: 1000,
|
||||
faceUnit: 'RUB',
|
||||
issueDate: '2021-06-23',
|
||||
typeName: 'Государственная облигация',
|
||||
group: 'stock_bonds',
|
||||
type: 'ofz_bond',
|
||||
isQualifiedInvestors: false,
|
||||
morningSession: false,
|
||||
eveningSession: false,
|
||||
});
|
||||
|
||||
await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(cache.getOrFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
108
apps/backend/src/openapi-artifacts.spec.ts
Normal file
108
apps/backend/src/openapi-artifacts.spec.ts
Normal 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' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -130,6 +130,25 @@
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /securities/screener`
|
||||
|
||||
Скринер ценных бумаг по параметрам цены, объёма, доходности, дюрации, купона и срока погашения.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Param | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `type` | enum | yes | `share` или `bond` |
|
||||
| `priceMin`, `priceMax` | number | no | Диапазон цены |
|
||||
| `volumeMin` | number | no | Минимальный объём |
|
||||
| `yieldMin`, `yieldMax` | number | no | Диапазон доходности облигаций |
|
||||
| `durationMin`, `durationMax` | number | no | Диапазон дюрации |
|
||||
| `sortBy` | string | no | Поле сортировки |
|
||||
| `sortOrder` | enum | no | `asc` или `desc` |
|
||||
| `page`, `pageSize` | integer | no | Пагинация |
|
||||
|
||||
**Response:** `{ data: { items, total, page, pageSize, totalPages }, meta }`.
|
||||
|
||||
## Shares
|
||||
|
||||
### `GET /securities/shares/:secid`
|
||||
@ -354,3 +373,22 @@
|
||||
"meta": { "fromCache": false, "cachedAt": null }
|
||||
}
|
||||
```
|
||||
|
||||
## Portfolios
|
||||
|
||||
Все portfolio endpoints защищены JWT и возвращают envelope `{ data, meta }`.
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|---|---|---|
|
||||
| `/api/v1/portfolios` | GET | Список портфелей пользователя |
|
||||
| `/api/v1/portfolios` | POST | Создать портфель |
|
||||
| `/api/v1/portfolios/:id` | GET | Детали портфеля с позициями и текущими ценами |
|
||||
| `/api/v1/portfolios/:id` | PATCH | Обновить портфель |
|
||||
| `/api/v1/portfolios/:id` | DELETE | Удалить портфель |
|
||||
| `/api/v1/portfolios/:id/positions` | POST | Добавить позицию |
|
||||
| `/api/v1/portfolios/:id/positions/:positionId` | PATCH | Обновить позицию |
|
||||
| `/api/v1/portfolios/:id/positions/:positionId` | DELETE | Удалить позицию |
|
||||
| `/api/v1/portfolios/:id/analytics` | GET | Аналитика портфеля и PnL |
|
||||
|
||||
`DELETE` endpoints возвращают `{ data: null, meta }`, что отражено в OpenAPI schema и frontend
|
||||
codegen types.
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
# Portfolio Module
|
||||
|
||||
The Portfolio module allows users to create and manage virtual investment portfolios for tracking purposes.
|
||||
Portfolio module позволяет пользователям создавать и вести виртуальные инвестиционные портфели для
|
||||
аналитики и отслеживания позиций.
|
||||
|
||||
## Overview
|
||||
|
||||
@ -10,19 +11,19 @@ The Portfolio module allows users to create and manage virtual investment portfo
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints require JWT authentication (`JwtAuthGuard`).
|
||||
Все endpoints требуют JWT authentication (`JwtAuthGuard`).
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|---|---|---|
|
||||
| `/api/v1/portfolios` | GET | List user's portfolios |
|
||||
| `/api/v1/portfolios` | POST | Create portfolio |
|
||||
| `/api/v1/portfolios/:id` | GET | Portfolio detail with enriched positions and analytics summary |
|
||||
| `/api/v1/portfolios/:id/analytics` | GET | Detailed portfolio analytics with PnL |
|
||||
| `/api/v1/portfolios/:id/patch` | PATCH | Update portfolio (name, description, currency) |
|
||||
| `/api/v1/portfolios/:id` | DELETE | Delete portfolio (cascade deletes positions) |
|
||||
| `/api/v1/portfolios/:id/positions` | POST | Add position (accepts buyPrice, buyDate) |
|
||||
| `/api/v1/portfolios/:id/positions/:posId` | PATCH | Update position (quantity, buyPrice, buyDate, notes) |
|
||||
| `/api/v1/portfolios/:id/positions/:posId` | DELETE | Remove position |
|
||||
| `/api/v1/portfolios` | GET | Список портфелей пользователя |
|
||||
| `/api/v1/portfolios` | POST | Создать портфель |
|
||||
| `/api/v1/portfolios/:id` | GET | Детали портфеля с обогащёнными позициями и analytics summary |
|
||||
| `/api/v1/portfolios/:id/analytics` | GET | Детальная аналитика портфеля и PnL |
|
||||
| `/api/v1/portfolios/:id` | PATCH | Обновить портфель (name, description, currency) |
|
||||
| `/api/v1/portfolios/:id` | DELETE | Удалить портфель вместе с позициями |
|
||||
| `/api/v1/portfolios/:id/positions` | POST | Добавить позицию (buyPrice, buyDate, notes, tags) |
|
||||
| `/api/v1/portfolios/:id/positions/:positionId` | PATCH | Обновить позицию |
|
||||
| `/api/v1/portfolios/:id/positions/:positionId` | DELETE | Удалить позицию |
|
||||
|
||||
## Domain Model
|
||||
|
||||
|
||||
@ -24,6 +24,19 @@ openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts
|
||||
### Output
|
||||
|
||||
- `apps/frontend/src/api/types.ts` — сгенерированные типы `paths` и `operations`
|
||||
- `docs/openapi/openapi.yaml` — статический snapshot Swagger JSON для ревью и документации
|
||||
|
||||
### Verify Artifacts
|
||||
|
||||
После регенерации OpenAPI artifacts запустите:
|
||||
|
||||
```bash
|
||||
npm run test -w apps/backend -- src/openapi-artifacts.spec.ts
|
||||
```
|
||||
|
||||
Тест проверяет, что checked-in frontend types и YAML содержат актуальные auth, screener и portfolio
|
||||
paths, не содержат локальный alternate port и сохраняют важные schema metadata для nullable полей,
|
||||
array enum tags и typed response envelopes.
|
||||
|
||||
### Manual Types
|
||||
|
||||
|
||||
@ -5,13 +5,16 @@
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 |
|
||||
| `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 |
|
||||
| `npm run dev:frontend` | Vite dev-сервер на `:5173`, проксирует `/api` на backend |
|
||||
| `npm run dev:docs` | Docusaurus dev-сервер документации |
|
||||
| `npm run build:backend` | `nest build` |
|
||||
| `npm run build:frontend` | `tsc -b && vite build` (в две фазы) |
|
||||
| `npm run test:backend` | `vitest run` (SWC, не ts-jest) |
|
||||
| `npm run lint` | ESLint только для бэкенда |
|
||||
| `npm run build:frontend` | `tsc -b && vite build` |
|
||||
| `npm run build:docs` | `docusaurus build` |
|
||||
| `npm run test:backend` | Offline backend unit tests через Vitest |
|
||||
| `npm run test:frontend` | Frontend tests через Vitest + Testing Library |
|
||||
| `npm run lint` | ESLint для backend и frontend |
|
||||
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
|
||||
| `npm run format:check` | Prettier check для всех `*.{ts,tsx}` |
|
||||
| `npm run format:check` | Проверка Prettier для всех `*.{ts,tsx}` |
|
||||
|
||||
## Backend Workspace
|
||||
|
||||
@ -23,6 +26,7 @@
|
||||
| `npm run lint -w apps/backend` | ESLint для `{src,test}/**/*.ts` |
|
||||
| `npm run test -w apps/backend` | `vitest run` |
|
||||
| `npm run test:watch -w apps/backend` | `vitest` |
|
||||
| `npm run test:integration -w apps/backend` | Opt-in live MOEX integration tests, требуется network access |
|
||||
|
||||
## Frontend Workspace
|
||||
|
||||
@ -32,6 +36,16 @@
|
||||
| `npm run build -w apps/frontend` | `tsc -b && vite build` |
|
||||
| `npm run preview -w apps/frontend` | `vite preview` |
|
||||
| `npm run codegen -w apps/frontend` | `openapi-typescript` из Swagger → `src/api/types.ts` |
|
||||
| `npm run lint -w apps/frontend` | ESLint для `src/**/*.{ts,tsx}` |
|
||||
| `npm run test -w apps/frontend` | Frontend Vitest suite |
|
||||
|
||||
## Docs Workspace
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `npm run dev -w apps/docs` | Docusaurus dev-server |
|
||||
| `npm run build -w apps/docs` | Production build документации |
|
||||
| `npm run serve -w apps/docs` | Локальная проверка production build |
|
||||
|
||||
## Single Test
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ Prettier (`.prettierrc`):
|
||||
|
||||
## Linting
|
||||
|
||||
ESLint только для бэкенда (`apps/backend`).
|
||||
ESLint запускается для backend (`apps/backend`) и frontend (`apps/frontend`).
|
||||
|
||||
Плагины:
|
||||
- `@typescript-eslint/eslint-plugin`
|
||||
|
||||
@ -39,4 +39,25 @@ npm run test:watch -w apps/backend
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
Фронтенд-тесты отсутствуют.
|
||||
Фреймворк: **Vitest 4** + **React Testing Library** + **MSW**.
|
||||
|
||||
Запуск:
|
||||
|
||||
```bash
|
||||
npm run test:frontend
|
||||
# или
|
||||
npm run test -w apps/frontend
|
||||
```
|
||||
|
||||
Тесты покрывают API-клиент, auth context, hooks, базовые pages и shared components.
|
||||
|
||||
## Live MOEX Integration Tests
|
||||
|
||||
Live MOEX checks вынесены из default backend suite.
|
||||
|
||||
```bash
|
||||
npm run test:integration -w apps/backend
|
||||
```
|
||||
|
||||
Эта команда opt-in: она требует network access и может падать при недоступности MOEX или сетевых
|
||||
ограничениях окружения.
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
async function request<T>(
|
||||
path: string,
|
||||
params?: Record<string, string>,
|
||||
options?: { method?: string; body?: unknown; skipAuth?: boolean },
|
||||
): Promise<{ data: T; meta: ApiResponseMeta }>
|
||||
```
|
||||
|
||||
@ -20,7 +21,14 @@ async function request<T>(
|
||||
| Function | Method | Path |
|
||||
|---|---|---|
|
||||
| `getHealth()` | GET | `/api/v1/health` |
|
||||
| `register(data)` | POST | `/api/v1/auth/register` |
|
||||
| `login(data)` | POST | `/api/v1/auth/login` |
|
||||
| `refresh()` | POST | `/api/v1/auth/refresh` |
|
||||
| `logout()` | POST | `/api/v1/auth/logout` |
|
||||
| `getProfile()` / `getMe()` | GET | `/api/v1/auth/me` |
|
||||
| `updateProfile(data)` | PATCH | `/api/v1/auth/me` |
|
||||
| `searchSecurities(q, type?, limit?)` | GET | `/api/v1/securities/search` |
|
||||
| `screenSecurities(query)` / `getScreenerResults(query)` | GET | `/api/v1/securities/screener` |
|
||||
| `getShare(secid)` | GET | `/api/v1/securities/shares/:secid` |
|
||||
| `getShareMarketData(secid)` | GET | `/api/v1/securities/shares/:secid/marketdata` |
|
||||
| `getShareDividends(secid)` | GET | `/api/v1/securities/shares/:secid/dividends` |
|
||||
@ -30,6 +38,15 @@ async function request<T>(
|
||||
| `getBondHistory(secid, from, till)` | GET | `/api/v1/securities/bonds/:secid/history` |
|
||||
| `getShareCandles(secid, interval, from, till)` | GET | `/api/v1/securities/shares/:secid/candles` |
|
||||
| `getBondCandles(secid, interval, from, till)` | GET | `/api/v1/securities/bonds/:secid/candles` |
|
||||
| `getPortfolios()` | GET | `/api/v1/portfolios` |
|
||||
| `createPortfolio(data)` | POST | `/api/v1/portfolios` |
|
||||
| `getPortfolio(id)` | GET | `/api/v1/portfolios/:id` |
|
||||
| `updatePortfolio(id, data)` | PATCH | `/api/v1/portfolios/:id` |
|
||||
| `deletePortfolio(id)` | DELETE | `/api/v1/portfolios/:id` |
|
||||
| `addPosition(portfolioId, data)` | POST | `/api/v1/portfolios/:id/positions` |
|
||||
| `updatePosition(portfolioId, positionId, data)` | PATCH | `/api/v1/portfolios/:id/positions/:positionId` |
|
||||
| `removePosition(portfolioId, positionId)` | DELETE | `/api/v1/portfolios/:id/positions/:positionId` |
|
||||
| `getPortfolioAnalytics(portfolioId)` | GET | `/api/v1/portfolios/:id/analytics` |
|
||||
|
||||
## Types
|
||||
|
||||
@ -41,5 +58,8 @@ async function request<T>(
|
||||
- `BondResponse` — спецификация облигации + `BondMarketData`
|
||||
- `CandleItem`, `DividendItem`, `ShareHistoryItem`, `BondHistoryItem`
|
||||
- `SearchResultItem`, `HealthResponse`
|
||||
- `UserResponse`, `AuthResponse`
|
||||
- `Portfolio`, `PortfolioDetail`, `Position`, `PortfolioSummary`, `AnalyticsResponse`
|
||||
- `ScreenerItem`, `ScreenerResult`
|
||||
|
||||
Codegen-типы из OpenAPI в `api/types.ts` (генерируются через `npm run codegen`).
|
||||
|
||||
@ -9,6 +9,7 @@ React SPA, собранная с Vite.
|
||||
- TanStack Query v5
|
||||
- lightweight-charts v4
|
||||
- openapi-fetch (с рукописными типами `responses.ts`)
|
||||
- Vitest + Testing Library + MSW
|
||||
- Vite 5
|
||||
|
||||
## Source Layout
|
||||
@ -19,10 +20,22 @@ apps/frontend/src/
|
||||
├── App.tsx # BrowserRouter
|
||||
├── routes.tsx # Маршруты
|
||||
├── api/
|
||||
│ ├── auth.ts # Auth API helpers
|
||||
│ ├── client.ts # HTTP-клиент (fetch)
|
||||
│ ├── portfolio.ts # Portfolio API helpers
|
||||
│ ├── responses.ts # Типы ответов (ручные)
|
||||
│ ├── screener.ts # Screener API helpers
|
||||
│ └── types.ts # Типы из openapi-typescript
|
||||
├── context/
|
||||
│ └── AuthContext.tsx
|
||||
├── hooks/
|
||||
│ ├── useAuth.ts
|
||||
│ ├── usePortfolio.ts
|
||||
│ ├── usePortfolioAnalytics.ts
|
||||
│ ├── usePortfolioMutations.ts
|
||||
│ ├── usePortfolios.ts
|
||||
│ ├── usePositionMutations.ts
|
||||
│ ├── useScreener.ts
|
||||
│ ├── useSearch.ts
|
||||
│ ├── useStock.ts
|
||||
│ ├── useStockCandles.ts
|
||||
@ -31,14 +44,24 @@ apps/frontend/src/
|
||||
│ └── useBondCandles.ts
|
||||
├── components/
|
||||
│ ├── Layout.tsx
|
||||
│ ├── ProtectedRoute.tsx
|
||||
│ ├── SearchBar.tsx
|
||||
│ ├── PriceChart.tsx
|
||||
│ ├── StockDetails.tsx
|
||||
│ └── BondDetails.tsx
|
||||
│ ├── BondDetails.tsx
|
||||
│ └── portfolios/
|
||||
├── pages/
|
||||
│ ├── HomePage.tsx
|
||||
│ ├── StockPage.tsx
|
||||
│ └── BondPage.tsx
|
||||
│ ├── BondPage.tsx
|
||||
│ ├── LoginPage.tsx
|
||||
│ ├── RegisterPage.tsx
|
||||
│ ├── ProfilePage.tsx
|
||||
│ ├── portfolios/
|
||||
│ └── screener/
|
||||
├── test/
|
||||
│ ├── handlers.ts # MSW handlers
|
||||
│ └── test-utils.tsx
|
||||
└── styles.css
|
||||
```
|
||||
|
||||
|
||||
@ -2,11 +2,17 @@
|
||||
|
||||
Определены в `apps/frontend/src/routes.tsx`.
|
||||
|
||||
| Path | Component | Description |
|
||||
|---|---|---|
|
||||
| `/` | `HomePage` | Главная страница с приветствием |
|
||||
| `/stocks/:secid` | `StockPage` | Страница акции |
|
||||
| `/bonds/:secid` | `BondPage` | Страница облигации |
|
||||
| Path | Component | Access | Description |
|
||||
|---|---|---|---|
|
||||
| `/` | `HomePage` | Public | Главная страница |
|
||||
| `/stocks/:secid` | `StockPage` | Public | Страница акции |
|
||||
| `/bonds/:secid` | `BondPage` | Public | Страница облигации |
|
||||
| `/screener` | `ScreenerPage` | Public | Скринер ценных бумаг |
|
||||
| `/login` | `LoginPage` | Public | Вход |
|
||||
| `/register` | `RegisterPage` | Public | Регистрация |
|
||||
| `/profile` | `ProfilePage` | Protected | Профиль текущего пользователя |
|
||||
| `/portfolios` | `PortfoliosListPage` | Protected | Список портфелей |
|
||||
| `/portfolios/:id` | `PortfolioDetailPage` | Protected | Детальная страница портфеля |
|
||||
|
||||
Все страницы обёрнуты в `Layout`, который содержит:
|
||||
|
||||
@ -22,6 +28,33 @@
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/stocks/:secid" element={<StockPage />} />
|
||||
<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>
|
||||
</Routes>
|
||||
```
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
---
|
||||
slug: /
|
||||
---
|
||||
|
||||
# MoexVibe
|
||||
|
||||
Веб-приложение для анализа ценных бумаг Московской биржи (MOEX).
|
||||
@ -15,7 +19,8 @@
|
||||
moex-vibe/
|
||||
├── apps/
|
||||
│ ├── backend/ # NestJS API (единственная точка доступа к MOEX)
|
||||
│ └── frontend/ # React SPA
|
||||
│ ├── frontend/ # React SPA
|
||||
│ └── docs/ # Docusaurus documentation site
|
||||
├── docs/
|
||||
│ ├── architecture/ # ADR и диаграммы
|
||||
│ ├── openapi/ # OpenAPI-спецификация
|
||||
@ -31,6 +36,6 @@ moex-vibe/
|
||||
## Key Principles
|
||||
|
||||
- Backend — единственный клиент MOEX. Frontend никогда не обращается к MOEX напрямую.
|
||||
- npm workspaces монорепозиторий: `apps/backend` и `apps/frontend`.
|
||||
- npm workspaces монорепозиторий: `apps/backend`, `apps/frontend` и `apps/docs`.
|
||||
- Глобальный префикс API: `/api/v1`. Swagger: `/api/docs`.
|
||||
- Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`.
|
||||
|
||||
@ -21,6 +21,92 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/auth/register': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Register new user */
|
||||
post: operations['AuthController_register'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/auth/login': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Login with email and password */
|
||||
post: operations['AuthController_login'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/auth/refresh': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Refresh access token */
|
||||
post: operations['AuthController_refresh'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/auth/logout': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Logout user */
|
||||
post: operations['AuthController_logout'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/auth/me': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get current user profile */
|
||||
get: operations['AuthController_getProfile'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** Update current user profile */
|
||||
patch: operations['AuthController_updateProfile'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/securities/search': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -38,6 +124,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/securities/screener': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Фильтр ценных бумаг по параметрам */
|
||||
get: operations['SecuritiesController_screener'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/securities/shares/{secid}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -191,10 +294,402 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/portfolios': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get all portfolios for current user */
|
||||
get: operations['PortfolioController_findAll'];
|
||||
put?: never;
|
||||
/** Create a new portfolio */
|
||||
post: operations['PortfolioController_create'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/portfolios/{id}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get portfolio details with positions and prices */
|
||||
get: operations['PortfolioController_findOne'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Delete portfolio */
|
||||
delete: operations['PortfolioController_remove'];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** Update portfolio */
|
||||
patch: operations['PortfolioController_update'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/portfolios/{id}/positions': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Add position to portfolio */
|
||||
post: operations['PortfolioController_addPosition'];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/portfolios/{id}/positions/{positionId}': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** Remove position from portfolio */
|
||||
delete: operations['PortfolioController_removePosition'];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** Update position */
|
||||
patch: operations['PortfolioController_updatePosition'];
|
||||
trace?: never;
|
||||
};
|
||||
'/api/v1/portfolios/{id}/analytics': {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get portfolio analytics with PnL */
|
||||
get: operations['PortfolioController_getAnalytics'];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: never;
|
||||
schemas: {
|
||||
RegisterDto: {
|
||||
/** @example user@example.com */
|
||||
email: string;
|
||||
/** @example securePass123 */
|
||||
password: string;
|
||||
/** @example John */
|
||||
name?: string;
|
||||
};
|
||||
AuthUserDto: {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
};
|
||||
AuthTokenDataDto: {
|
||||
user: components['schemas']['AuthUserDto'];
|
||||
accessToken: string;
|
||||
};
|
||||
AuthResponseMetaDto: {
|
||||
cachedAt: string | null;
|
||||
fromCache: boolean;
|
||||
};
|
||||
AuthTokenResponseDto: {
|
||||
data: components['schemas']['AuthTokenDataDto'];
|
||||
meta: components['schemas']['AuthResponseMetaDto'];
|
||||
};
|
||||
LoginDto: {
|
||||
/** @example user@example.com */
|
||||
email: string;
|
||||
/** @example securePass123 */
|
||||
password: string;
|
||||
};
|
||||
LogoutDataDto: {
|
||||
message: string;
|
||||
};
|
||||
AuthLogoutResponseDto: {
|
||||
data: components['schemas']['LogoutDataDto'];
|
||||
meta: components['schemas']['AuthResponseMetaDto'];
|
||||
};
|
||||
AuthProfileResponseDto: {
|
||||
data: components['schemas']['AuthUserDto'];
|
||||
meta: components['schemas']['AuthResponseMetaDto'];
|
||||
};
|
||||
UpdateProfileDto: {
|
||||
/** @example John Doe */
|
||||
name?: string;
|
||||
};
|
||||
ScreenerItemDto: {
|
||||
/** @example SBER */
|
||||
secid: string;
|
||||
/** @example Сбербанк */
|
||||
shortName: string;
|
||||
/** @example RU0009029540 */
|
||||
isin: string;
|
||||
/** @enum {string} */
|
||||
type: 'share' | 'bond';
|
||||
/** @example 322.35 */
|
||||
price?: number | null;
|
||||
/** @example 1.15 */
|
||||
change?: number | null;
|
||||
/** @example 0.36 */
|
||||
changePercent?: number | null;
|
||||
/** @example 1925163 */
|
||||
volume: number;
|
||||
/** @example 1 */
|
||||
listLevel: number;
|
||||
/** @example 6958336818320 */
|
||||
capitalization?: number | null;
|
||||
/** @example 12.71 */
|
||||
yieldToMaturity?: number | null;
|
||||
/** @example 4.5 */
|
||||
duration?: number | null;
|
||||
/** @example 40.64 */
|
||||
couponValue?: number | null;
|
||||
/** @example 8.15 */
|
||||
couponPercent?: number | null;
|
||||
/** @example 29.48 */
|
||||
accruedInt?: number | null;
|
||||
/** @example 2027-02-03 */
|
||||
matDate?: string | null;
|
||||
/** @example ОФЗ-ПД */
|
||||
bondType?: string | null;
|
||||
};
|
||||
ScreenerResultDto: {
|
||||
items: components['schemas']['ScreenerItemDto'][];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
};
|
||||
ScreenerResponseMetaDto: {
|
||||
cachedAt: string | null;
|
||||
fromCache: boolean;
|
||||
};
|
||||
ScreenerResponseDto: {
|
||||
data: components['schemas']['ScreenerResultDto'];
|
||||
meta: components['schemas']['ScreenerResponseMetaDto'];
|
||||
};
|
||||
PortfolioResponseMetaDto: {
|
||||
cachedAt: string | null;
|
||||
fromCache: boolean;
|
||||
};
|
||||
PortfolioListResponseDto: {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
/** @default RUB */
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** @description Total market value of all positions */
|
||||
totalValue: number;
|
||||
/** @description Total number of positions */
|
||||
positionCount: number;
|
||||
/** @description Number of share positions */
|
||||
shareCount: number;
|
||||
/** @description Number of bond positions */
|
||||
bondCount: number;
|
||||
};
|
||||
PortfolioListEnvelopeDto: {
|
||||
data: components['schemas']['PortfolioListResponseDto'][];
|
||||
meta: components['schemas']['PortfolioResponseMetaDto'];
|
||||
};
|
||||
CreatePortfolioDto: {
|
||||
/** @example Мой портфель */
|
||||
name: string;
|
||||
/** @example Описание портфеля */
|
||||
description?: string;
|
||||
/**
|
||||
* @default RUB
|
||||
* @enum {string}
|
||||
*/
|
||||
currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN';
|
||||
};
|
||||
PortfolioResponseDto: {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
/** @default RUB */
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
PortfolioEnvelopeDto: {
|
||||
data: components['schemas']['PortfolioResponseDto'];
|
||||
meta: components['schemas']['PortfolioResponseMetaDto'];
|
||||
};
|
||||
PositionWithPriceDto: {
|
||||
id: number;
|
||||
/** @example SBER */
|
||||
secid: string;
|
||||
shortName?: string | null;
|
||||
/**
|
||||
* @example share
|
||||
* @enum {string}
|
||||
*/
|
||||
type: 'share' | 'bond';
|
||||
/** @example 10 */
|
||||
quantity: number;
|
||||
buyPrice?: number | null;
|
||||
buyDate?: string | null;
|
||||
notes?: string | null;
|
||||
tags?: string[] | null;
|
||||
currentPrice?: number | null;
|
||||
totalCost?: number | null;
|
||||
currentValue?: number | null;
|
||||
weightPercent: number;
|
||||
pnl?: number | null;
|
||||
pnlPercent?: number | null;
|
||||
dividendIncome?: number | null;
|
||||
totalReturn?: number | null;
|
||||
totalReturnPercent?: number | null;
|
||||
change?: number | null;
|
||||
changePercent?: number | null;
|
||||
yieldToMaturity?: number | null;
|
||||
duration?: number | null;
|
||||
couponValue?: number | null;
|
||||
couponPercent?: number | null;
|
||||
nextCouponDate?: string | null;
|
||||
matDate?: string | null;
|
||||
accruedInt?: number | null;
|
||||
bid?: number | null;
|
||||
offer?: number | null;
|
||||
couponPeriod?: number | null;
|
||||
bondType?: string | null;
|
||||
offerDate?: string | null;
|
||||
};
|
||||
PortfolioSummaryDto: {
|
||||
totalInvested: number;
|
||||
totalValue: number;
|
||||
totalPnl: number;
|
||||
totalPnlPercent: number | null;
|
||||
totalDividends: number;
|
||||
totalReturn: number;
|
||||
totalReturnPercent: number | null;
|
||||
positionCount: number;
|
||||
weightedYield: number | null;
|
||||
};
|
||||
PortfolioDetailResponseDto: {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
/** @default RUB */
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
positions: components['schemas']['PositionWithPriceDto'][];
|
||||
totalValue: number;
|
||||
analytics: components['schemas']['PortfolioSummaryDto'];
|
||||
};
|
||||
PortfolioDetailEnvelopeDto: {
|
||||
data: components['schemas']['PortfolioDetailResponseDto'];
|
||||
meta: components['schemas']['PortfolioResponseMetaDto'];
|
||||
};
|
||||
UpdatePortfolioDto: {
|
||||
/** @example Мой портфель */
|
||||
name?: string;
|
||||
/** @example Обновлённое описание */
|
||||
description?: string;
|
||||
/**
|
||||
* @default RUB
|
||||
* @enum {string}
|
||||
*/
|
||||
currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN';
|
||||
};
|
||||
AddPositionDto: {
|
||||
/** @example SBER */
|
||||
secid: string;
|
||||
/** @example 10 */
|
||||
quantity: number;
|
||||
/** @example 250.5 */
|
||||
buyPrice?: number;
|
||||
/** @example 2026-06-01 */
|
||||
buyDate?: string;
|
||||
/** @example Покупка на дип */
|
||||
notes?: string;
|
||||
/**
|
||||
* @example [
|
||||
* "DIVIDEND",
|
||||
* "GROWTH"
|
||||
* ]
|
||||
*/
|
||||
tags?: (
|
||||
| 'DIVIDEND'
|
||||
| 'GROWTH'
|
||||
| 'DEFENSIVE'
|
||||
| 'SPECULATIVE'
|
||||
| 'BOND'
|
||||
| 'ETF'
|
||||
| 'GOVERNMENT'
|
||||
| 'CASH'
|
||||
)[];
|
||||
};
|
||||
PositionResponseDto: {
|
||||
id: number;
|
||||
/** @example SBER */
|
||||
secid: string;
|
||||
/** @example 10 */
|
||||
quantity: number;
|
||||
notes?: string | null;
|
||||
tags?: string[] | null;
|
||||
portfolioId: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
PositionEnvelopeDto: {
|
||||
data: components['schemas']['PositionResponseDto'];
|
||||
meta: components['schemas']['PortfolioResponseMetaDto'];
|
||||
};
|
||||
UpdatePositionDto: {
|
||||
/** @example 15 */
|
||||
quantity?: number;
|
||||
/** @example 260 */
|
||||
buyPrice?: number;
|
||||
/** @example 2026-06-15 */
|
||||
buyDate?: string;
|
||||
/** @example Докупка */
|
||||
notes?: string;
|
||||
/**
|
||||
* @example [
|
||||
* "DIVIDEND"
|
||||
* ]
|
||||
*/
|
||||
tags?: (
|
||||
| 'DIVIDEND'
|
||||
| 'GROWTH'
|
||||
| 'DEFENSIVE'
|
||||
| 'SPECULATIVE'
|
||||
| 'BOND'
|
||||
| 'ETF'
|
||||
| 'GOVERNMENT'
|
||||
| 'CASH'
|
||||
)[];
|
||||
};
|
||||
AnalyticsResponseDto: {
|
||||
positions: components['schemas']['PositionWithPriceDto'][];
|
||||
summary: components['schemas']['PortfolioSummaryDto'];
|
||||
};
|
||||
AnalyticsEnvelopeDto: {
|
||||
data: components['schemas']['AnalyticsResponseDto'];
|
||||
meta: components['schemas']['PortfolioResponseMetaDto'];
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
requestBodies: never;
|
||||
@ -220,6 +715,132 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
AuthController_register: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['RegisterDto'];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['AuthTokenResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
AuthController_login: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['LoginDto'];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['AuthTokenResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
AuthController_refresh: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['AuthTokenResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
AuthController_logout: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['AuthLogoutResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
AuthController_getProfile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['AuthProfileResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
AuthController_updateProfile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['UpdateProfileDto'];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['AuthProfileResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
SecuritiesController_search: {
|
||||
parameters: {
|
||||
query: {
|
||||
@ -242,6 +863,49 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
SecuritiesController_screener: {
|
||||
parameters: {
|
||||
query: {
|
||||
type: 'share' | 'bond';
|
||||
priceMin?: number;
|
||||
priceMax?: number;
|
||||
volumeMin?: number;
|
||||
listLevel?: number;
|
||||
changePercentMin?: number;
|
||||
changePercentMax?: number;
|
||||
capitalizationMin?: number;
|
||||
yieldMin?: number;
|
||||
yieldMax?: number;
|
||||
durationMin?: number;
|
||||
durationMax?: number;
|
||||
couponMin?: number;
|
||||
couponMax?: number;
|
||||
couponPercentMin?: number;
|
||||
couponPercentMax?: number;
|
||||
maturityBefore?: string;
|
||||
maturityAfter?: string;
|
||||
bondType?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['ScreenerResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
SharesController_getShare: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@ -427,4 +1091,213 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
PortfolioController_findAll: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['PortfolioListEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
PortfolioController_create: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['CreatePortfolioDto'];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['PortfolioEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
PortfolioController_findOne: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['PortfolioDetailEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
PortfolioController_remove: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': {
|
||||
data: null;
|
||||
meta: components['schemas']['PortfolioResponseMetaDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
PortfolioController_update: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['UpdatePortfolioDto'];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['PortfolioEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
PortfolioController_addPosition: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['AddPositionDto'];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['PositionEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
PortfolioController_removePosition: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number;
|
||||
positionId: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': {
|
||||
data: null;
|
||||
meta: components['schemas']['PortfolioResponseMetaDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
PortfolioController_updatePosition: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number;
|
||||
positionId: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': components['schemas']['UpdatePositionDto'];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['PositionEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
PortfolioController_getAnalytics: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['AnalyticsEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1401
docs/superpowers/plans/2026-06-14-quality-gate-contract-docs.md
Normal file
1401
docs/superpowers/plans/2026-06-14-quality-gate-contract-docs.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||
Loading…
x
Reference in New Issue
Block a user