feat: управление портфелями с разделением акций и облигаций #7
@ -0,0 +1,31 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Portfolio" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"currency" TEXT NOT NULL DEFAULT 'RUB',
|
||||
"targets" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Portfolio_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Position" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"portfolioId" INTEGER NOT NULL,
|
||||
"secid" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"notes" TEXT,
|
||||
"tags" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Position_portfolioId_fkey" FOREIGN KEY ("portfolioId") REFERENCES "Portfolio" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Portfolio_userId_name_key" ON "Portfolio"("userId", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Position_portfolioId_secid_key" ON "Position"("portfolioId", "secid");
|
||||
@ -0,0 +1,21 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Position" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"portfolioId" INTEGER NOT NULL,
|
||||
"secid" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL DEFAULT 'share',
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"notes" TEXT,
|
||||
"tags" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Position_portfolioId_fkey" FOREIGN KEY ("portfolioId") REFERENCES "Portfolio" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Position" ("createdAt", "id", "notes", "portfolioId", "quantity", "secid", "tags", "updatedAt") SELECT "createdAt", "id", "notes", "portfolioId", "quantity", "secid", "tags", "updatedAt" FROM "Position";
|
||||
DROP TABLE "Position";
|
||||
ALTER TABLE "new_Position" RENAME TO "Position";
|
||||
CREATE UNIQUE INDEX "Position_portfolioId_secid_key" ON "Position"("portfolioId", "secid");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@ -6,6 +6,38 @@ datasource db {
|
||||
provider = "sqlite"
|
||||
}
|
||||
|
||||
model Portfolio {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
name String
|
||||
description String?
|
||||
currency String @default("RUB")
|
||||
targets String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
positions Position[]
|
||||
|
||||
@@unique([userId, name])
|
||||
}
|
||||
|
||||
model Position {
|
||||
id Int @id @default(autoincrement())
|
||||
portfolioId Int
|
||||
secid String
|
||||
type String @default("share")
|
||||
quantity Int
|
||||
notes String?
|
||||
tags String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([portfolioId, secid])
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
@ -15,4 +47,5 @@ model User {
|
||||
refreshToken String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
portfolios Portfolio[]
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { SecuritiesModule } from './modules/securities/securities.module';
|
||||
import { SharesModule } from './modules/shares/shares.module';
|
||||
import { BondsModule } from './modules/bonds/bonds.module';
|
||||
import { CandlesModule } from './modules/candles/candles.module';
|
||||
import { PortfolioModule } from './modules/portfolio/portfolio.module';
|
||||
import { PrismaModule } from './modules/prisma/prisma.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import configuration from './config/configuration';
|
||||
@ -23,6 +24,7 @@ import configuration from './config/configuration';
|
||||
SharesModule,
|
||||
BondsModule,
|
||||
CandlesModule,
|
||||
PortfolioModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@ -174,7 +174,10 @@ export class MoexClientService {
|
||||
{ boards: boardId },
|
||||
);
|
||||
const rows = this.extractTable(data, 'securities');
|
||||
const bond = rows.find((r) => r.BOARDID === boardId) || rows[0];
|
||||
const bond =
|
||||
rows.find((r) => r.BOARDID === boardId && r.PREVWAPRICE != null) ||
|
||||
rows.find((r) => r.PREVWAPRICE != null) ||
|
||||
rows[0];
|
||||
if (!bond) return null;
|
||||
|
||||
return {
|
||||
@ -208,7 +211,10 @@ export class MoexClientService {
|
||||
{ boards: boardId },
|
||||
);
|
||||
const mktRows = this.extractTable(data, 'marketdata');
|
||||
const mkt = mktRows.find((r) => r.SECID === secid);
|
||||
const mkt =
|
||||
mktRows.find((r) => r.BOARDID === boardId && r.LAST != null) ||
|
||||
mktRows.find((r) => r.LAST != null) ||
|
||||
mktRows.find((r) => r.SECID === secid);
|
||||
if (!mkt) return null;
|
||||
|
||||
return {
|
||||
|
||||
47
apps/backend/src/modules/portfolio/dto/add-position.dto.ts
Normal file
47
apps/backend/src/modules/portfolio/dto/add-position.dto.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsInt,
|
||||
Min,
|
||||
IsArray,
|
||||
IsIn,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
const TAGS = [
|
||||
'DIVIDEND',
|
||||
'GROWTH',
|
||||
'DEFENSIVE',
|
||||
'SPECULATIVE',
|
||||
'BOND',
|
||||
'ETF',
|
||||
'GOVERNMENT',
|
||||
'CASH',
|
||||
] as const;
|
||||
|
||||
export class AddPositionDto {
|
||||
@ApiProperty({ example: 'SBER' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(50)
|
||||
secid!: string;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Покупка на дип' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS })
|
||||
@IsArray()
|
||||
@IsIn(TAGS, { each: true })
|
||||
@IsOptional()
|
||||
tags?: string[];
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import { IsString, IsOptional, IsIn, MaxLength, MinLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] as const;
|
||||
|
||||
export class CreatePortfolioDto {
|
||||
@ApiProperty({ example: 'Мой портфель' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Описание портфеля' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 'RUB', enum: CURRENCIES })
|
||||
@IsString()
|
||||
@IsIn(CURRENCIES)
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
class PositionWithPriceDto {
|
||||
@ApiProperty() id!: number;
|
||||
@ApiProperty({ example: 'SBER' }) secid!: string;
|
||||
@ApiProperty({ example: 'share', enum: ['share', 'bond'] }) type!: string;
|
||||
@ApiProperty({ example: 10 }) quantity!: number;
|
||||
@ApiPropertyOptional() notes!: string | null;
|
||||
@ApiPropertyOptional() tags!: string[] | null;
|
||||
@ApiPropertyOptional() currentPrice!: number | null;
|
||||
@ApiPropertyOptional() currentValue!: number | null;
|
||||
@ApiProperty() weightPercent!: number;
|
||||
@ApiPropertyOptional() change!: number | null;
|
||||
@ApiPropertyOptional() changePercent!: number | null;
|
||||
@ApiPropertyOptional() yieldToMaturity!: number | null;
|
||||
@ApiPropertyOptional() duration!: number | null;
|
||||
@ApiPropertyOptional() couponValue!: number | null;
|
||||
@ApiPropertyOptional() couponPercent!: number | null;
|
||||
@ApiPropertyOptional() nextCouponDate!: string | null;
|
||||
@ApiPropertyOptional() matDate!: string | null;
|
||||
@ApiPropertyOptional() accruedInt!: number | null;
|
||||
@ApiPropertyOptional() bid!: number | null;
|
||||
@ApiPropertyOptional() offer!: number | null;
|
||||
@ApiPropertyOptional() couponPeriod!: number | null;
|
||||
@ApiPropertyOptional() bondType!: string | null;
|
||||
@ApiPropertyOptional() offerDate!: string | null;
|
||||
}
|
||||
|
||||
export class PortfolioResponseDto {
|
||||
@ApiProperty() id!: number;
|
||||
@ApiProperty() name!: string;
|
||||
@ApiPropertyOptional() description!: string | null;
|
||||
@ApiProperty({ default: 'RUB' }) currency!: string;
|
||||
@ApiProperty() createdAt!: string;
|
||||
@ApiProperty() updatedAt!: string;
|
||||
}
|
||||
|
||||
export class PortfolioDetailResponseDto extends PortfolioResponseDto {
|
||||
@ApiProperty({ type: [PositionWithPriceDto] })
|
||||
positions!: PositionWithPriceDto[];
|
||||
|
||||
@ApiProperty() totalValue!: number;
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class PositionResponseDto {
|
||||
@ApiProperty() id!: number;
|
||||
@ApiProperty({ example: 'SBER' }) secid!: string;
|
||||
@ApiProperty({ example: 10 }) quantity!: number;
|
||||
@ApiPropertyOptional() notes!: string | null;
|
||||
@ApiPropertyOptional() tags!: string[] | null;
|
||||
@ApiProperty() portfolioId!: number;
|
||||
@ApiProperty() createdAt!: string;
|
||||
@ApiProperty() updatedAt!: string;
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { IsString, IsOptional, IsIn, MaxLength, MinLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] as const;
|
||||
|
||||
export class UpdatePortfolioDto {
|
||||
@ApiPropertyOptional({ example: 'Мой портфель' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Обновлённое описание' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 'RUB', enum: CURRENCIES })
|
||||
@IsString()
|
||||
@IsIn(CURRENCIES)
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import { IsString, IsOptional, IsInt, Min, IsArray, IsIn, MaxLength } from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
const TAGS = [
|
||||
'DIVIDEND',
|
||||
'GROWTH',
|
||||
'DEFENSIVE',
|
||||
'SPECULATIVE',
|
||||
'BOND',
|
||||
'ETF',
|
||||
'GOVERNMENT',
|
||||
'CASH',
|
||||
] as const;
|
||||
|
||||
export class UpdatePositionDto {
|
||||
@ApiPropertyOptional({ example: 15 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@IsOptional()
|
||||
quantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Докупка' })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(500)
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS })
|
||||
@IsArray()
|
||||
@IsIn(TAGS, { each: true })
|
||||
@IsOptional()
|
||||
tags?: string[];
|
||||
}
|
||||
88
apps/backend/src/modules/portfolio/portfolio.controller.ts
Normal file
88
apps/backend/src/modules/portfolio/portfolio.controller.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } 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';
|
||||
|
||||
@ApiTags('Portfolios')
|
||||
@ApiBearerAuth()
|
||||
@Controller('portfolios')
|
||||
export class PortfolioController {
|
||||
constructor(private readonly portfolioService: PortfolioService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get all portfolios for current user' })
|
||||
async findAll(@CurrentUser() user: { sub: number }) {
|
||||
const portfolios = await this.portfolioService.findAll(user.sub);
|
||||
return { data: portfolios, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new portfolio' })
|
||||
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 } };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get portfolio details with positions and prices' })
|
||||
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 } };
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update portfolio' })
|
||||
async update(
|
||||
@CurrentUser() user: { sub: number },
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdatePortfolioDto,
|
||||
) {
|
||||
const portfolio = await this.portfolioService.update(user.sub, id, dto);
|
||||
return { data: portfolio, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete portfolio' })
|
||||
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 } };
|
||||
}
|
||||
|
||||
@Post(':id/positions')
|
||||
@ApiOperation({ summary: 'Add position to portfolio' })
|
||||
async addPosition(
|
||||
@CurrentUser() user: { sub: number },
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: AddPositionDto,
|
||||
) {
|
||||
const position = await this.portfolioService.addPosition(user.sub, id, dto);
|
||||
return { data: position, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
|
||||
@Patch(':id/positions/:positionId')
|
||||
@ApiOperation({ summary: 'Update position' })
|
||||
async updatePosition(
|
||||
@CurrentUser() user: { sub: number },
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Param('positionId', ParseIntPipe) positionId: number,
|
||||
@Body() dto: UpdatePositionDto,
|
||||
) {
|
||||
const position = await this.portfolioService.updatePosition(user.sub, id, positionId, dto);
|
||||
return { data: position, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
|
||||
@Delete(':id/positions/:positionId')
|
||||
@ApiOperation({ summary: 'Remove position from portfolio' })
|
||||
async removePosition(
|
||||
@CurrentUser() user: { sub: number },
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Param('positionId', ParseIntPipe) positionId: number,
|
||||
) {
|
||||
await this.portfolioService.removePosition(user.sub, id, positionId);
|
||||
return { data: null, meta: { cachedAt: null, fromCache: false } };
|
||||
}
|
||||
}
|
||||
10
apps/backend/src/modules/portfolio/portfolio.module.ts
Normal file
10
apps/backend/src/modules/portfolio/portfolio.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PortfolioController } from './portfolio.controller';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PortfolioController],
|
||||
providers: [PortfolioService],
|
||||
exports: [PortfolioService],
|
||||
})
|
||||
export class PortfolioModule {}
|
||||
345
apps/backend/src/modules/portfolio/portfolio.service.ts
Normal file
345
apps/backend/src/modules/portfolio/portfolio.service.ts
Normal file
@ -0,0 +1,345 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { CacheService } from '../cache/cache.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';
|
||||
|
||||
export interface EnrichedPosition {
|
||||
id: number;
|
||||
secid: string;
|
||||
shortName: string | null;
|
||||
type: string;
|
||||
quantity: number;
|
||||
notes: string | null;
|
||||
tags: string[] | null;
|
||||
currentPrice: number | null;
|
||||
currentValue: number | null;
|
||||
weightPercent: number;
|
||||
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;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async create(userId: number, dto: CreatePortfolioDto) {
|
||||
return this.prisma.portfolio.create({
|
||||
data: {
|
||||
userId,
|
||||
name: dto.name,
|
||||
description: dto.description ?? null,
|
||||
currency: dto.currency ?? 'RUB',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(userId: number) {
|
||||
return this.prisma.portfolio.findMany({
|
||||
where: { userId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(userId: number, id: number) {
|
||||
const portfolio = await this.prisma.portfolio.findUnique({
|
||||
where: { id },
|
||||
include: { positions: true },
|
||||
});
|
||||
|
||||
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
|
||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||||
|
||||
const positionsWithPrices = await this.enrichPositions(portfolio.positions);
|
||||
|
||||
const totalValue = positionsWithPrices.reduce((sum, p) => sum + (p.currentValue ?? 0), 0);
|
||||
|
||||
const positionsWithWeights: EnrichedPosition[] = positionsWithPrices.map((p) => {
|
||||
const weightPercent = totalValue > 0 ? ((p.currentValue ?? 0) / totalValue) * 100 : 0;
|
||||
return {
|
||||
...p,
|
||||
weightPercent: Math.round(weightPercent * 2) / 2,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: portfolio.id,
|
||||
name: portfolio.name,
|
||||
description: portfolio.description,
|
||||
currency: portfolio.currency,
|
||||
createdAt: portfolio.createdAt.toISOString(),
|
||||
updatedAt: portfolio.updatedAt.toISOString(),
|
||||
positions: positionsWithWeights,
|
||||
totalValue: Math.round(totalValue * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
async update(userId: number, id: number, dto: UpdatePortfolioDto) {
|
||||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
|
||||
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
|
||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||||
|
||||
return this.prisma.portfolio.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.currency !== undefined && { currency: dto.currency }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(userId: number, id: number) {
|
||||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id } });
|
||||
if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`);
|
||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||||
|
||||
await this.prisma.portfolio.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async addPosition(userId: number, portfolioId: number, dto: AddPositionDto) {
|
||||
const portfolio = await this.prisma.portfolio.findUnique({
|
||||
where: { id: portfolioId },
|
||||
include: { positions: true },
|
||||
});
|
||||
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
|
||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||||
|
||||
const exists = portfolio.positions.find((p) => p.secid === dto.secid);
|
||||
if (exists)
|
||||
throw new BadRequestException(`Position ${dto.secid} already exists in this portfolio`);
|
||||
|
||||
if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0');
|
||||
|
||||
const desc = await this.moexClient.getSecurityDescription(dto.secid);
|
||||
if (!desc) throw new BadRequestException(`Security ${dto.secid} not found in MOEX`);
|
||||
|
||||
const type = desc.group === 'stock_bonds' ? 'bond' : 'share';
|
||||
|
||||
return this.prisma.position.create({
|
||||
data: {
|
||||
portfolioId,
|
||||
secid: dto.secid,
|
||||
type,
|
||||
quantity: dto.quantity,
|
||||
notes: dto.notes ?? null,
|
||||
tags: dto.tags ? JSON.stringify(dto.tags) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updatePosition(
|
||||
userId: number,
|
||||
portfolioId: number,
|
||||
positionId: number,
|
||||
dto: UpdatePositionDto,
|
||||
) {
|
||||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
|
||||
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
|
||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||||
|
||||
const position = await this.prisma.position.findUnique({ where: { id: positionId } });
|
||||
if (!position || position.portfolioId !== portfolioId) {
|
||||
throw new NotFoundException(`Position ${positionId} not found`);
|
||||
}
|
||||
|
||||
return this.prisma.position.update({
|
||||
where: { id: positionId },
|
||||
data: {
|
||||
...(dto.quantity !== undefined && { quantity: dto.quantity }),
|
||||
...(dto.notes !== undefined && { notes: dto.notes }),
|
||||
...(dto.tags !== undefined && { tags: dto.tags ? JSON.stringify(dto.tags) : null }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removePosition(userId: number, portfolioId: number, positionId: number) {
|
||||
const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } });
|
||||
if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`);
|
||||
if (portfolio.userId !== userId) throw new ForbiddenException('Access denied');
|
||||
|
||||
const position = await this.prisma.position.findUnique({ where: { id: positionId } });
|
||||
if (!position || position.portfolioId !== portfolioId) {
|
||||
throw new NotFoundException(`Position ${positionId} not found`);
|
||||
}
|
||||
|
||||
await this.prisma.position.delete({ where: { id: positionId } });
|
||||
}
|
||||
|
||||
private async enrichPositions(
|
||||
positions: {
|
||||
id: number;
|
||||
portfolioId: number;
|
||||
secid: string;
|
||||
type: string;
|
||||
quantity: number;
|
||||
notes: string | null;
|
||||
tags: string | null;
|
||||
}[],
|
||||
): Promise<EnrichedPosition[]> {
|
||||
return Promise.all(
|
||||
positions.map(async (pos) => {
|
||||
let shortName: string | null = null;
|
||||
try {
|
||||
const { data: desc } = await this.cache.getOrFetch(
|
||||
'security',
|
||||
['portfolio-name', pos.secid],
|
||||
async () => {
|
||||
const d = await this.moexClient.getSecurityDescription(pos.secid);
|
||||
return { shortName: d?.shortName ?? null };
|
||||
},
|
||||
'securityTtl',
|
||||
);
|
||||
shortName = desc.shortName;
|
||||
} catch {
|
||||
shortName = null;
|
||||
}
|
||||
|
||||
const base = {
|
||||
id: pos.id,
|
||||
secid: pos.secid,
|
||||
shortName,
|
||||
type: pos.type,
|
||||
quantity: pos.quantity,
|
||||
notes: pos.notes,
|
||||
tags: pos.tags ? JSON.parse(pos.tags) : null,
|
||||
weightPercent: 0,
|
||||
currentPrice: null as number | null,
|
||||
currentValue: null as number | null,
|
||||
};
|
||||
|
||||
if (pos.type === 'bond') {
|
||||
return this.enrichBondPosition(pos, base);
|
||||
}
|
||||
return this.enrichSharePosition(pos, base);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async enrichSharePosition(
|
||||
pos: { id: number; secid: string; quantity: number },
|
||||
base: EnrichedPosition,
|
||||
): Promise<EnrichedPosition> {
|
||||
try {
|
||||
const { data: marketData } = await this.cache.getOrFetch(
|
||||
'marketdata',
|
||||
['portfolio', pos.secid],
|
||||
async () => {
|
||||
const data = await this.moexClient.getShareMarketData(pos.secid);
|
||||
return {
|
||||
price: data?.last ?? null,
|
||||
change: data?.lastChange ?? null,
|
||||
changePercent: data?.lastChangePrcnt ?? null,
|
||||
};
|
||||
},
|
||||
'marketDataTtl',
|
||||
);
|
||||
|
||||
return {
|
||||
...base,
|
||||
currentPrice: marketData.price,
|
||||
change: marketData.change,
|
||||
changePercent: marketData.changePercent,
|
||||
currentValue: marketData.price !== null ? marketData.price * pos.quantity : null,
|
||||
};
|
||||
} catch {
|
||||
return { ...base, currentPrice: null, change: null, changePercent: null, currentValue: null };
|
||||
}
|
||||
}
|
||||
|
||||
private async enrichBondPosition(
|
||||
pos: { id: number; secid: string; quantity: number },
|
||||
base: EnrichedPosition,
|
||||
): Promise<EnrichedPosition> {
|
||||
try {
|
||||
const { data: bondData } = await this.cache.getOrFetch(
|
||||
'bonddata',
|
||||
['portfolio', pos.secid],
|
||||
async () => {
|
||||
const desc = await this.moexClient.getBondData(pos.secid);
|
||||
const mkt = await this.moexClient.getBondMarketData(pos.secid);
|
||||
return {
|
||||
price: mkt?.last ?? null,
|
||||
yieldToMaturity: mkt?.yield ?? null,
|
||||
duration: mkt?.duration ?? null,
|
||||
couponValue: desc?.couponValue ?? null,
|
||||
couponPercent: desc?.couponPercent ?? null,
|
||||
nextCouponDate: desc?.nextCoupon ?? null,
|
||||
matDate: desc?.matDate ?? null,
|
||||
accruedInt: desc?.accruedInt ?? null,
|
||||
faceValue: desc?.faceValue ?? 1000,
|
||||
bid: mkt?.bid ?? null,
|
||||
offer: mkt?.offer ?? null,
|
||||
couponPeriod: desc?.couponPeriod ?? null,
|
||||
bondType: desc?.bondType ?? null,
|
||||
offerDate: desc?.offerDate ?? null,
|
||||
};
|
||||
},
|
||||
'marketDataTtl',
|
||||
);
|
||||
|
||||
const currentValue =
|
||||
bondData.price !== null ? (bondData.price / 100) * bondData.faceValue * pos.quantity : null;
|
||||
|
||||
return {
|
||||
...base,
|
||||
currentPrice: bondData.price,
|
||||
yieldToMaturity: bondData.yieldToMaturity,
|
||||
duration: bondData.duration,
|
||||
couponValue: bondData.couponValue,
|
||||
couponPercent: bondData.couponPercent,
|
||||
nextCouponDate: bondData.nextCouponDate,
|
||||
matDate: bondData.matDate,
|
||||
accruedInt: bondData.accruedInt,
|
||||
bid: bondData.bid,
|
||||
offer: bondData.offer,
|
||||
couponPeriod: bondData.couponPeriod,
|
||||
bondType: bondData.bondType,
|
||||
offerDate: bondData.offerDate,
|
||||
currentValue,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...base,
|
||||
currentPrice: null,
|
||||
yieldToMaturity: null,
|
||||
duration: null,
|
||||
couponValue: null,
|
||||
couponPercent: null,
|
||||
nextCouponDate: null,
|
||||
matDate: null,
|
||||
accruedInt: null,
|
||||
bid: null,
|
||||
offer: null,
|
||||
couponPeriod: null,
|
||||
bondType: null,
|
||||
offerDate: null,
|
||||
currentValue: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
27
apps/docs/docs/adr/ADR-009-portfolio-domain.md
Normal file
27
apps/docs/docs/adr/ADR-009-portfolio-domain.md
Normal file
@ -0,0 +1,27 @@
|
||||
# ADR-009: Portfolio Domain Model
|
||||
|
||||
**Status:** Accepted
|
||||
|
||||
**Date:** 2026-06-14
|
||||
|
||||
## Context
|
||||
|
||||
Adding portfolio tracking feature to MoexVibe. Need to decide on data model — relational tables vs document-oriented storage for portfolio and position data.
|
||||
|
||||
## Decision
|
||||
|
||||
Use SQL via Prisma (existing database) with `Portfolio` and `Position` as separate tables. JSON fields for flexible data (`targets`, `tags`).
|
||||
|
||||
## Rationale
|
||||
|
||||
- SQLite already in use for User model
|
||||
- Portfolio and Position have clear relational structure (1:N)
|
||||
- JSON fields cover semi-structured requirements (targets, tags) without adding another database
|
||||
- No need for complex queries across tags at current scale (50 positions max per portfolio)
|
||||
- Straightforward migration to PostgreSQL if needed
|
||||
|
||||
## Consequences
|
||||
|
||||
- JSON fields not individually queryable in SQLite (acceptable for Phase 1)
|
||||
- Targets edited as full JSON replacement (not atomic per-item update)
|
||||
- Cascade delete handles portfolio → position cleanup
|
||||
26
apps/docs/docs/adr/ADR-010-backend-price-computation.md
Normal file
26
apps/docs/docs/adr/ADR-010-backend-price-computation.md
Normal file
@ -0,0 +1,26 @@
|
||||
# ADR-010: Backend Price Computation
|
||||
|
||||
**Status:** Accepted
|
||||
|
||||
**Date:** 2026-06-14
|
||||
|
||||
## Context
|
||||
|
||||
Portfolio positions need current market prices for value calculation. Where should price computation happen — on backend or frontend?
|
||||
|
||||
## Decision
|
||||
|
||||
Compute prices on backend. `GET /api/v1/portfolios/:id` returns fully computed `PortfolioDetailResponseDto` with `currentPrice`, `currentValue`, `weightPercent`, and `deviation` for each position.
|
||||
|
||||
## Rationale
|
||||
|
||||
- Single source of truth for financial calculations
|
||||
- Frontend receives ready-to-display data
|
||||
- Backend caching reduces MOEX API calls
|
||||
- Avoids N individual price requests from frontend
|
||||
|
||||
## Consequences
|
||||
|
||||
- Backend makes N MOEX requests per portfolio read (cached by TTL 900s)
|
||||
- Portfolio endpoint cannot use naive response caching (prices per-user)
|
||||
- Extra load on backend when many users view portfolios simultaneously
|
||||
92
apps/docs/docs/backend/portfolio.md
Normal file
92
apps/docs/docs/backend/portfolio.md
Normal file
@ -0,0 +1,92 @@
|
||||
# Portfolio Module
|
||||
|
||||
The Portfolio module allows users to create and manage virtual investment portfolios for tracking purposes.
|
||||
|
||||
## Overview
|
||||
|
||||
- **Backend:** `PortfolioModule` (`apps/backend/src/modules/portfolio/`)
|
||||
- **Frontend:** Protected pages at `/portfolios` and `/portfolios/:id`
|
||||
- **Database:** `Portfolio` and `Position` models (Prisma + SQLite)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All endpoints require JWT authentication (`JwtAuthGuard`).
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|---|---|---|
|
||||
| `/api/v1/portfolios` | GET | List user's portfolios |
|
||||
| `/api/v1/portfolios` | POST | Create portfolio |
|
||||
| `/api/v1/portfolios/:id` | GET | Portfolio detail with enriched positions |
|
||||
| `/api/v1/portfolios/:id` | PATCH | Update portfolio (name, description, currency) |
|
||||
| `/api/v1/portfolios/:id` | DELETE | Delete portfolio (cascade deletes positions) |
|
||||
| `/api/v1/portfolios/:id/positions` | POST | Add position (auto-detects share/bond type) |
|
||||
| `/api/v1/portfolios/:id/positions/:posId` | PATCH | Update position (quantity, notes) |
|
||||
| `/api/v1/portfolios/:id/positions/:posId` | DELETE | Remove position |
|
||||
|
||||
## Domain Model
|
||||
|
||||
```
|
||||
Portfolio
|
||||
├── id, userId
|
||||
├── name, description, currency
|
||||
└── positions[]
|
||||
|
||||
Position
|
||||
├── id, portfolioId
|
||||
├── secid (MOEX security ID)
|
||||
├── type ("share" | "bond") — auto-detected from MOEX
|
||||
├── quantity (integer, >= 0)
|
||||
├── notes (free text)
|
||||
├── tags (JSON, stored but not displayed in Phase 1)
|
||||
└── enriched: currentPrice, currentValue, weightPercent
|
||||
+ share: change, changePercent, shortName
|
||||
+ bond: yieldToMaturity, duration, couponValue, couponPercent,
|
||||
nextCouponDate, matDate, accruedInt, bid, offer,
|
||||
couponPeriod, bondType, offerDate
|
||||
```
|
||||
|
||||
## Security Type Detection
|
||||
|
||||
When adding a position, the backend fetches `getSecurityDescription(secid)` from MOEX. If `desc.group === 'stock_bonds'`, the position type is set to `bond`; otherwise `share`. This determines which enrichment path is used on read.
|
||||
|
||||
## Price Computation (Shares)
|
||||
|
||||
For shares: `currentPrice = marketData.last`, `currentValue = price * quantity`, with `change` and `changePercent` from `lastChange` and `lastChangePrcnt`.
|
||||
|
||||
## Price Computation (Bonds)
|
||||
|
||||
For bonds, MOEX returns prices as a percentage of face value (e.g., 98.5 = 98.5% of 1000 RUB).
|
||||
|
||||
- `currentPrice = marketData.last` (% of face value)
|
||||
- `currentValue = (price / 100) * faceValue * quantity`
|
||||
|
||||
Bond enrichment also fetches:
|
||||
| Field | Source | Description |
|
||||
|---|---|---|
|
||||
| `yieldToMaturity` | `getBondMarketData().yield` | YTM (%) |
|
||||
| `duration` | `getBondMarketData().duration` | Modified duration (years) |
|
||||
| `couponValue` | `getBondData().couponValue` | Coupon amount in RUB |
|
||||
| `couponPercent` | `getBondData().couponPercent` | Coupon rate (%) |
|
||||
| `couponPeriod` | `getBondData().couponPeriod` | Days between payments |
|
||||
| `nextCouponDate` | `getBondData().nextCoupon` | Next coupon date |
|
||||
| `matDate` | `getBondData().matDate` | Maturity date |
|
||||
| `offerDate` | `getBondData().offerDate` | Early redemption date |
|
||||
| `accruedInt` | `getBondData().accruedInt` | Accrued interest per bond (RUB) |
|
||||
| `bondType` | `getBondData().bondType` | "ОФЗ", "Корпоративная", etc. |
|
||||
| `bid` / `offer` | `getBondMarketData()` | Current bid/ask (% of face) |
|
||||
|
||||
## MOEX Board Selection
|
||||
|
||||
The backend uses the following default MOEX boards:
|
||||
- **Shares:** `TQBR` (Т+: Акции — безадрес.)
|
||||
- **Bonds:** `TQCB` (Т+: Корпоративные облигации — безадрес.)
|
||||
|
||||
Some bonds (primarily OFZ government bonds) trade on `TQOB` board. If the requested board has no trading data, the backend falls back to any board with a non-null `LAST` price. Data is cached with standard market data TTL (900s).
|
||||
|
||||
## Future Phases
|
||||
|
||||
1. **Positions v2** — pie chart, filtering
|
||||
2. **Transactions** — buy/sell history, average cost basis
|
||||
3. **Analytics** — portfolio value chart, XIRR, benchmark comparison
|
||||
4. **Dividends & Coupons** — aggregated forecast calendar
|
||||
5. **Corporate Actions** — splits, consolidations auto-adjustment
|
||||
@ -17,6 +17,7 @@ const sidebars: SidebarsConfig = {
|
||||
'backend/configuration',
|
||||
'backend/caching',
|
||||
'backend/moex-client',
|
||||
'backend/portfolio',
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -59,6 +60,8 @@ const sidebars: SidebarsConfig = {
|
||||
'adr/ADR-006-no-cci',
|
||||
'adr/ADR-007-two-level-caching',
|
||||
'adr/ADR-008-auth-system',
|
||||
'adr/ADR-009-portfolio-domain',
|
||||
'adr/ADR-010-backend-price-computation',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
74
apps/frontend/src/api/portfolio.ts
Normal file
74
apps/frontend/src/api/portfolio.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { request } from './client';
|
||||
import type { Portfolio, PortfolioDetail, Position } from './responses';
|
||||
|
||||
export function getPortfolios(): Promise<{
|
||||
data: Portfolio[];
|
||||
meta: { cachedAt: string | null; fromCache: boolean };
|
||||
}> {
|
||||
return request<Portfolio[]>('/api/v1/portfolios');
|
||||
}
|
||||
|
||||
export function getPortfolio(
|
||||
id: number,
|
||||
): Promise<{ data: PortfolioDetail; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||
return request<PortfolioDetail>(`/api/v1/portfolios/${id}`);
|
||||
}
|
||||
|
||||
export function createPortfolio(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
currency?: string;
|
||||
}): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||
return request<Portfolio>('/api/v1/portfolios', undefined, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePortfolio(
|
||||
id: number,
|
||||
data: { name?: string; description?: string; currency?: string },
|
||||
): Promise<{ data: Portfolio; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||
return request<Portfolio>(`/api/v1/portfolios/${id}`, undefined, {
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export function deletePortfolio(
|
||||
id: number,
|
||||
): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||
return request<null>(`/api/v1/portfolios/${id}`, undefined, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export function addPosition(
|
||||
portfolioId: number,
|
||||
data: { secid: string; quantity: number; notes?: string; tags?: string[] },
|
||||
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions`, undefined, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePosition(
|
||||
portfolioId: number,
|
||||
positionId: number,
|
||||
data: { quantity?: number; notes?: string; tags?: string[] },
|
||||
): Promise<{ data: Position; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||
return request<Position>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
});
|
||||
}
|
||||
|
||||
export function removePosition(
|
||||
portfolioId: number,
|
||||
positionId: number,
|
||||
): Promise<{ data: null; meta: { cachedAt: string | null; fromCache: boolean } }> {
|
||||
return request<null>(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@ -134,3 +134,55 @@ export interface AuthResponse {
|
||||
user: UserResponse;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
export interface Portfolio {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
currency: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PositionWithPrice {
|
||||
id: number;
|
||||
secid: string;
|
||||
shortName: string | null;
|
||||
type: 'share' | 'bond';
|
||||
quantity: number;
|
||||
notes: string | null;
|
||||
tags: string[] | null;
|
||||
currentPrice: number | null;
|
||||
currentValue: number | null;
|
||||
weightPercent: number;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface PortfolioDetail extends Portfolio {
|
||||
positions: PositionWithPrice[];
|
||||
totalValue: number;
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
id: number;
|
||||
secid: string;
|
||||
quantity: number;
|
||||
notes: string | null;
|
||||
tags: string[] | null;
|
||||
portfolioId: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@ -35,6 +35,17 @@ export function Layout() {
|
||||
MoexVibe
|
||||
</Link>
|
||||
<SearchBar />
|
||||
<Link
|
||||
to="/portfolios"
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: 'var(--color-text)',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Портфели
|
||||
</Link>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
|
||||
137
apps/frontend/src/components/portfolios/BondPositionRow.tsx
Normal file
137
apps/frontend/src/components/portfolios/BondPositionRow.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { PositionWithPrice } from '../../api/responses';
|
||||
|
||||
interface Props {
|
||||
position: PositionWithPrice;
|
||||
onUpdate: (data: { quantity?: number }) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function BondPositionRow({ position, onUpdate, onDelete }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [qty, setQty] = useState(String(position.quantity));
|
||||
|
||||
function handleSave() {
|
||||
const num = parseInt(qty, 10);
|
||||
if (!isNaN(num) && num >= 0 && num !== position.quantity) {
|
||||
onUpdate({ quantity: num });
|
||||
}
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleDateString('ru-RU');
|
||||
}
|
||||
|
||||
function formatPct(value: number | null | undefined): string {
|
||||
return value != null ? `${value.toFixed(2)}%` : '—';
|
||||
}
|
||||
|
||||
function formatRubles(value: number | null | undefined): string {
|
||||
return value != null
|
||||
? value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
: '—';
|
||||
}
|
||||
|
||||
const totalAccrued = position.accruedInt != null ? position.accruedInt * position.quantity : null;
|
||||
|
||||
return (
|
||||
<tr style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||
<td style={{ padding: '8px 12px', fontWeight: 600, fontFamily: 'monospace' }}>
|
||||
<Link to={`/bonds/${position.secid}`} style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
{position.secid}
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>
|
||||
{position.shortName ?? '—'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
{position.bondType ? <span>{position.bondType}</span> : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px' }}>
|
||||
{editing ? (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={qty}
|
||||
onChange={(e) => setQty(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||
autoFocus
|
||||
style={{
|
||||
width: 80,
|
||||
padding: '4px 8px',
|
||||
border: '1px solid var(--color-primary)',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={() => {
|
||||
setQty(String(position.quantity));
|
||||
setEditing(true);
|
||||
}}
|
||||
style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }}
|
||||
>
|
||||
{position.quantity.toLocaleString('ru-RU')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{formatPct(position.currentPrice)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>{formatPct(position.bid)}</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>{formatPct(position.offer)}</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{formatPct(position.yieldToMaturity)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{position.duration != null ? `${position.duration.toFixed(2)}г` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{formatRubles(position.couponValue)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{formatPct(position.couponPercent)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{position.couponPeriod ? `${position.couponPeriod}д` : '—'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{totalAccrued != null
|
||||
? totalAccrued.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
: '—'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{formatDate(position.nextCouponDate)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>{formatDate(position.matDate)}</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>{formatDate(position.offerDate)}</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{position.weightPercent.toFixed(1)}%
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px' }}>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#e53935',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
padding: 4,
|
||||
}}
|
||||
title="Удалить позицию"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
226
apps/frontend/src/components/portfolios/BondPositionTable.tsx
Normal file
226
apps/frontend/src/components/portfolios/BondPositionTable.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
import { BondPositionRow } from './BondPositionRow';
|
||||
import type { PositionWithPrice } from '../../api/responses';
|
||||
|
||||
interface Props {
|
||||
positions: PositionWithPrice[];
|
||||
onUpdatePosition: (positionId: number, data: { quantity?: number }) => void;
|
||||
onDeletePosition: (positionId: number) => void;
|
||||
}
|
||||
|
||||
export function BondPositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) {
|
||||
if (positions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Облигации
|
||||
</h3>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Тикер
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Название
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Тип
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Количество
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Цена
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Бид
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Оффер
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Доходность
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Дюрация
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Купон
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Куп. %
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Период
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
НКД
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
След. купон
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Погашение
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Оферта
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Доля
|
||||
</th>
|
||||
<th style={{ padding: '8px 12px', width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((pos) => (
|
||||
<BondPositionRow
|
||||
key={pos.id}
|
||||
position={pos}
|
||||
onUpdate={(data) => onUpdatePosition(pos.id, data)}
|
||||
onDelete={() => onDeletePosition(pos.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
apps/frontend/src/components/portfolios/PortfolioCard.tsx
Normal file
39
apps/frontend/src/components/portfolios/PortfolioCard.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Portfolio } from '../../api/responses';
|
||||
|
||||
export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/portfolios/${portfolio.id}`}
|
||||
style={{
|
||||
display: 'block',
|
||||
padding: 20,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
textDecoration: 'none',
|
||||
color: 'inherit',
|
||||
transition: 'box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')}
|
||||
>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{portfolio.name}</h3>
|
||||
{portfolio.description && (
|
||||
<p style={{ margin: '4px 0 0', fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||
{portfolio.description}
|
||||
</p>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginTop: 8,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{portfolio.currency} · обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
119
apps/frontend/src/components/portfolios/PortfolioForm.tsx
Normal file
119
apps/frontend/src/components/portfolios/PortfolioForm.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { useState } from 'react';
|
||||
import type { Portfolio } from '../../api/responses';
|
||||
|
||||
interface Props {
|
||||
initial?: Portfolio;
|
||||
onSave: (data: { name: string; description?: string; currency?: string }) => void;
|
||||
onCancel: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'];
|
||||
|
||||
export function PortfolioForm({ initial, onSave, onCancel, isLoading }: Props) {
|
||||
const [name, setName] = useState(initial?.name || '');
|
||||
const [description, setDescription] = useState(initial?.description || '');
|
||||
const [currency, setCurrency] = useState(initial?.currency || 'RUB');
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
onSave({ name: name.trim(), description: description.trim() || undefined, currency });
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
maxLength={100}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 4 }}>
|
||||
Валюта
|
||||
</label>
|
||||
<select
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'transparent',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !name.trim()}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{initial ? 'Сохранить' : 'Создать'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
44
apps/frontend/src/components/portfolios/PortfolioSummary.tsx
Normal file
44
apps/frontend/src/components/portfolios/PortfolioSummary.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import type { PortfolioDetail } from '../../api/responses';
|
||||
|
||||
export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 32,
|
||||
padding: 20,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
|
||||
Общая стоимость
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700 }}>
|
||||
{portfolio.totalValue.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
color: 'var(--color-text-secondary)',
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
{portfolio.currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
|
||||
Позиций
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 700 }}>{portfolio.positions.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
apps/frontend/src/components/portfolios/SharePositionRow.tsx
Normal file
113
apps/frontend/src/components/portfolios/SharePositionRow.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { PositionWithPrice } from '../../api/responses';
|
||||
|
||||
interface Props {
|
||||
position: PositionWithPrice;
|
||||
onUpdate: (data: { quantity?: number }) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function SharePositionRow({ position, onUpdate, onDelete }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [qty, setQty] = useState(String(position.quantity));
|
||||
|
||||
function handleSave() {
|
||||
const num = parseInt(qty, 10);
|
||||
if (!isNaN(num) && num >= 0 && num !== position.quantity) {
|
||||
onUpdate({ quantity: num });
|
||||
}
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<tr style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||
<td style={{ padding: '8px 12px', fontWeight: 600, fontFamily: 'monospace' }}>
|
||||
<Link to={`/stocks/${position.secid}`} style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
{position.secid}
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', color: 'var(--color-text-secondary)' }}>
|
||||
{position.shortName ?? '—'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px' }}>
|
||||
{editing ? (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={qty}
|
||||
onChange={(e) => setQty(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
||||
autoFocus
|
||||
style={{
|
||||
width: 80,
|
||||
padding: '4px 8px',
|
||||
border: '1px solid var(--color-primary)',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onClick={() => {
|
||||
setQty(String(position.quantity));
|
||||
setEditing(true);
|
||||
}}
|
||||
style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }}
|
||||
>
|
||||
{position.quantity}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{position.currentPrice !== null
|
||||
? position.currentPrice.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
: '—'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{position.change != null && position.change !== 0 ? (
|
||||
<span style={{ color: position.change > 0 ? '#43a047' : '#e53935' }}>
|
||||
{position.change > 0 ? '+' : ''}
|
||||
{position.change.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{position.currentValue !== null
|
||||
? position.currentValue.toLocaleString('ru-RU', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
: '—'}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||
{position.weightPercent.toFixed(1)}%
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px' }}>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#e53935',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
padding: 4,
|
||||
}}
|
||||
title="Удалить позицию"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
116
apps/frontend/src/components/portfolios/SharePositionTable.tsx
Normal file
116
apps/frontend/src/components/portfolios/SharePositionTable.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { SharePositionRow } from './SharePositionRow';
|
||||
import type { PositionWithPrice } from '../../api/responses';
|
||||
|
||||
interface Props {
|
||||
positions: PositionWithPrice[];
|
||||
onUpdatePosition: (positionId: number, data: { quantity?: number }) => void;
|
||||
onDeletePosition: (positionId: number) => void;
|
||||
}
|
||||
|
||||
export function SharePositionTable({ positions, onUpdatePosition, onDeletePosition }: Props) {
|
||||
if (positions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Акции
|
||||
</h3>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid #e0e0e0' }}>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Тикер
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Название
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Количество
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Цена
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Изм.
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Стоимость
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Доля
|
||||
</th>
|
||||
<th style={{ padding: '8px 12px', width: 40 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((pos) => (
|
||||
<SharePositionRow
|
||||
key={pos.id}
|
||||
position={pos}
|
||||
onUpdate={(data) => onUpdatePosition(pos.id, data)}
|
||||
onDelete={() => onDeletePosition(pos.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/frontend/src/hooks/usePortfolio.ts
Normal file
17
apps/frontend/src/hooks/usePortfolio.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPortfolio } from '../api/portfolio';
|
||||
import type { PortfolioDetail } from '../api/responses';
|
||||
|
||||
export function usePortfolio(id: number) {
|
||||
return useQuery<PortfolioDetail>({
|
||||
queryKey: ['portfolio', id],
|
||||
queryFn: async () => {
|
||||
const res = await getPortfolio(id);
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 900_000,
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
45
apps/frontend/src/hooks/usePortfolioMutations.ts
Normal file
45
apps/frontend/src/hooks/usePortfolioMutations.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { createPortfolio, updatePortfolio, deletePortfolio } from '../api/portfolio';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export function usePortfolioMutations() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (data: { name: string; description?: string; currency?: string }) =>
|
||||
createPortfolio(data),
|
||||
onSuccess: (res) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||||
navigate(`/portfolios/${res.data.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
data,
|
||||
}: {
|
||||
id: number;
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
currency?: string;
|
||||
};
|
||||
}) => updatePortfolio(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', id] });
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => deletePortfolio(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
|
||||
navigate('/portfolios');
|
||||
},
|
||||
});
|
||||
|
||||
return { create, update, remove };
|
||||
}
|
||||
16
apps/frontend/src/hooks/usePortfolios.ts
Normal file
16
apps/frontend/src/hooks/usePortfolios.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPortfolios } from '../api/portfolio';
|
||||
import type { Portfolio } from '../api/responses';
|
||||
|
||||
export function usePortfolios() {
|
||||
return useQuery<Portfolio[]>({
|
||||
queryKey: ['portfolios'],
|
||||
queryFn: async () => {
|
||||
const res = await getPortfolios();
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 900_000,
|
||||
retry: 2,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
61
apps/frontend/src/hooks/usePositionMutations.ts
Normal file
61
apps/frontend/src/hooks/usePositionMutations.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { addPosition, updatePosition, removePosition } from '../api/portfolio';
|
||||
import type { PortfolioDetail } from '../api/responses';
|
||||
|
||||
export function usePositionMutations(portfolioId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (data: { secid: string; quantity: number; notes?: string; tags?: string[] }) =>
|
||||
addPosition(portfolioId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
|
||||
},
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({
|
||||
positionId,
|
||||
data,
|
||||
}: {
|
||||
positionId: number;
|
||||
data: { quantity?: number; notes?: string; tags?: string[] };
|
||||
}) => updatePosition(portfolioId, positionId, data),
|
||||
onMutate: async ({ positionId, data }) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['portfolio', portfolioId] });
|
||||
const previous = queryClient.getQueryData<{ data: PortfolioDetail }>([
|
||||
'portfolio',
|
||||
portfolioId,
|
||||
]);
|
||||
queryClient.setQueryData(['portfolio', portfolioId], (old: any) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
positions: old.positions.map((p: any) =>
|
||||
p.id === positionId
|
||||
? { ...p, ...(data.quantity !== undefined ? { quantity: data.quantity } : {}) }
|
||||
: p,
|
||||
),
|
||||
};
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(['portfolio', portfolioId], context.previous);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (positionId: number) => removePosition(portfolioId, positionId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] });
|
||||
},
|
||||
});
|
||||
|
||||
return { add, update, remove };
|
||||
}
|
||||
248
apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx
Normal file
248
apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx
Normal file
@ -0,0 +1,248 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { usePortfolio } from '../../hooks/usePortfolio';
|
||||
import { usePortfolioMutations } from '../../hooks/usePortfolioMutations';
|
||||
import { usePositionMutations } from '../../hooks/usePositionMutations';
|
||||
import { PortfolioForm } from '../../components/portfolios/PortfolioForm';
|
||||
import { PortfolioSummary } from '../../components/portfolios/PortfolioSummary';
|
||||
import { SharePositionTable } from '../../components/portfolios/SharePositionTable';
|
||||
import { BondPositionTable } from '../../components/portfolios/BondPositionTable';
|
||||
|
||||
export function PortfolioDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const portfolioId = parseInt(id!, 10);
|
||||
|
||||
const { data: portfolio, isLoading, error } = usePortfolio(portfolioId);
|
||||
const { update, remove } = usePortfolioMutations();
|
||||
const {
|
||||
update: updatePosition,
|
||||
remove: removePosition,
|
||||
add: addPosition,
|
||||
} = usePositionMutations(portfolioId);
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newSecid, setNewSecid] = useState('');
|
||||
const [newQty, setNewQty] = useState('1');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--color-text-secondary)' }}>
|
||||
Загрузка...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !portfolio) {
|
||||
return (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: '#e53935' }}>
|
||||
Ошибка загрузки портфеля
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (window.confirm('Удалить портфель и все позиции?')) {
|
||||
remove.mutate(portfolioId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddPosition() {
|
||||
if (!newSecid.trim() || !parseInt(newQty, 10)) return;
|
||||
addPosition.mutate(
|
||||
{
|
||||
secid: newSecid.trim().toUpperCase(),
|
||||
quantity: parseInt(newQty, 10),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowAddForm(false);
|
||||
setNewSecid('');
|
||||
setNewQty('1');
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
to="/portfolios"
|
||||
style={{ color: 'var(--color-text-secondary)', textDecoration: 'none', fontSize: 14 }}
|
||||
>
|
||||
← К списку
|
||||
</Link>
|
||||
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 700 }}>{portfolio.name}</h1>
|
||||
<button
|
||||
onClick={() => setEditing(!editing)}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
padding: '6px 16px',
|
||||
background: 'transparent',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{editing ? 'Закрыть' : 'Редактировать'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
background: 'transparent',
|
||||
border: '1px solid #e53935',
|
||||
color: '#e53935',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 13,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
padding: 20,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600 }}>
|
||||
Редактировать портфель
|
||||
</h3>
|
||||
<PortfolioForm
|
||||
initial={portfolio}
|
||||
onSave={(d) => update.mutate({ id: portfolioId, data: d })}
|
||||
onCancel={() => setEditing(false)}
|
||||
isLoading={update.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PortfolioSummary portfolio={portfolio} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>Позиции</h2>
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
+ Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 16,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
|
||||
Тикер
|
||||
</label>
|
||||
<input
|
||||
value={newSecid}
|
||||
onChange={(e) => setNewSecid(e.target.value)}
|
||||
placeholder="SBER"
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
width: 120,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, marginBottom: 4 }}>
|
||||
Количество
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={newQty}
|
||||
onChange={(e) => setNewQty(e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
width: 100,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddPosition}
|
||||
disabled={addPosition.isPending}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SharePositionTable
|
||||
positions={portfolio.positions.filter((p) => p.type === 'share')}
|
||||
onUpdatePosition={(positionId, data) => updatePosition.mutate({ positionId, data })}
|
||||
onDeletePosition={(positionId) => {
|
||||
if (window.confirm('Удалить позицию?')) removePosition.mutate(positionId);
|
||||
}}
|
||||
/>
|
||||
|
||||
<BondPositionTable
|
||||
positions={portfolio.positions.filter((p) => p.type === 'bond')}
|
||||
onUpdatePosition={(positionId, data) => updatePosition.mutate({ positionId, data })}
|
||||
onDeletePosition={(positionId) => {
|
||||
if (window.confirm('Удалить позицию?')) removePosition.mutate(positionId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
apps/frontend/src/pages/portfolios/PortfoliosListPage.tsx
Normal file
95
apps/frontend/src/pages/portfolios/PortfoliosListPage.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import { usePortfolios } from '../../hooks/usePortfolios';
|
||||
import { usePortfolioMutations } from '../../hooks/usePortfolioMutations';
|
||||
import { PortfolioCard } from '../../components/portfolios/PortfolioCard';
|
||||
import { PortfolioForm } from '../../components/portfolios/PortfolioForm';
|
||||
|
||||
export function PortfoliosListPage() {
|
||||
const { data: portfolios, isLoading, error } = usePortfolios();
|
||||
const { create } = usePortfolioMutations();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--color-text-secondary)' }}>
|
||||
Загрузка...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: 40, textAlign: 'center', color: '#e53935' }}>
|
||||
Ошибка загрузки портфелей
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 700 }}>Мои портфели</h1>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
+ Создать портфель
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
padding: 20,
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: '0 0 16px', fontSize: 16, fontWeight: 600 }}>Новый портфель</h3>
|
||||
<PortfolioForm
|
||||
onSave={(data) => create.mutate(data)}
|
||||
onCancel={() => setShowForm(false)}
|
||||
isLoading={create.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!portfolios || portfolios.length === 0 ? (
|
||||
<div style={{ padding: 60, textAlign: 'center', color: 'var(--color-text-secondary)' }}>
|
||||
<p style={{ fontSize: 16, marginBottom: 8 }}>У вас пока нет портфелей</p>
|
||||
<p style={{ fontSize: 14 }}>Создайте первый, чтобы начать отслеживать свои инвестиции</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gap: 16,
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
|
||||
}}
|
||||
>
|
||||
{portfolios.map((p) => (
|
||||
<PortfolioCard key={p.id} portfolio={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -7,6 +7,8 @@ import { LoginPage } from './pages/LoginPage';
|
||||
import { RegisterPage } from './pages/RegisterPage';
|
||||
import { ProfilePage } from './pages/ProfilePage';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
|
||||
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
@ -25,6 +27,22 @@ export function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/portfolios"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PortfoliosListPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/portfolios/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PortfolioDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
1615
docs/superpowers/plans/2026-06-14-portfolio-phase1.md
Normal file
1615
docs/superpowers/plans/2026-06-14-portfolio-phase1.md
Normal file
File diff suppressed because it is too large
Load Diff
439
docs/superpowers/specs/2026-06-14-portfolio-design.md
Normal file
439
docs/superpowers/specs/2026-06-14-portfolio-design.md
Normal file
@ -0,0 +1,439 @@
|
||||
# Portfolio — Design Specification (SDD)
|
||||
|
||||
**Date:** 2026-06-14
|
||||
**Status:** Draft
|
||||
**Author:** AI Assistant
|
||||
|
||||
---
|
||||
|
||||
## 1. Product Requirements Document (PRD)
|
||||
|
||||
### 1.1 Product Vision
|
||||
|
||||
Добавить в MoexVibe возможность создания и управления инвестиционными портфелями-слежения. Пользователь может создавать виртуальные портфели, добавлять в них позиции по ценным бумагам MOEX, указывать количество, теги и заметки, а также задавать целевое распределение капитала. Система отображает текущую стоимость портфеля на основе рыночных данных MOEX и отклонение от целевого распределения.
|
||||
|
||||
### 1.2 Target Audience
|
||||
|
||||
Текущие пользователи MoexVibe — частные инвесторы, интересующиеся российским фондовым рынком. Функция портфелей ориентирована на пользователей, которые хотят отслеживать состав и структуру своих вложений без привязки к реальному брокерскому счёту.
|
||||
|
||||
### 1.3 Scope
|
||||
|
||||
| In Scope (Phase 1) | Out of Scope (Future Phases) |
|
||||
|---|---|
|
||||
| CRUD портфелей (название, описание, валюта) | История транзакций (buy/sell) |
|
||||
| CRUD позиций (secid, количество, теги, заметки) | P&L и налоговая отчётность |
|
||||
| Текущая стоимость портфеля (из MOEX) | График стоимости портфеля во времени |
|
||||
| Целевое распределение (% на позицию) | XIRR / доходность |
|
||||
| Отклонение от целевого распределения | Дивидендный календарь / прогноз купонов |
|
||||
| Предопределённые теги | Корпоративные действия (сплиты и т.д.) |
|
||||
| Аутентификация через существующую JWT-систему | Экспорт / импорт портфеля |
|
||||
|
||||
### 1.4 User Stories
|
||||
|
||||
- US-PF-001: Пользователь создаёт портфель с названием, описанием и валютой
|
||||
- US-PF-002: Пользователь видит список своих портфелей
|
||||
- US-PF-003: Пользователь открывает портфель и видит таблицу позиций с текущими ценами и стоимостью
|
||||
- US-PF-004: Пользователь добавляет бумагу в портфель, выбирая её через поиск
|
||||
- US-PF-005: Пользователь редактирует количество бумаги в позиции (inline)
|
||||
- US-PF-006: Пользователь удаляет позицию из портфеля
|
||||
- US-PF-007: Пользователь задаёт целевой процент для каждой позиции
|
||||
- US-PF-008: Пользователь видит отклонение фактического распределения от целевого
|
||||
- US-PF-009: Пользователь помечает позиции тегами (DIVIDEND, GROWTH, и т.д.)
|
||||
- US-PF-010: Пользователь добавляет текстовую заметку к позиции
|
||||
- US-PF-011: Пользователь удаляет портфель со всеми позициями
|
||||
|
||||
### 1.5 Non-Functional Requirements
|
||||
|
||||
- Цены позиций загружаются через существующий `MoexClientService` с кешированием (TTL: 900s)
|
||||
- Страница портфеля отображает данные менее чем за 1 секунду (с кешем)
|
||||
- Оптимистичные обновления при изменении количества позиции (TanStack Query mutation)
|
||||
- При недоступности MOEX цена показывается как `—` с индикатором stale
|
||||
|
||||
---
|
||||
|
||||
## 2. Domain Model
|
||||
|
||||
```
|
||||
Portfolio {
|
||||
id: Int
|
||||
userId: Int
|
||||
name: String
|
||||
description: String (optional)
|
||||
currency: Currency (default: RUB)
|
||||
targets: TargetAllocation[] (optional, JSON)
|
||||
createdAt: DateTime
|
||||
updatedAt: DateTime
|
||||
}
|
||||
|
||||
Position {
|
||||
id: Int
|
||||
portfolioId: Int
|
||||
secid: String — MOEX security ID (e.g. "SBER")
|
||||
quantity: Int — количество бумаг (>= 0)
|
||||
notes: String (optional, free text)
|
||||
tags: Tag[] (optional, JSON)
|
||||
createdAt: DateTime
|
||||
updatedAt: DateTime
|
||||
|
||||
// Computed at read time:
|
||||
currentPrice: number — из MOEX marketdata
|
||||
currentValue: number — quantity * currentPrice
|
||||
weightPercent: number — currentValue / totalValue * 100
|
||||
targetPercent: number — из Portfolio.targets
|
||||
deviation: number — weightPercent - targetPercent
|
||||
}
|
||||
|
||||
enum Currency {
|
||||
RUB, USD, EUR, CNY, KZT, BYN
|
||||
}
|
||||
|
||||
enum Tag {
|
||||
DIVIDEND, GROWTH, DEFENSIVE, SPECULATIVE,
|
||||
BOND, ETF, GOVERNMENT, CASH
|
||||
}
|
||||
|
||||
TargetAllocation {
|
||||
secid: String
|
||||
targetPercent: number // 0-100
|
||||
// sum(targetPercent) across all positions = 100
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 Backend
|
||||
|
||||
```
|
||||
PortfolioModule
|
||||
├── PortfolioController — /api/v1/portfolios
|
||||
├── PortfolioService — бизнес-логика
|
||||
├── dto/
|
||||
│ ├── create-portfolio.dto.ts
|
||||
│ ├── update-portfolio.dto.ts
|
||||
│ ├── portfolio-response.dto.ts
|
||||
│ ├── add-position.dto.ts
|
||||
│ ├── update-position.dto.ts
|
||||
│ └── position-response.dto.ts
|
||||
└── portfolio.module.ts
|
||||
```
|
||||
|
||||
- `PortfolioService` использует существующий `MoexClientService` для получения цен
|
||||
- `PortfolioService` использует `PrismaService` для доступа к БД
|
||||
- Все эндпоинты защищены `JwtAuthGuard` (глобальный guard) — только аутентифицированные пользователи
|
||||
- Ответы обёрнуты в `ApiEnvelope<T>`: `{ data: T, meta: { fromCache, cachedAt } }`
|
||||
|
||||
### 3.2 Frontend
|
||||
|
||||
```
|
||||
src/pages/portfolios/
|
||||
├── PortfoliosListPage.tsx — /portfolios — список портфелей
|
||||
└── PortfolioDetailPage.tsx — /portfolios/:id — детали + позиции
|
||||
|
||||
src/hooks/
|
||||
├── usePortfolios.ts
|
||||
├── usePortfolio.ts
|
||||
├── usePortfolioMutations.ts
|
||||
└── usePositionMutations.ts
|
||||
|
||||
src/components/portfolios/
|
||||
├── PortfolioCard.tsx
|
||||
├── PortfolioForm.tsx — create/edit form
|
||||
├── PositionTable.tsx — редактируемая таблица
|
||||
├── PositionRow.tsx — строка с inline edit qty
|
||||
├── PortfolioSummary.tsx — total + distribution
|
||||
├── TargetAllocationEditor.tsx
|
||||
└── TagBadge.tsx
|
||||
|
||||
src/api/portfolio.ts — API client functions
|
||||
```
|
||||
|
||||
- TanStack Query stale time: 900s (как у stock/bond)
|
||||
- Optimistic updates при изменении количества позиции
|
||||
- Существующий Layout + ProtectedRoute для страниц портфелей
|
||||
|
||||
### 3.3 Роутинг
|
||||
|
||||
```tsx
|
||||
<Route path="/portfolios" element={<ProtectedRoute><PortfoliosListPage /></ProtectedRoute>} />
|
||||
<Route path="/portfolios/:id" element={<ProtectedRoute><PortfolioDetailPage /></ProtectedRoute>} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API Contracts
|
||||
|
||||
### 4.1 Portfolio CRUD
|
||||
|
||||
```
|
||||
GET /api/v1/portfolios
|
||||
Response: { data: Portfolio[], meta: { fromCache, cachedAt } }
|
||||
|
||||
POST /api/v1/portfolios
|
||||
Body: { name, description?, currency? }
|
||||
Response: { data: Portfolio, meta: ... }
|
||||
|
||||
GET /api/v1/portfolios/:id
|
||||
Response: { data: PortfolioDetail, meta: ... }
|
||||
// PortfolioDetail = Portfolio + positions[] (with computed prices)
|
||||
|
||||
PATCH /api/v1/portfolios/:id
|
||||
Body: { name?, description?, currency?, targets? }
|
||||
Response: { data: Portfolio, meta: ... }
|
||||
|
||||
DELETE /api/v1/portfolios/:id
|
||||
Response: { data: null, meta: ... }
|
||||
```
|
||||
|
||||
### 4.2 Position CRUD
|
||||
|
||||
```
|
||||
GET /api/v1/portfolios/:id/positions
|
||||
Response: { data: Position[], meta: ... }
|
||||
|
||||
POST /api/v1/portfolios/:id/positions
|
||||
Body: { secid, quantity, notes?, tags? }
|
||||
Response: { data: Position, meta: ... }
|
||||
|
||||
PATCH /api/v1/portfolios/:id/positions/:posId
|
||||
Body: { quantity?, notes?, tags? }
|
||||
Response: { data: Position, meta: ... }
|
||||
|
||||
DELETE /api/v1/portfolios/:id/positions/:posId
|
||||
Response: { data: null, meta: ... }
|
||||
```
|
||||
|
||||
### 4.3 Response Types (Swagger DTOs)
|
||||
|
||||
```typescript
|
||||
class PortfolioResponseDto {
|
||||
@ApiProperty() id: number;
|
||||
@ApiProperty() name: string;
|
||||
@ApiPropertyOptional() description: string;
|
||||
@ApiProperty({ default: 'RUB' }) currency: string;
|
||||
@ApiPropertyOptional() targets: TargetAllocationDto[];
|
||||
@ApiProperty() createdAt: string;
|
||||
@ApiProperty() updatedAt: string;
|
||||
}
|
||||
|
||||
class PortfolioDetailResponseDto extends PortfolioResponseDto {
|
||||
@ApiProperty({ type: [PositionWithPriceDto] })
|
||||
positions: PositionWithPriceDto[];
|
||||
@ApiProperty() totalValue: number;
|
||||
}
|
||||
|
||||
class PositionWithPriceDto {
|
||||
@ApiProperty() id: number;
|
||||
@ApiProperty() secid: string;
|
||||
@ApiProperty() quantity: number;
|
||||
@ApiPropertyOptional() notes: string;
|
||||
@ApiPropertyOptional() tags: string[];
|
||||
@ApiProperty() currentPrice: number | null;
|
||||
@ApiProperty() currentValue: number | null;
|
||||
@ApiProperty() weightPercent: number;
|
||||
@ApiProperty() targetPercent: number | null;
|
||||
@ApiProperty() deviation: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Database Schema (Prisma)
|
||||
|
||||
```prisma
|
||||
model Portfolio {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
name String
|
||||
description String?
|
||||
currency String @default("RUB")
|
||||
targets String? // JSON: [{ secid: "SBER", targetPercent: 30 }]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
positions Position[]
|
||||
|
||||
@@unique([userId, name]) // уникальное имя в рамках пользователя
|
||||
}
|
||||
|
||||
model Position {
|
||||
id Int @id @default(autoincrement())
|
||||
portfolioId Int
|
||||
secid String
|
||||
quantity Int
|
||||
notes String?
|
||||
tags String? // JSON: ["DIVIDEND", "GROWTH"]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([portfolioId, secid]) // один secid — одна позиция в портфеле
|
||||
}
|
||||
```
|
||||
|
||||
**Миграция:** `npx prisma migrate dev --name add-portfolio-position`
|
||||
|
||||
---
|
||||
|
||||
## 6. Business Rules
|
||||
|
||||
| Rule | Описание |
|
||||
|---|---|
|
||||
| BR-001 | `quantity >= 0` — отрицательное количество запрещено |
|
||||
| BR-002 | `quantity = 0` — автоматическое удаление позиции (best-effort) |
|
||||
| BR-003 | `sum(targets.targetPercent)` должна быть 100 (на стороне backend при PATCH portfolio) |
|
||||
| BR-004 | `tags` — только значения из enum `Tag` |
|
||||
| BR-005 | `secid` должен существовать в MOEX (проверка через MoexClient при POST position) |
|
||||
| BR-006 | `currency` — только из enum `Currency` |
|
||||
| BR-007 | Один `secid` может быть только в одной позиции внутри портфеля (unique constraint) |
|
||||
| BR-008 | Только владелец может видеть/редактировать портфель (проверка `userId`) |
|
||||
| BR-009 | При удалении портфеля удаляются все его позиции (cascade) |
|
||||
|
||||
---
|
||||
|
||||
## 7. Events (Domain Events)
|
||||
|
||||
Phase 1 не требует событийной шины. События документируются для будущих фаз:
|
||||
|
||||
| Событие | Payload | Когда происходит | Будущее использование |
|
||||
|---|---|---|---|
|
||||
| `PortfolioCreated` | `{ portfolioId, userId }` | POST portfolio | Аудит, уведомления |
|
||||
| `PortfolioDeleted` | `{ portfolioId, userId }` | DELETE portfolio | Очистка связанных данных |
|
||||
| `PositionAdded` | `{ portfolioId, secid, quantity }` | POST position | Триггер аналитики |
|
||||
| `PositionQuantityChanged` | `{ portfolioId, secid, oldQty, newQty }` | PATCH position | Snapshot для графиков |
|
||||
| `PositionRemoved` | `{ portfolioId, secid }` | DELETE position | Фиксация P&L |
|
||||
|
||||
---
|
||||
|
||||
## 8. Risks and Edge Cases
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|---|---|---|
|
||||
| **MOEX недоступен** | Цены не загружаются | Показывать `—` + stale indicator, не блокировать CRUD |
|
||||
| **SECID удалён из MOEX** | Цена = N/A | Position остаётся, currentPrice = null |
|
||||
| **Очень много позиций** | Производительность | Backend: пагинация positions (Phase 2). Сейчас ~50 позиций не проблема |
|
||||
| **Параллельное редактирование** | Lost update | Пока ignored (Single user per portfolio). Фаза 2: `updatedAt` optimistic locking |
|
||||
| **Target sum ≠ 100** | Некорректное распределение | Backend validation при сохранении |
|
||||
| **Смена валюты портфеля** | Стоимость в разной валюте | Пока только переименование. Конвертация — Phase 3 |
|
||||
| **Некорректный secid** | Ошибка при добавлении | Проверка существования в MOEX при POST, возвращать 422 |
|
||||
| **Удаление пользователя** | Потеря портфелей | Cascade delete, ok |
|
||||
| **Очень большое количество** | `quantity` Int overflow | Использовать `BigInt` при необходимости (сейчас Int до ~2B) |
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation Phases
|
||||
|
||||
### Phase 1: Portfolio + Position CRUD
|
||||
|
||||
**Backend:**
|
||||
- Prisma: добавить модели `Portfolio` и `Position`, запустить миграцию
|
||||
- Создать `PortfolioModule` с `PortfolioController`, `PortfolioService`
|
||||
- DTO: create/update portfolio, add/update/position, response
|
||||
- CRUD эндпоинты для портфелей и позиций
|
||||
- Интеграция с `MoexClientService` для получения текущей цены (`getShareMarketData` / `getBondMarketData`)
|
||||
- Расчёт computed полей (currentValue, weightPercent, deviation)
|
||||
- Валидация бизнес-правил (BR-001 — BR-009)
|
||||
|
||||
**Frontend:**
|
||||
- API client functions в `src/api/portfolio.ts`
|
||||
- Hooks: `usePortfolios`, `usePortfolio`, `usePortfolioMutations`, `usePositionMutations`
|
||||
- Страница `/portfolios` — список портфелей, кнопка создать
|
||||
- Страница `/portfolios/:id` — детали портфеля + таблица позиций
|
||||
- `PortfolioForm` (create/edit modal)
|
||||
- `PositionRow` с inline editing количества
|
||||
- `TargetAllocationEditor` — числовой ввод процентов
|
||||
- `PortfolioSummary` — общая стоимость + pie chart распределения
|
||||
- Добавить ссылку в навигацию (Layout)
|
||||
|
||||
**Test:**
|
||||
- Backend: unit-тесты `PortfolioService` (CRUD, валидация, расчёты)
|
||||
- Backend: e2e-тесты эндпоинтов портфелей
|
||||
|
||||
### Phase 2: Positions — расширение
|
||||
|
||||
- Target allocation editor с drag-to-set
|
||||
- Pie chart визуализация (Chart.js или lightweight-charts)
|
||||
- Отклонение от цели: цветовая индикация (зелёный/жёлтый/красный)
|
||||
- Фильтрация и сортировка позиций в таблице
|
||||
- Группировка по тегам
|
||||
|
||||
### Phase 3: Transactions (опционально)
|
||||
|
||||
- Модель `Transaction` (type: BUY/SELL, date, price, quantity, commission)
|
||||
- История операций по позиции
|
||||
- P&L по закрытым позициям
|
||||
- Средняя цена входа и общая доходность
|
||||
|
||||
### Phase 4: Analytics
|
||||
|
||||
- Snapshot текущей стоимости по дням (cron + таблица `PortfolioSnapshot`)
|
||||
- График стоимости портфеля во времени
|
||||
- Сравнение с бенчмарком (IMOEX)
|
||||
- Доходность (простая, XIRR)
|
||||
|
||||
### Phase 5: Dividend and Coupon Forecasts
|
||||
|
||||
- Использовать `SharesService.getDividends` / `BondsService.getCoupons`
|
||||
- Агрегировать предстоящие выплаты по позициям
|
||||
- Календарь выплат на ближайшие 12 месяцев
|
||||
- Прогнозный доход к портфелю
|
||||
|
||||
### Phase 6: Corporate Actions
|
||||
|
||||
- Модель `CorporateAction` (type, secid, date, ratio)
|
||||
- Автокорректировка количества позиций при сплитах/консолидациях
|
||||
- Обработка допэмиссий
|
||||
- Уведомления о грядущих корпоративных действиях
|
||||
|
||||
---
|
||||
|
||||
## 10. OpenAPI Specification
|
||||
|
||||
Дополнение к существующему `docs/openapi/openapi.yaml` — новые эндпоинты и схемы для Portfolio и Position.
|
||||
|
||||
---
|
||||
|
||||
## 11. ADR
|
||||
|
||||
### ADR-009: Portfolio Domain Model
|
||||
|
||||
**Context:** Выбор между SQL-моделью и document store для хранения портфелей.
|
||||
|
||||
**Decision:** SQL через Prisma (существующая БД). Portfolio и Position — отдельные таблицы. Targets и Tags — JSON-поля, т.к.:
|
||||
- SQLite поддерживает JSON
|
||||
- Нет необходимости в join по тегам (максимум 50 позиций на портфель)
|
||||
- Миграция на PostgreSQL в будущем не потребует изменений схемы
|
||||
|
||||
**Consequences:**
|
||||
- Нельзя делать SQL-запросы по тегам (не нужно для Phase 1)
|
||||
- Targets редактируются целиком (замена JSON), что достаточно для сценария
|
||||
|
||||
### ADR-010: Backend Price Computation
|
||||
|
||||
**Context:** Где вычислять текущую стоимость портфеля — на backend или frontend?
|
||||
|
||||
**Decision:** На backend. `GET /portfolios/:id` возвращает `PortfolioDetailResponseDto` с computed полями (`currentPrice`, `currentValue`, `weightPercent`, `deviation`).
|
||||
|
||||
**Rationale:**
|
||||
- Единый источник правды
|
||||
- Frontend получает готовые данные для отображения
|
||||
- Можно кешировать computed response
|
||||
|
||||
**Consequences:**
|
||||
- Backend делает N запросов к MOEX (по числу уникальных secid)
|
||||
- Используется in-memory cache для цен (900s TTL)
|
||||
|
||||
---
|
||||
|
||||
## 12. Self-Review Checklist
|
||||
|
||||
- [x] Нет placeholder'ов (TBD, TODO)
|
||||
- [x] Все разделы заполнены
|
||||
- [x] Терминология согласована (Portfolio/Position, не "watchlist"/"holding")
|
||||
- [x] API контракты полны (CRUD для обоих ресурсов)
|
||||
- [x] Business rules однозначны
|
||||
- [x] Scope фазы 1 чётко отделён от будущих фаз
|
||||
- [x] ADR документируют ключевые решения
|
||||
Loading…
x
Reference in New Issue
Block a user