feat: управление портфелями с разделением акций и облигаций #7

Merged
ksv741 merged 2 commits from feat/portfolio-phase1 into main 2026-06-14 11:17:20 +03:00
37 changed files with 4360 additions and 7 deletions
Showing only changes of commit a980520261 - Show all commits

View File

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

View File

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

View File

@ -6,13 +6,46 @@ datasource db {
provider = "sqlite"
}
model Portfolio {
id Int @id @default(autoincrement())
userId Int
name String
description String?
currency String @default("RUB")
targets String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
positions Position[]
@@unique([userId, name])
}
model Position {
id Int @id @default(autoincrement())
portfolioId Int
secid String
type String @default("share")
quantity Int
notes String?
tags String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
@@unique([portfolioId, secid])
}
model User {
id Int @id @default(autoincrement())
email String @unique
id Int @id @default(autoincrement())
email String @unique
password String
name String?
role String @default("user")
role String @default("user")
refreshToken String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
portfolios Portfolio[]
}

View File

@ -7,6 +7,7 @@ import { SecuritiesModule } from './modules/securities/securities.module';
import { SharesModule } from './modules/shares/shares.module';
import { BondsModule } from './modules/bonds/bonds.module';
import { CandlesModule } from './modules/candles/candles.module';
import { PortfolioModule } from './modules/portfolio/portfolio.module';
import { PrismaModule } from './modules/prisma/prisma.module';
import { AuthModule } from './modules/auth/auth.module';
import configuration from './config/configuration';
@ -23,6 +24,7 @@ import configuration from './config/configuration';
SharesModule,
BondsModule,
CandlesModule,
PortfolioModule,
],
})
export class AppModule {}

View File

@ -174,7 +174,10 @@ export class MoexClientService {
{ boards: boardId },
);
const rows = this.extractTable(data, 'securities');
const bond = rows.find((r) => r.BOARDID === boardId) || rows[0];
const bond =
rows.find((r) => r.BOARDID === boardId && r.PREVWAPRICE != null) ||
rows.find((r) => r.PREVWAPRICE != null) ||
rows[0];
if (!bond) return null;
return {
@ -208,7 +211,10 @@ export class MoexClientService {
{ boards: boardId },
);
const mktRows = this.extractTable(data, 'marketdata');
const mkt = mktRows.find((r) => r.SECID === secid);
const mkt =
mktRows.find((r) => r.BOARDID === boardId && r.LAST != null) ||
mktRows.find((r) => r.LAST != null) ||
mktRows.find((r) => r.SECID === secid);
if (!mkt) return null;
return {

View 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[];
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View 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,
};
}
}
}

View 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

View 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

View 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

View File

@ -17,6 +17,7 @@ const sidebars: SidebarsConfig = {
'backend/configuration',
'backend/caching',
'backend/moex-client',
'backend/portfolio',
],
},
{
@ -59,6 +60,8 @@ const sidebars: SidebarsConfig = {
'adr/ADR-006-no-cci',
'adr/ADR-007-two-level-caching',
'adr/ADR-008-auth-system',
'adr/ADR-009-portfolio-domain',
'adr/ADR-010-backend-price-computation',
],
},
],

View 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',
});
}

View File

@ -134,3 +134,55 @@ export interface AuthResponse {
user: UserResponse;
accessToken: string;
}
export interface Portfolio {
id: number;
name: string;
description: string | null;
currency: string;
createdAt: string;
updatedAt: string;
}
export interface PositionWithPrice {
id: number;
secid: string;
shortName: string | null;
type: 'share' | 'bond';
quantity: number;
notes: string | null;
tags: string[] | null;
currentPrice: number | null;
currentValue: number | null;
weightPercent: number;
change?: number | null;
changePercent?: number | null;
yieldToMaturity?: number | null;
duration?: number | null;
couponValue?: number | null;
couponPercent?: number | null;
nextCouponDate?: string | null;
matDate?: string | null;
accruedInt?: number | null;
bid?: number | null;
offer?: number | null;
couponPeriod?: number | null;
bondType?: string | null;
offerDate?: string | null;
}
export interface PortfolioDetail extends Portfolio {
positions: PositionWithPrice[];
totalValue: number;
}
export interface Position {
id: number;
secid: string;
quantity: number;
notes: string | null;
tags: string[] | null;
portfolioId: number;
createdAt: string;
updatedAt: string;
}

View File

@ -35,6 +35,17 @@ export function Layout() {
MoexVibe
</Link>
<SearchBar />
<Link
to="/portfolios"
style={{
fontSize: 14,
color: 'var(--color-text)',
textDecoration: 'none',
fontWeight: 500,
}}
>
Портфели
</Link>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 12 }}>
{isAuthenticated ? (
<>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
});
}

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

View 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,
});
}

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

View 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>
);
}

View 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>
);
}

View File

@ -7,6 +7,8 @@ import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { ProfilePage } from './pages/ProfilePage';
import { ProtectedRoute } from './components/ProtectedRoute';
import { PortfoliosListPage } from './pages/portfolios/PortfoliosListPage';
import { PortfolioDetailPage } from './pages/portfolios/PortfolioDetailPage';
export function AppRoutes() {
return (
@ -25,6 +27,22 @@ export function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/portfolios"
element={
<ProtectedRoute>
<PortfoliosListPage />
</ProtectedRoute>
}
/>
<Route
path="/portfolios/:id"
element={
<ProtectedRoute>
<PortfolioDetailPage />
</ProtectedRoute>
}
/>
</Route>
</Routes>
);

File diff suppressed because it is too large Load Diff

View 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 документируют ключевые решения