codex/tech-debt-sdd-tdd #12

Merged
ksv741 merged 10 commits from codex/tech-debt-sdd-tdd into main 2026-06-15 20:20:33 +03:00
15 changed files with 964 additions and 197 deletions
Showing only changes of commit 835686d886 - Show all commits

View File

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

View File

@ -0,0 +1,60 @@
import { ApiProperty } from '@nestjs/swagger';
class AuthResponseMetaDto {
@ApiProperty({ type: String, nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
class AuthUserDto {
@ApiProperty()
id!: number;
@ApiProperty()
email!: string;
@ApiProperty({ type: String, nullable: true })
name!: string | null;
@ApiProperty()
role!: string;
}
class AuthTokenDataDto {
@ApiProperty({ type: AuthUserDto })
user!: AuthUserDto;
@ApiProperty()
accessToken!: string;
}
class LogoutDataDto {
@ApiProperty()
message!: string;
}
export class AuthTokenResponseDto {
@ApiProperty({ type: AuthTokenDataDto })
data!: AuthTokenDataDto;
@ApiProperty({ type: AuthResponseMetaDto })
meta!: AuthResponseMetaDto;
}
export class AuthProfileResponseDto {
@ApiProperty({ type: AuthUserDto })
data!: AuthUserDto;
@ApiProperty({ type: AuthResponseMetaDto })
meta!: AuthResponseMetaDto;
}
export class AuthLogoutResponseDto {
@ApiProperty({ type: LogoutDataDto })
data!: LogoutDataDto;
@ApiProperty({ type: AuthResponseMetaDto })
meta!: AuthResponseMetaDto;
}

View File

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

View File

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

View File

@ -0,0 +1,53 @@
import { ApiProperty } from '@nestjs/swagger';
import { AnalyticsResponseDto } from './analytics-response.dto';
import { PortfolioListResponseDto } from './portfolio-list-response.dto';
import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto';
import { PositionResponseDto } from './position-response.dto';
export class PortfolioResponseMetaDto {
@ApiProperty({ type: String, nullable: true })
cachedAt!: string | null;
@ApiProperty()
fromCache!: boolean;
}
export class PortfolioListEnvelopeDto {
@ApiProperty({ type: [PortfolioListResponseDto] })
data!: PortfolioListResponseDto[];
@ApiProperty({ type: PortfolioResponseMetaDto })
meta!: PortfolioResponseMetaDto;
}
export class PortfolioEnvelopeDto {
@ApiProperty({ type: PortfolioResponseDto })
data!: PortfolioResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto })
meta!: PortfolioResponseMetaDto;
}
export class PortfolioDetailEnvelopeDto {
@ApiProperty({ type: PortfolioDetailResponseDto })
data!: PortfolioDetailResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto })
meta!: PortfolioResponseMetaDto;
}
export class PositionEnvelopeDto {
@ApiProperty({ type: PositionResponseDto })
data!: PositionResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto })
meta!: PortfolioResponseMetaDto;
}
export class AnalyticsEnvelopeDto {
@ApiProperty({ type: AnalyticsResponseDto })
data!: AnalyticsResponseDto;
@ApiProperty({ type: PortfolioResponseMetaDto })
meta!: PortfolioResponseMetaDto;
}

View File

