codex/tech-debt-sdd-tdd #12
@ -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;
|
||||
}
|
||||
@ -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: {
|
||||
nullable: true,
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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,10 +1,14 @@
|
||||
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',
|
||||
@ -31,4 +35,62 @@ describe('checked-in OpenAPI artifacts', () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -395,12 +395,41 @@ export interface components {
|
||||
/** @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;
|
||||
@ -415,31 +444,31 @@ export interface components {
|
||||
/** @enum {string} */
|
||||
type: 'share' | 'bond';
|
||||
/** @example 322.35 */
|
||||
price?: Record<string, never>;
|
||||
price?: number | null;
|
||||
/** @example 1.15 */
|
||||
change?: Record<string, never>;
|
||||
change?: number | null;
|
||||
/** @example 0.36 */
|
||||
changePercent?: Record<string, never>;
|
||||
changePercent?: number | null;
|
||||
/** @example 1925163 */
|
||||
volume: number;
|
||||
/** @example 1 */
|
||||
listLevel: number;
|
||||
/** @example 6958336818320 */
|
||||
capitalization?: Record<string, never>;
|
||||
capitalization?: number | null;
|
||||
/** @example 12.71 */
|
||||
yieldToMaturity?: Record<string, never>;
|
||||
yieldToMaturity?: number | null;
|
||||
/** @example 4.5 */
|
||||
duration?: Record<string, never>;
|
||||
duration?: number | null;
|
||||
/** @example 40.64 */
|
||||
couponValue?: Record<string, never>;
|
||||
couponValue?: number | null;
|
||||
/** @example 8.15 */
|
||||
couponPercent?: Record<string, never>;
|
||||
couponPercent?: number | null;
|
||||
/** @example 29.48 */
|
||||
accruedInt?: Record<string, never>;
|
||||
accruedInt?: number | null;
|
||||
/** @example 2027-02-03 */
|
||||
matDate?: Record<string, never>;
|
||||
matDate?: string | null;
|
||||
/** @example ОФЗ-ПД */
|
||||
bondType?: Record<string, never>;
|
||||
bondType?: string | null;
|
||||
};
|
||||
ScreenerResultDto: {
|
||||
items: components['schemas']['ScreenerItemDto'][];
|
||||
@ -448,10 +477,22 @@ export interface components {
|
||||
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?: Record<string, never>;
|
||||
description?: string | null;
|
||||
/** @default RUB */
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
@ -465,6 +506,10 @@ export interface components {
|
||||
/** @description Number of bond positions */
|
||||
bondCount: number;
|
||||
};
|
||||
PortfolioListEnvelopeDto: {
|
||||
data: components['schemas']['PortfolioListResponseDto'][];
|
||||
meta: components['schemas']['PortfolioResponseMetaDto'];
|
||||
};
|
||||
CreatePortfolioDto: {
|
||||
/** @example Мой портфель */
|
||||
name: string;
|
||||
@ -476,11 +521,24 @@ export interface components {
|
||||
*/
|
||||
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?: Record<string, never>;
|
||||
shortName?: string | null;
|
||||
/**
|
||||
* @example share
|
||||
* @enum {string}
|
||||
@ -488,49 +546,49 @@ export interface components {
|
||||
type: 'share' | 'bond';
|
||||
/** @example 10 */
|
||||
quantity: number;
|
||||
buyPrice?: Record<string, never>;
|
||||
buyDate?: Record<string, never>;
|
||||
notes?: Record<string, never>;
|
||||
tags?: Record<string, never>;
|
||||
currentPrice?: Record<string, never>;
|
||||
totalCost?: Record<string, never>;
|
||||
currentValue?: Record<string, never>;
|
||||
buyPrice?: number | null;
|
||||
buyDate?: string | null;
|
||||
notes?: string | null;
|
||||
tags?: string[] | null;
|
||||
currentPrice?: number | null;
|
||||
totalCost?: number | null;
|
||||
currentValue?: number | null;
|
||||
weightPercent: number;
|
||||
pnl?: Record<string, never>;
|
||||
pnlPercent?: Record<string, never>;
|
||||
dividendIncome?: Record<string, never>;
|
||||
totalReturn?: Record<string, never>;
|
||||
totalReturnPercent?: Record<string, never>;
|
||||
change?: Record<string, never>;
|
||||
changePercent?: Record<string, never>;
|
||||
yieldToMaturity?: Record<string, never>;
|
||||
duration?: Record<string, never>;
|
||||
couponValue?: Record<string, never>;
|
||||
couponPercent?: Record<string, never>;
|
||||
nextCouponDate?: Record<string, never>;
|
||||
matDate?: Record<string, never>;
|
||||
accruedInt?: Record<string, never>;
|
||||
bid?: Record<string, never>;
|
||||
offer?: Record<string, never>;
|
||||
couponPeriod?: Record<string, never>;
|
||||
bondType?: Record<string, never>;
|
||||
offerDate?: Record<string, never>;
|
||||
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: Record<string, never>;
|
||||
totalPnlPercent: number | null;
|
||||
totalDividends: number;
|
||||
totalReturn: number;
|
||||
totalReturnPercent: Record<string, never>;
|
||||
totalReturnPercent: number | null;
|
||||
positionCount: number;
|
||||
weightedYield: Record<string, never>;
|
||||
weightedYield: number | null;
|
||||
};
|
||||
PortfolioDetailResponseDto: {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: Record<string, never>;
|
||||
description?: string | null;
|
||||
/** @default RUB */
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
@ -539,6 +597,10 @@ export interface components {
|
||||
totalValue: number;
|
||||
analytics: components['schemas']['PortfolioSummaryDto'];
|
||||
};
|
||||
PortfolioDetailEnvelopeDto: {
|
||||
data: components['schemas']['PortfolioDetailResponseDto'];
|
||||
meta: components['schemas']['PortfolioResponseMetaDto'];
|
||||
};
|
||||
UpdatePortfolioDto: {
|
||||
/** @example Мой портфель */
|
||||
name?: string;
|
||||
@ -566,9 +628,8 @@ export interface components {
|
||||
* "DIVIDEND",
|
||||
* "GROWTH"
|
||||
* ]
|
||||
* @enum {string}
|
||||
*/
|
||||
tags?:
|
||||
tags?: (
|
||||
| 'DIVIDEND'
|
||||
| 'GROWTH'
|
||||
| 'DEFENSIVE'
|
||||
@ -576,7 +637,24 @@ export interface components {
|
||||
| 'BOND'
|
||||
| 'ETF'
|
||||
| '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: {
|
||||
/** @example 15 */
|
||||
@ -591,9 +669,8 @@ export interface components {
|
||||
* @example [
|
||||
* "DIVIDEND"
|
||||
* ]
|
||||
* @enum {string}
|
||||
*/
|
||||
tags?:
|
||||
tags?: (
|
||||
| 'DIVIDEND'
|
||||
| 'GROWTH'
|
||||
| 'DEFENSIVE'
|
||||
@ -601,7 +678,16 @@ export interface components {
|
||||
| 'BOND'
|
||||
| 'ETF'
|
||||
| 'GOVERNMENT'
|
||||
| 'CASH';
|
||||
| 'CASH'
|
||||
)[];
|
||||
};
|
||||
AnalyticsResponseDto: {
|
||||
positions: components['schemas']['PositionWithPriceDto'][];
|
||||
summary: components['schemas']['PortfolioSummaryDto'];
|
||||
};
|
||||
AnalyticsEnvelopeDto: {
|
||||
data: components['schemas']['AnalyticsResponseDto'];
|
||||
meta: components['schemas']['PortfolioResponseMetaDto'];
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
@ -646,7 +732,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
content: {
|
||||
'application/json': components['schemas']['AuthTokenResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -667,7 +755,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
content: {
|
||||
'application/json': components['schemas']['AuthTokenResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -684,7 +774,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
content: {
|
||||
'application/json': components['schemas']['AuthTokenResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -701,7 +793,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
content: {
|
||||
'application/json': components['schemas']['AuthLogoutResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -718,7 +812,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
content: {
|
||||
'application/json': components['schemas']['AuthProfileResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -739,7 +835,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
content: {
|
||||
'application/json': components['schemas']['AuthProfileResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -803,7 +901,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['ScreenerResultDto'];
|
||||
'application/json': components['schemas']['ScreenerResponseDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -1007,7 +1105,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['PortfolioListResponseDto'][];
|
||||
'application/json': components['schemas']['PortfolioListEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -1029,7 +1127,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
content: {
|
||||
'application/json': components['schemas']['PortfolioEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -1049,7 +1149,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
'application/json': components['schemas']['PortfolioDetailResponseDto'];
|
||||
'application/json': components['schemas']['PortfolioDetailEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -1069,7 +1169,12 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
content: {
|
||||
'application/json': {
|
||||
data: unknown;
|
||||
meta: components['schemas']['PortfolioResponseMetaDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -1092,7 +1197,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
content: {
|
||||
'application/json': components['schemas']['PortfolioEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -1115,7 +1222,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
content: {
|
||||
'application/json': components['schemas']['PositionEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -1135,7 +1244,12 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
content: {
|
||||
'application/json': {
|
||||
data: unknown;
|
||||
meta: components['schemas']['PortfolioResponseMetaDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -1159,7 +1273,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
content: {
|
||||
'application/json': components['schemas']['PositionEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
@ -1178,7 +1294,9 @@ export interface operations {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
content: {
|
||||
'application/json': components['schemas']['AnalyticsEnvelopeDto'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@ -24,6 +24,10 @@ paths:
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthTokenResponseDto'
|
||||
tags:
|
||||
- Auth
|
||||
/api/v1/auth/login:
|
||||
@ -40,6 +44,10 @@ paths:
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthTokenResponseDto'
|
||||
tags:
|
||||
- Auth
|
||||
/api/v1/auth/refresh:
|
||||
@ -50,6 +58,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthTokenResponseDto'
|
||||
tags:
|
||||
- Auth
|
||||
/api/v1/auth/logout:
|
||||
@ -60,6 +72,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthLogoutResponseDto'
|
||||
tags:
|
||||
- Auth
|
||||
security:
|
||||
@ -72,6 +88,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthProfileResponseDto'
|
||||
tags:
|
||||
- Auth
|
||||
security:
|
||||
@ -89,6 +109,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthProfileResponseDto'
|
||||
tags:
|
||||
- Auth
|
||||
security:
|
||||
@ -258,7 +282,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ScreenerResultDto'
|
||||
$ref: '#/components/schemas/ScreenerResponseDto'
|
||||
tags:
|
||||
- Securities
|
||||
/api/v1/securities/shares/{secid}:
|
||||
@ -471,9 +495,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/PortfolioListResponseDto'
|
||||
$ref: '#/components/schemas/PortfolioListEnvelopeDto'
|
||||
tags:
|
||||
- Portfolios
|
||||
security:
|
||||
@ -491,6 +513,10 @@ paths:
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PortfolioEnvelopeDto'
|
||||
tags:
|
||||
- Portfolios
|
||||
security:
|
||||
@ -511,7 +537,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PortfolioDetailResponseDto'
|
||||
$ref: '#/components/schemas/PortfolioDetailEnvelopeDto'
|
||||
tags:
|
||||
- Portfolios
|
||||
security:
|
||||
@ -534,6 +560,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PortfolioEnvelopeDto'
|
||||
tags:
|
||||
- Portfolios
|
||||
security:
|
||||
@ -550,6 +580,18 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
nullable: true
|
||||
meta:
|
||||
$ref: '#/components/schemas/PortfolioResponseMetaDto'
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
tags:
|
||||
- Portfolios
|
||||
security:
|
||||
@ -573,6 +615,10 @@ paths:
|
||||
responses:
|
||||
'201':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PositionEnvelopeDto'
|
||||
tags:
|
||||
- Portfolios
|
||||
security:
|
||||
@ -601,6 +647,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PositionEnvelopeDto'
|
||||
tags:
|
||||
- Portfolios
|
||||
security:
|
||||
@ -622,6 +672,18 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
nullable: true
|
||||
meta:
|
||||
$ref: '#/components/schemas/PortfolioResponseMetaDto'
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
tags:
|
||||
- Portfolios
|
||||
security:
|
||||
@ -639,6 +701,10 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: ''
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AnalyticsEnvelopeDto'
|
||||
tags:
|
||||
- Portfolios
|
||||
security:
|
||||
@ -672,6 +738,54 @@ components:
|
||||
required:
|
||||
- email
|
||||
- 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:
|
||||
type: object
|
||||
properties:
|
||||
@ -684,6 +798,33 @@ components:
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
LogoutDataDto:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- message
|
||||
AuthLogoutResponseDto:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/LogoutDataDto'
|
||||
meta:
|
||||
$ref: '#/components/schemas/AuthResponseMetaDto'
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
AuthProfileResponseDto:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthUserDto'
|
||||
meta:
|
||||
$ref: '#/components/schemas/AuthResponseMetaDto'
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
UpdateProfileDto:
|
||||
type: object
|
||||
properties:
|
||||
@ -708,13 +849,16 @@ components:
|
||||
- share
|
||||
- bond
|
||||
price:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
example: 322.35
|
||||
change:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
example: 1.15
|
||||
changePercent:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
example: 0.36
|
||||
volume:
|
||||
type: number
|
||||
@ -723,28 +867,36 @@ components:
|
||||
type: number
|
||||
example: 1
|
||||
capitalization:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
example: 6958336818320
|
||||
yieldToMaturity:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
example: 12.71
|
||||
duration:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
example: 4.5
|
||||
couponValue:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
example: 40.64
|
||||
couponPercent:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
example: 8.15
|
||||
accruedInt:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
example: 29.48
|
||||
matDate:
|
||||
type: object
|
||||
type: string
|
||||
nullable: true
|
||||
example: '2027-02-03'
|
||||
bondType:
|
||||
type: object
|
||||
type: string
|
||||
nullable: true
|
||||
example: ОФЗ-ПД
|
||||
required:
|
||||
- secid
|
||||
@ -774,6 +926,38 @@ components:
|
||||
- page
|
||||
- pageSize
|
||||
- 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:
|
||||
type: object
|
||||
properties:
|
||||
@ -782,7 +966,8 @@ components:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: object
|
||||
type: string
|
||||
nullable: true
|
||||
currency:
|
||||
type: string
|
||||
default: RUB
|
||||
@ -812,6 +997,18 @@ components:
|
||||
- positionCount
|
||||
- shareCount
|
||||
- bondCount
|
||||
PortfolioListEnvelopeDto:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/PortfolioListResponseDto'
|
||||
meta:
|
||||
$ref: '#/components/schemas/PortfolioResponseMetaDto'
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
CreatePortfolioDto:
|
||||
type: object
|
||||
properties:
|
||||
@ -833,6 +1030,39 @@ components:
|
||||
- BYN
|
||||
required:
|
||||
- name
|
||||
PortfolioResponseDto:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: number
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
nullable: true
|
||||
currency:
|
||||
type: string
|
||||
default: RUB
|
||||
createdAt:
|
||||
type: string
|
||||
updatedAt:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- currency
|
||||
- createdAt
|
||||
- updatedAt
|
||||
PortfolioEnvelopeDto:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/PortfolioResponseDto'
|
||||
meta:
|
||||
$ref: '#/components/schemas/PortfolioResponseMetaDto'
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
PositionWithPriceDto:
|
||||
type: object
|
||||
properties:
|
||||
@ -842,7 +1072,8 @@ components:
|
||||
type: string
|
||||
example: SBER
|
||||
shortName:
|
||||
type: object
|
||||
type: string
|
||||
nullable: true
|
||||
type:
|
||||
type: string
|
||||
example: share
|
||||
@ -853,59 +1084,87 @@ components:
|
||||
type: number
|
||||
example: 10
|
||||
buyPrice:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
buyDate:
|
||||
type: object
|
||||
type: string
|
||||
nullable: true
|
||||
notes:
|
||||
type: object
|
||||
type: string
|
||||
nullable: true
|
||||
tags:
|
||||
type: object
|
||||
nullable: true
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
currentPrice:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
totalCost:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
currentValue:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
weightPercent:
|
||||
type: number
|
||||
pnl:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
pnlPercent:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
dividendIncome:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
totalReturn:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
totalReturnPercent:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
change:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
changePercent:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
yieldToMaturity:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
duration:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
couponValue:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
couponPercent:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
nextCouponDate:
|
||||
type: object
|
||||
type: string
|
||||
nullable: true
|
||||
matDate:
|
||||
type: object
|
||||
type: string
|
||||
nullable: true
|
||||
accruedInt:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
bid:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
offer:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
couponPeriod:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
bondType:
|
||||
type: object
|
||||
type: string
|
||||
nullable: true
|
||||
offerDate:
|
||||
type: object
|
||||
type: string
|
||||
nullable: true
|
||||
required:
|
||||
- id
|
||||
- secid
|
||||
@ -922,17 +1181,20 @@ components:
|
||||
totalPnl:
|
||||
type: number
|
||||
totalPnlPercent:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
totalDividends:
|
||||
type: number
|
||||
totalReturn:
|
||||
type: number
|
||||
totalReturnPercent:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
positionCount:
|
||||
type: number
|
||||
weightedYield:
|
||||
type: object
|
||||
type: number
|
||||
nullable: true
|
||||
required:
|
||||
- totalInvested
|
||||
- totalValue
|
||||
@ -951,7 +1213,8 @@ components:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: object
|
||||
type: string
|
||||
nullable: true
|
||||
currency:
|
||||
type: string
|
||||
default: RUB
|
||||
@ -976,6 +1239,16 @@ components:
|
||||
- positions
|
||||
- totalValue
|
||||
- analytics
|
||||
PortfolioDetailEnvelopeDto:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/PortfolioDetailResponseDto'
|
||||
meta:
|
||||
$ref: '#/components/schemas/PortfolioResponseMetaDto'
|
||||
required:
|
||||
- data
|
||||
- meta
|
||||
UpdatePortfolioDto:
|
||||
type: object
|
||||
properties:
|
||||
@ -1014,10 +1287,12 @@ components:
|
||||
type: string
|
||||
example: Покупка на дип
|
||||
tags:
|
||||
type: string
|
||||
type: array
|
||||
example:
|
||||
- DIVIDEND
|
||||
- GROWTH
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- DIVIDEND
|
||||
- GROWTH
|
||||
@ -1030,6 +1305,48 @@ components:
|
||||
required:
|
||||
- secid
|
||||
- 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:
|
||||
type: object
|
||||
properties:
|
||||
@ -1046,9 +1363,11 @@ components:
|
||||
type: string
|
||||
example: Докупка
|
||||
tags:
|
||||
type: string
|
||||
type: array
|
||||
example:
|
||||
- DIVIDEND
|
||||
items:
|
||||
type: string
|
||||
enum:
|
||||
- DIVIDEND
|
||||
- GROWTH
|
||||
@ -1058,3 +1377,25 @@ components:
|
||||
- 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user