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 { 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 {

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)
notes?: string;
@ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS })
@ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS, isArray: true })
@IsArray()
@IsIn(TAGS, { each: true })
@IsOptional()

View File

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

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 { 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;

View File

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

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)
notes?: string;
@ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS })
@ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS, isArray: true })
@IsArray()
@IsIn(TAGS, { each: true })
@IsOptional()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,22 +1287,66 @@ components:
type: string
example: Покупка на дип
tags:
type: string
type: array
example:
- DIVIDEND
- GROWTH
enum:
- DIVIDEND
- GROWTH
- DEFENSIVE
- SPECULATIVE
- BOND
- ETF
- GOVERNMENT
- CASH
items:
type: string
enum:
- DIVIDEND
- GROWTH
- DEFENSIVE
- SPECULATIVE
- BOND
- ETF
- GOVERNMENT
- CASH
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,15 +1363,39 @@ components:
type: string
example: Докупка
tags:
type: string
type: array
example:
- DIVIDEND
enum:
- DIVIDEND
- GROWTH
- DEFENSIVE
- SPECULATIVE
- BOND
- ETF
- GOVERNMENT
- CASH
items:
type: string
enum:
- DIVIDEND
- GROWTH
- DEFENSIVE
- SPECULATIVE
- BOND
- ETF
- GOVERNMENT
- CASH
AnalyticsResponseDto:
type: object
properties:
positions:
type: array
items:
$ref: '#/components/schemas/PositionWithPriceDto'
summary:
$ref: '#/components/schemas/PortfolioSummaryDto'
required:
- positions
- summary
AnalyticsEnvelopeDto:
type: object
properties:
data:
$ref: '#/components/schemas/AnalyticsResponseDto'
meta:
$ref: '#/components/schemas/PortfolioResponseMetaDto'
required:
- data
- meta