@ -1,45 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PortfolioSummaryDto } from './analytics-response.dto'; import { PortfolioSummaryDto } from './analytics-response.dto';
import { PositionWithPriceDto } from './position-with-price.dto';
class PositionWithPriceDto {
@ApiProperty() id!: number;
@ApiProperty({ example: 'SBER' }) secid!: string;
@ApiPropertyOptional() shortName!: string | null;
@ApiProperty({ example: 'share', enum: ['share', 'bond'] }) type!: string;
@ApiProperty({ example: 10 }) quantity!: number;
@ApiPropertyOptional() buyPrice!: number | null;
@ApiPropertyOptional() buyDate!: string | null;
@ApiPropertyOptional() notes!: string | null;
@ApiPropertyOptional() tags!: string[] | null;
@ApiPropertyOptional() currentPrice!: number | null;
@ApiPropertyOptional() totalCost!: number | null;
@ApiPropertyOptional() currentValue!: number | null;
@ApiProperty() weightPercent!: number;
@ApiPropertyOptional() pnl!: number | null;
@ApiPropertyOptional() pnlPercent!: number | null;
@ApiPropertyOptional() dividendIncome!: number | null;
@ApiPropertyOptional() totalReturn!: number | null;
@ApiPropertyOptional() totalReturnPercent!: number | null;
@ApiPropertyOptional() change!: number | null;
@ApiPropertyOptional() changePercent!: number | null;
@ApiPropertyOptional() yieldToMaturity!: number | null;
@ApiPropertyOptional() duration!: number | null;
@ApiPropertyOptional() couponValue!: number | null;
@ApiPropertyOptional() couponPercent!: number | null;
@ApiPropertyOptional() nextCouponDate!: string | null;
@ApiPropertyOptional() matDate!: string | null;
@ApiPropertyOptional() accruedInt!: number | null;
@ApiPropertyOptional() bid!: number | null;
@ApiPropertyOptional() offer!: number | null;
@ApiPropertyOptional() couponPeriod!: number | null;
@ApiPropertyOptional() bondType!: string | null;
@ApiPropertyOptional() offerDate!: string | null;
}
export class PortfolioResponseDto { export class PortfolioResponseDto {
@ApiProperty() id!: number; @ApiProperty() id!: number;
@ApiProperty() name!: string; @ApiProperty() name!: string;
@ApiPropertyOptional() description!: string | null; @ApiPropertyOptional({ type: String, nullable: true }) description!: string | null;
@ApiProperty({ default: 'RUB' }) currency!: string; @ApiProperty({ default: 'RUB' }) currency!: string;
@ApiProperty() createdAt!: string; @ApiProperty() createdAt!: string;
@ApiProperty() updatedAt!: string; @ApiProperty() updatedAt!: string;

View File

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

View File

@ -0,0 +1,99 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class PositionWithPriceDto {
@ApiProperty()
id!: number;
@ApiProperty({ example: 'SBER' })
secid!: string;
@ApiPropertyOptional({ type: String, nullable: true })
shortName!: string | null;
@ApiProperty({ example: 'share', enum: ['share', 'bond'] })
type!: string;
@ApiProperty({ example: 10 })
quantity!: number;
@ApiPropertyOptional({ type: Number, nullable: true })
buyPrice!: number | null;
@ApiPropertyOptional({ type: String, nullable: true })
buyDate!: string | null;
@ApiPropertyOptional({ type: String, nullable: true })
notes!: string | null;
@ApiPropertyOptional({ type: String, isArray: true, nullable: true })
tags!: string[] | null;
@ApiPropertyOptional({ type: Number, nullable: true })
currentPrice!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
totalCost!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
currentValue!: number | null;
@ApiProperty()
weightPercent!: number;
@ApiPropertyOptional({ type: Number, nullable: true })
pnl!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
pnlPercent!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
dividendIncome!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
totalReturn!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
totalReturnPercent!: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
change?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
changePercent?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
yieldToMaturity?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
duration?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
couponValue?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
couponPercent?: number | null;
@ApiPropertyOptional({ type: String, nullable: true })
nextCouponDate?: string | null;
@ApiPropertyOptional({ type: String, nullable: true })
matDate?: string | null;
@ApiPropertyOptional({ type: Number, nullable: true })
accruedInt?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
bid?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
offer?: number | null;
@ApiPropertyOptional({ type: Number, nullable: true })
couponPeriod?: number | null;
@ApiPropertyOptional({ type: String, nullable: true })
bondType?: string | null;
@ApiPropertyOptional({ type: String, nullable: true })
offerDate?: string | null;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,14 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path'; 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', () => { describe('checked-in OpenAPI artifacts', () => {
const rootDir = resolve(process.cwd(), '../..'); const rootDir = resolve(process.cwd(), '../..');
const frontendTypes = readFileSync(join(rootDir, 'apps/frontend/src/api/types.ts'), 'utf8'); const frontendTypes = readFileSync(join(rootDir, 'apps/frontend/src/api/types.ts'), 'utf8');
const openapiYaml = readFileSync(join(rootDir, 'docs/openapi/openapi.yaml'), 'utf8'); const openapiYaml = readFileSync(join(rootDir, 'docs/openapi/openapi.yaml'), 'utf8');
const openapi = load(openapiYaml) as any;
const requiredPaths = [ const requiredPaths = [
'/api/v1/auth/register', '/api/v1/auth/register',
@ -31,4 +35,62 @@ describe('checked-in OpenAPI artifacts', () => {
expect(openapiYaml).toContain(`${path}:`); 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();
}
});
}); });

View File

@ -395,12 +395,41 @@ export interface components {
/** @example John */ /** @example John */
name?: string; 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: { LoginDto: {
/** @example user@example.com */ /** @example user@example.com */
email: string; email: string;
/** @example securePass123 */ /** @example securePass123 */
password: string; password: string;
}; };
LogoutDataDto: {
message: string;
};
AuthLogoutResponseDto: {
data: components['schemas']['LogoutDataDto'];
meta: components['schemas']['AuthResponseMetaDto'];
};
AuthProfileResponseDto: {
data: components['schemas']['AuthUserDto'];
meta: components['schemas']['AuthResponseMetaDto'];
};
UpdateProfileDto: { UpdateProfileDto: {
/** @example John Doe */ /** @example John Doe */
name?: string; name?: string;
@ -415,31 +444,31 @@ export interface components {
/** @enum {string} */ /** @enum {string} */
type: 'share' | 'bond'; type: 'share' | 'bond';
/** @example 322.35 */ /** @example 322.35 */
price?: Record<string, never>; price?: number | null;
/** @example 1.15 */ /** @example 1.15 */
change?: Record<string, never>; change?: number | null;
/** @example 0.36 */ /** @example 0.36 */
changePercent?: Record<string, never>; changePercent?: number | null;
/** @example 1925163 */ /** @example 1925163 */
volume: number; volume: number;
/** @example 1 */ /** @example 1 */
listLevel: number; listLevel: number;
/** @example 6958336818320 */ /** @example 6958336818320 */
capitalization?: Record<string, never>; capitalization?: number | null;
/** @example 12.71 */ /** @example 12.71 */
yieldToMaturity?: Record<string, never>; yieldToMaturity?: number | null;
/** @example 4.5 */ /** @example 4.5 */
duration?: Record<string, never>; duration?: number | null;
/** @example 40.64 */ /** @example 40.64 */
couponValue?: Record<string, never>; couponValue?: number | null;
/** @example 8.15 */ /** @example 8.15 */
couponPercent?: Record<string, never>; couponPercent?: number | null;
/** @example 29.48 */ /** @example 29.48 */
accruedInt?: Record<string, never>; accruedInt?: number | null;
/** @example 2027-02-03 */ /** @example 2027-02-03 */
matDate?: Record<string, never>; matDate?: string | null;
/** @example ОФЗ-ПД */ /** @example ОФЗ-ПД */
bondType?: Record<string, never>; bondType?: string | null;
}; };
ScreenerResultDto: { ScreenerResultDto: {
items: components['schemas']['ScreenerItemDto'][]; items: components['schemas']['ScreenerItemDto'][];
@ -448,10 +477,22 @@ export interface components {
pageSize: number; pageSize: number;
totalPages: 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: { PortfolioListResponseDto: {
id: number; id: number;
name: string; name: string;
description?: Record<string, never>; description?: string | null;
/** @default RUB */ /** @default RUB */
currency: string; currency: string;
createdAt: string; createdAt: string;
@ -465,6 +506,10 @@ export interface components {
/** @description Number of bond positions */ /** @description Number of bond positions */
bondCount: number; bondCount: number;
}; };
PortfolioListEnvelopeDto: {
data: components['schemas']['PortfolioListResponseDto'][];
meta: components['schemas']['PortfolioResponseMetaDto'];
};
CreatePortfolioDto: { CreatePortfolioDto: {
/** @example Мой портфель */ /** @example Мой портфель */
name: string; name: string;
@ -476,11 +521,24 @@ export interface components {
*/ */
currency: 'RUB' | 'USD' | 'EUR' | 'CNY' | 'KZT' | 'BYN'; 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: { PositionWithPriceDto: {
id: number; id: number;
/** @example SBER */ /** @example SBER */
secid: string; secid: string;
shortName?: Record<string, never>; shortName?: string | null;
/** /**
* @example share * @example share
* @enum {string} * @enum {string}
@ -488,49 +546,49 @@ export interface components {
type: 'share' | 'bond'; type: 'share' | 'bond';
/** @example 10 */ /** @example 10 */
quantity: number; quantity: number;
buyPrice?: Record<string, never>; buyPrice?: number | null;
buyDate?: Record<string, never>; buyDate?: string | null;
notes?: Record<string, never>; notes?: string | null;
tags?: Record<string, never>; tags?: string[] | null;
currentPrice?: Record<string, never>; currentPrice?: number | null;
totalCost?: Record<string, never>; totalCost?: number | null;
currentValue?: Record<string, never>; currentValue?: number | null;
weightPercent: number; weightPercent: number;
pnl?: Record<string, never>; pnl?: number | null;
pnlPercent?: Record<string, never>; pnlPercent?: number | null;
dividendIncome?: Record<string, never>; dividendIncome?: number | null;
totalReturn?: Record<string, never>; totalReturn?: number | null;
totalReturnPercent?: Record<string, never>; totalReturnPercent?: number | null;
change?: Record<string, never>; change?: number | null;
changePercent?: Record<string, never>; changePercent?: number | null;
yieldToMaturity?: Record<string, never>; yieldToMaturity?: number | null;
duration?: Record<string, never>; duration?: number | null;
couponValue?: Record<string, never>; couponValue?: number | null;
couponPercent?: Record<string, never>; couponPercent?: number | null;
nextCouponDate?: Record<string, never>; nextCouponDate?: string | null;
matDate?: Record<string, never>; matDate?: string | null;
accruedInt?: Record<string, never>; accruedInt?: number | null;
bid?: Record<string, never>; bid?: number | null;
offer?: Record<string, never>; offer?: number | null;
couponPeriod?: Record<string, never>; couponPeriod?: number | null;
bondType?: Record<string, never>; bondType?: string | null;
offerDate?: Record<string, never>; offerDate?: string | null;
}; };
PortfolioSummaryDto: { PortfolioSummaryDto: {
totalInvested: number; totalInvested: number;
totalValue: number; totalValue: number;
totalPnl: number; totalPnl: number;
totalPnlPercent: Record<string, never>; totalPnlPercent: number | null;
totalDividends: number; totalDividends: number;
totalReturn: number; totalReturn: number;
totalReturnPercent: Record<string, never>; totalReturnPercent: number | null;
positionCount: number; positionCount: number;
weightedYield: Record<string, never>; weightedYield: number | null;
}; };
PortfolioDetailResponseDto: { PortfolioDetailResponseDto: {
id: number; id: number;
name: string; name: string;
description?: Record<string, never>; description?: string | null;
/** @default RUB */ /** @default RUB */
currency: string; currency: string;
createdAt: string; createdAt: string;
@ -539,6 +597,10 @@ export interface components {
totalValue: number; totalValue: number;
analytics: components['schemas']['PortfolioSummaryDto']; analytics: components['schemas']['PortfolioSummaryDto'];
}; };
PortfolioDetailEnvelopeDto: {
data: components['schemas']['PortfolioDetailResponseDto'];
meta: components['schemas']['PortfolioResponseMetaDto'];
};
UpdatePortfolioDto: { UpdatePortfolioDto: {
/** @example Мой портфель */ /** @example Мой портфель */
name?: string; name?: string;
@ -566,9 +628,8 @@ export interface components {
* "DIVIDEND", * "DIVIDEND",
* "GROWTH" * "GROWTH"
* ] * ]
* @enum {string}
*/ */
tags?: tags?: (
| 'DIVIDEND' | 'DIVIDEND'
| 'GROWTH' | 'GROWTH'
| 'DEFENSIVE' | 'DEFENSIVE'
@ -576,7 +637,24 @@ export interface components {
| 'BOND' | 'BOND'
| 'ETF' | 'ETF'
| 'GOVERNMENT' | 'GOVERNMENT'
| 'CASH'; | '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: { UpdatePositionDto: {
/** @example 15 */ /** @example 15 */
@ -591,9 +669,8 @@ export interface components {
* @example [ * @example [
* "DIVIDEND" * "DIVIDEND"
* ] * ]
* @enum {string}
*/ */
tags?: tags?: (
| 'DIVIDEND' | 'DIVIDEND'
| 'GROWTH' | 'GROWTH'
| 'DEFENSIVE' | 'DEFENSIVE'
@ -601,7 +678,16 @@ export interface components {
| 'BOND' | 'BOND'
| 'ETF' | 'ETF'
| 'GOVERNMENT' | 'GOVERNMENT'
| 'CASH'; | 'CASH'
)[];
};
AnalyticsResponseDto: {
positions: components['schemas']['PositionWithPriceDto'][];
summary: components['schemas']['PortfolioSummaryDto'];
};
AnalyticsEnvelopeDto: {
data: components['schemas']['AnalyticsResponseDto'];
meta: components['schemas']['PortfolioResponseMetaDto'];
}; };
}; };
responses: never; responses: never;
@ -646,7 +732,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content?: never; content: {
'application/json': components['schemas']['AuthTokenResponseDto'];
};
}; };
}; };
}; };
@ -667,7 +755,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content?: never; content: {
'application/json': components['schemas']['AuthTokenResponseDto'];
};
}; };
}; };
}; };
@ -684,7 +774,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content?: never; content: {
'application/json': components['schemas']['AuthTokenResponseDto'];
};
}; };
}; };
}; };
@ -701,7 +793,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content?: never; content: {
'application/json': components['schemas']['AuthLogoutResponseDto'];
};
}; };
}; };
}; };
@ -718,7 +812,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content?: never; content: {
'application/json': components['schemas']['AuthProfileResponseDto'];
};
}; };
}; };
}; };
@ -739,7 +835,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content?: never; content: {
'application/json': components['schemas']['AuthProfileResponseDto'];
};
}; };
}; };
}; };
@ -803,7 +901,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
'application/json': components['schemas']['ScreenerResultDto']; 'application/json': components['schemas']['ScreenerResponseDto'];
}; };
}; };
}; };
@ -1007,7 +1105,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
'application/json': components['schemas']['PortfolioListResponseDto'][]; 'application/json': components['schemas']['PortfolioListEnvelopeDto'];
}; };
}; };
}; };
@ -1029,7 +1127,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content?: never; content: {
'application/json': components['schemas']['PortfolioEnvelopeDto'];
};
}; };
}; };
}; };
@ -1049,7 +1149,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
'application/json': components['schemas']['PortfolioDetailResponseDto']; 'application/json': components['schemas']['PortfolioDetailEnvelopeDto'];
}; };
}; };
}; };
@ -1069,7 +1169,12 @@ export interface operations {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content?: never; content: {
'application/json': {
data: unknown;
meta: components['schemas']['PortfolioResponseMetaDto'];
};
};
}; };
}; };
}; };
@ -1092,7 +1197,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content?: never; content: {
'application/json': components['schemas']['PortfolioEnvelopeDto'];
};
}; };
}; };
}; };
@ -1115,7 +1222,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content?: never; content: {
'application/json': components['schemas']['PositionEnvelopeDto'];
};
}; };
}; };
}; };
@ -1135,7 +1244,12 @@ export interface operations {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content?: never; content: {
'application/json': {
data: unknown;
meta: components['schemas']['PortfolioResponseMetaDto'];
};
};
}; };
}; };
}; };
@ -1159,7 +1273,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content?: never; content: {
'application/json': components['schemas']['PositionEnvelopeDto'];
};
}; };
}; };
}; };
@ -1178,7 +1294,9 @@ export interface operations {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content?: never; content: {
'application/json': components['schemas']['AnalyticsEnvelopeDto'];
};
}; };
}; };
}; };

