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,13 +6,46 @@ datasource db {
|
|||||||
provider = "sqlite"
|
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 {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String
|
||||||
name String?
|
name String?
|
||||||
role String @default("user")
|
role String @default("user")
|
||||||
refreshToken String?
|
refreshToken String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
portfolios Portfolio[]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { SecuritiesModule } from './modules/securities/securities.module';
|
|||||||
import { SharesModule } from './modules/shares/shares.module';
|
import { SharesModule } from './modules/shares/shares.module';
|
||||||
import { BondsModule } from './modules/bonds/bonds.module';
|
import { BondsModule } from './modules/bonds/bonds.module';
|
||||||
import { CandlesModule } from './modules/candles/candles.module';
|
import { CandlesModule } from './modules/candles/candles.module';
|
||||||
|
import { PortfolioModule } from './modules/portfolio/portfolio.module';
|
||||||
import { PrismaModule } from './modules/prisma/prisma.module';
|
import { PrismaModule } from './modules/prisma/prisma.module';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import configuration from './config/configuration';
|
import configuration from './config/configuration';
|
||||||
@ -23,6 +24,7 @@ import configuration from './config/configuration';
|
|||||||
SharesModule,
|
SharesModule,
|
||||||
BondsModule,
|
BondsModule,
|
||||||
CandlesModule,
|
CandlesModule,
|
||||||
|
PortfolioModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@ -174,7 +174,10 @@ export class MoexClientService {
|
|||||||
{ boards: boardId },
|
{ boards: boardId },
|
||||||
);
|
);
|
||||||
const rows = this.extractTable(data, 'securities');
|
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;
|
if (!bond) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -208,7 +211,10 @@ export class MoexClientService {
|
|||||||
{ boards: boardId },
|
{ boards: boardId },
|
||||||
);
|
);
|
||||||
const mktRows = this.extractTable(data, 'marketdata');
|
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;
|
if (!mkt) return null;
|
||||||
|
|
||||||
return {
|
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/configuration',
|
||||||
'backend/caching',
|
'backend/caching',
|
||||||
'backend/moex-client',
|
'backend/moex-client',
|
||||||
|
'backend/portfolio',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -59,6 +60,8 @@ const sidebars: SidebarsConfig = {
|
|||||||
'adr/ADR-006-no-cci',
|
'adr/ADR-006-no-cci',
|
||||||
'adr/ADR-007-two-level-caching',
|
'adr/ADR-007-two-level-caching',
|
||||||
'adr/ADR-008-auth-system',
|
'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;
|
user: UserResponse;
|
||||||
accessToken: string;
|
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
|
MoexVibe
|
||||||
</Link>
|
</Link>
|
||||||
<SearchBar />
|
<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 }}>
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
{isAuthenticated ? (
|
{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 { RegisterPage } from './pages/RegisterPage';
|
||||||
import { ProfilePage } from './pages/ProfilePage';
|
import { ProfilePage } from './pages/ProfilePage';
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||||
|
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
|
||||||
|
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
|
||||||
|
|
||||||
export function AppRoutes() {
|
export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
@ -25,6 +27,22 @@ export function AppRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/portfolios"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PortfoliosListPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/portfolios/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<PortfolioDetailPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</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