View File

@ -24,6 +24,10 @@ paths:
responses: responses:
'201': '201':
description: '' description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/AuthTokenResponseDto'
tags: tags:
- Auth - Auth
/api/v1/auth/login: /api/v1/auth/login:
@ -40,6 +44,10 @@ paths:
responses: responses:
'201': '201':
description: '' description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/AuthTokenResponseDto'
tags: tags:
- Auth - Auth
/api/v1/auth/refresh: /api/v1/auth/refresh:
@ -50,6 +58,10 @@ paths:
responses: responses:
'200': '200':
description: '' description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/AuthTokenResponseDto'
tags: tags:
- Auth - Auth
/api/v1/auth/logout: /api/v1/auth/logout:
@ -60,6 +72,10 @@ paths:
responses: responses:
'200': '200':
description: '' description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/AuthLogoutResponseDto'
tags: tags:
- Auth - Auth
security: security:
@ -72,6 +88,10 @@ paths:
responses: responses:
'200': '200':
description: '' description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/AuthProfileResponseDto'
tags: tags:
- Auth - Auth
security: security:
@ -89,6 +109,10 @@ paths:
responses: responses:
'200': '200':
description: '' description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/AuthProfileResponseDto'
tags: tags:
- Auth - Auth
security: security:
@ -258,7 +282,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ScreenerResultDto' $ref: '#/components/schemas/ScreenerResponseDto'
tags: tags:
- Securities - Securities
/api/v1/securities/shares/{secid}: /api/v1/securities/shares/{secid}:
@ -471,9 +495,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
type: array $ref: '#/components/schemas/PortfolioListEnvelopeDto'
items:
$ref: '#/components/schemas/PortfolioListResponseDto'
tags: tags:
- Portfolios - Portfolios
security: security:
@ -491,6 +513,10 @@ paths:
responses: responses:
'201': '201':
description: '' description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/PortfolioEnvelopeDto'
tags: tags:
- Portfolios - Portfolios
security: security:
@ -511,7 +537,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PortfolioDetailResponseDto' $ref: '#/components/schemas/PortfolioDetailEnvelopeDto'
tags: tags:
- Portfolios - Portfolios
security: security:
@ -534,6 +560,10 @@ paths:
responses: responses:
'200': '200':
description: '' description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/PortfolioEnvelopeDto'
tags: tags:
- Portfolios - Portfolios
security: security:
@ -550,6 +580,18 @@ paths:
responses: responses:
'200': '200':
description: '' description: ''
content:
application/json:
schema:
type: object
properties:
data:
nullable: true
meta:
$ref: '#/components/schemas/PortfolioResponseMetaDto'
required:
- data
- meta
tags: tags:
- Portfolios - Portfolios
security: security:
@ -573,6 +615,10 @@ paths:
responses: responses:
'201': '201':
description: '' description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/PositionEnvelopeDto'
tags: tags:
- Portfolios - Portfolios
security: security:
@ -601,6 +647,10 @@ paths:
responses: responses:
'200': '200':
description: '' description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/PositionEnvelopeDto'
tags: tags:
- Portfolios - Portfolios
security: security:
@ -622,6 +672,18 @@ paths:
responses: responses:
'200': '200':
description: '' description: ''
content:
application/json:
schema:
type: object
properties:
data:
nullable: true
meta:
$ref: '#/components/schemas/PortfolioResponseMetaDto'
required:
- data
- meta
tags: tags:
- Portfolios - Portfolios
security: security:
@ -639,6 +701,10 @@ paths:
responses: responses:
'200': '200':
description: '' description: ''
content:
application/json:
schema:
$ref: '#/components/schemas/AnalyticsEnvelopeDto'
tags: tags:
- Portfolios - Portfolios
security: security:
@ -672,6 +738,54 @@ components:
required: required:
- email - email
- password - password
AuthUserDto:
type: object
properties:
id:
type: number
email:
type: string
name:
type: string
nullable: true
role:
type: string
required:
- id
- email
- name
- role
AuthTokenDataDto:
type: object
properties:
user:
$ref: '#/components/schemas/AuthUserDto'
accessToken:
type: string
required:
- user
- accessToken
AuthResponseMetaDto:
type: object
properties:
cachedAt:
type: string
nullable: true
fromCache:
type: boolean
required:
- cachedAt
- fromCache
AuthTokenResponseDto:
type: object
properties:
data:
$ref: '#/components/schemas/AuthTokenDataDto'
meta:
$ref: '#/components/schemas/AuthResponseMetaDto'
required:
- data
- meta
LoginDto: LoginDto:
type: object type: object
properties: properties:
@ -684,6 +798,33 @@ components:
required: required:
- email - email
- password - password
LogoutDataDto:
type: object
properties:
message:
type: string
required:
- message
AuthLogoutResponseDto:
type: object
properties:
data:
$ref: '#/components/schemas/LogoutDataDto'
meta:
$ref: '#/components/schemas/AuthResponseMetaDto'
required:
- data
- meta
AuthProfileResponseDto:
type: object
properties:
data:
$ref: '#/components/schemas/AuthUserDto'
meta:
$ref: '#/components/schemas/AuthResponseMetaDto'
required:
- data
- meta
UpdateProfileDto: UpdateProfileDto:
type: object type: object
properties: properties:
@ -708,13 +849,16 @@ components:
- share - share
- bond - bond
price: price:
type: object type: number
nullable: true
example: 322.35 example: 322.35
change: change:
type: object type: number
nullable: true
example: 1.15 example: 1.15
changePercent: changePercent:
type: object type: number
nullable: true
example: 0.36 example: 0.36
volume: volume:
type: number type: number
@ -723,28 +867,36 @@ components:
type: number type: number
example: 1 example: 1
capitalization: capitalization:
type: object type: number
nullable: true
example: 6958336818320 example: 6958336818320
yieldToMaturity: yieldToMaturity:
type: object type: number
nullable: true
example: 12.71 example: 12.71
duration: duration:
type: object type: number
nullable: true
example: 4.5 example: 4.5
couponValue: couponValue:
type: object type: number
nullable: true
example: 40.64 example: 40.64
couponPercent: couponPercent:
type: object type: number
nullable: true
example: 8.15 example: 8.15
accruedInt: accruedInt:
type: object type: number
nullable: true
example: 29.48 example: 29.48
matDate: matDate:
type: object type: string
nullable: true
example: '2027-02-03' example: '2027-02-03'
bondType: bondType:
type: object type: string
nullable: true
example: ОФЗ-ПД example: ОФЗ-ПД
required: required:
- secid - secid
@ -774,6 +926,38 @@ components:
- page - page
- pageSize - pageSize
- totalPages - totalPages
ScreenerResponseMetaDto:
type: object
properties:
cachedAt:
type: string
nullable: true
fromCache:
type: boolean
required:
- cachedAt
- fromCache
ScreenerResponseDto:
type: object
properties:
data:
$ref: '#/components/schemas/ScreenerResultDto'
meta:
$ref: '#/components/schemas/ScreenerResponseMetaDto'
required:
- data
- meta
PortfolioResponseMetaDto:
type: object
properties:
cachedAt:
type: string
nullable: true
fromCache:
type: boolean
required:
- cachedAt
- fromCache
PortfolioListResponseDto: PortfolioListResponseDto:
type: object type: object
properties: properties:
@ -782,7 +966,8 @@ components:
name: name:
type: string type: string
description: description:
type: object type: string
nullable: true
currency: currency:
type: string type: string
default: RUB default: RUB
@ -812,6 +997,18 @@ components:
- positionCount - positionCount
- shareCount - shareCount
- bondCount - bondCount
PortfolioListEnvelopeDto:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/PortfolioListResponseDto'
meta:
$ref: '#/components/schemas/PortfolioResponseMetaDto'
required:
- data
- meta
CreatePortfolioDto: CreatePortfolioDto:
type: object type: object
properties: properties:
@ -833,6 +1030,39 @@ components:
- BYN - BYN
required: required:
- name - name
PortfolioResponseDto:
type: object
properties:
id:
type: number
name:
type: string
description:
type: string
nullable: true
currency:
type: string
default: RUB
createdAt:
type: string
updatedAt:
type: string
required:
- id
- name
- currency
- createdAt
- updatedAt
PortfolioEnvelopeDto:
type: object
properties:
data:
$ref: '#/components/schemas/PortfolioResponseDto'
meta:
$ref: '#/components/schemas/PortfolioResponseMetaDto'
required:
- data
- meta
PositionWithPriceDto: PositionWithPriceDto:
type: object type: object
properties: properties:
@ -842,7 +1072,8 @@ components:
type: string type: string
example: SBER example: SBER
shortName: shortName:
type: object type: string
nullable: true
type: type:
type: string type: string
example: share example: share
@ -853,59 +1084,87 @@ components:
type: number type: number
example: 10 example: 10
buyPrice: buyPrice:
type: object type: number
nullable: true
buyDate: buyDate:
type: object type: string
nullable: true
notes: notes:
type: object type: string
nullable: true
tags: tags:
type: object nullable: true
type: array
items:
type: string
currentPrice: currentPrice:
type: object type: number
nullable: true
totalCost: totalCost:
type: object type: number
nullable: true
currentValue: currentValue:
type: object type: number
nullable: true
weightPercent: weightPercent:
type: number type: number
pnl: pnl:
type: object type: number
nullable: true
pnlPercent: pnlPercent:
type: object type: number
nullable: true
dividendIncome: dividendIncome:
type: object type: number
nullable: true
totalReturn: totalReturn:
type: object type: number
nullable: true
totalReturnPercent: totalReturnPercent:
type: object type: number
nullable: true
change: change:
type: object type: number
nullable: true
changePercent: changePercent:
type: object type: number
nullable: true
yieldToMaturity: yieldToMaturity:
type: object type: number
nullable: true
duration: duration:
type: object type: number
nullable: true
couponValue: couponValue:
type: object type: number
nullable: true
couponPercent: couponPercent:
type: object type: number
nullable: true
nextCouponDate: nextCouponDate:
type: object type: string
nullable: true
matDate: matDate:
type: object type: string
nullable: true
accruedInt: accruedInt:
type: object type: number
nullable: true
bid: bid:
type: object type: number
nullable: true
offer: offer:
type: object type: number
nullable: true
couponPeriod: couponPeriod:
type: object type: number
nullable: true
bondType: bondType:
type: object type: string
nullable: true
offerDate: offerDate:
type: object type: string
nullable: true
required: required:
- id - id
- secid - secid
@ -922,17 +1181,20 @@ components:
totalPnl: totalPnl:
type: number type: number
totalPnlPercent: totalPnlPercent:
type: object type: number
nullable: true
totalDividends: totalDividends:
type: number type: number
totalReturn: totalReturn:
type: number type: number
totalReturnPercent: totalReturnPercent:
type: object type: number
nullable: true
positionCount: positionCount:
type: number type: number
weightedYield: weightedYield:
type: object type: number
nullable: true
required: required:
- totalInvested - totalInvested
- totalValue - totalValue
@ -951,7 +1213,8 @@ components:
name: name:
type: string type: string
description: description:
type: object type: string
nullable: true
currency: currency:
type: string type: string
default: RUB default: RUB
@ -976,6 +1239,16 @@ components:
- positions - positions
- totalValue - totalValue
- analytics - analytics
PortfolioDetailEnvelopeDto:
type: object
properties:
data:
$ref: '#/components/schemas/PortfolioDetailResponseDto'
meta:
$ref: '#/components/schemas/PortfolioResponseMetaDto'
required:
- data
- meta
UpdatePortfolioDto: UpdatePortfolioDto:
type: object type: object
properties: properties:
@ -1014,22 +1287,66 @@ components:
type: string type: string
example: Покупка на дип example: Покупка на дип
tags: tags:
type: string type: array
example: example:
- DIVIDEND - DIVIDEND
- GROWTH - GROWTH
enum: items:
- DIVIDEND type: string
- GROWTH enum:
- DEFENSIVE - DIVIDEND
- SPECULATIVE - GROWTH
- BOND - DEFENSIVE
- ETF - SPECULATIVE
- GOVERNMENT - BOND
- CASH - ETF
- GOVERNMENT
- CASH
required: required:
- secid - secid
- quantity - quantity
PositionResponseDto:
type: object
properties:
id:
type: number
secid:
type: string
example: SBER
quantity:
type: number
example: 10
notes:
type: string
nullable: true
tags:
nullable: true
type: array
items:
type: string
portfolioId:
type: number
createdAt:
type: string
updatedAt:
type: string
required:
- id
- secid
- quantity
- portfolioId
- createdAt
- updatedAt
PositionEnvelopeDto:
type: object
properties:
data:
$ref: '#/components/schemas/PositionResponseDto'
meta:
$ref: '#/components/schemas/PortfolioResponseMetaDto'
required:
- data
- meta
UpdatePositionDto: UpdatePositionDto:
type: object type: object
properties: properties:
@ -1046,15 +1363,39 @@ components:
type: string type: string
example: Докупка example: Докупка
tags: tags:
type: string type: array
example: example:
- DIVIDEND - DIVIDEND
enum: items:
- DIVIDEND type: string
- GROWTH enum:
- DEFENSIVE - DIVIDEND
- SPECULATIVE - GROWTH
- BOND - DEFENSIVE
- ETF - SPECULATIVE
- GOVERNMENT - BOND
- CASH - ETF
- GOVERNMENT
- CASH
AnalyticsResponseDto:
type: object
properties:
positions:
type: array
items:
$ref: '#/components/schemas/PositionWithPriceDto'
summary:
$ref: '#/components/schemas/PortfolioSummaryDto'
required:
- positions
- summary
AnalyticsEnvelopeDto:
type: object
properties:
data:
$ref: '#/components/schemas/AnalyticsResponseDto'
meta:
$ref: '#/components/schemas/PortfolioResponseMetaDto'
required:
- data
- meta