From ca4a8b35f320b64a1def9b5d274e3fe3cfaab99e Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 14 Jun 2026 09:22:02 +0300 Subject: [PATCH 1/2] fix: null safety for market data components and backend CI --- apps/backend/package.json | 3 ++- apps/frontend/src/api/responses.ts | 14 +++++++------- apps/frontend/src/components/BondDetails.tsx | 8 +++++--- apps/frontend/src/components/StockDetails.tsx | 8 ++++---- package-lock.json | 1 + 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index 292b1c5..1d8a57e 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -3,11 +3,12 @@ "version": "0.0.1", "private": true, "scripts": { + "postinstall": "prisma generate", "build": "nest build", "start:dev": "nest start --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,test}/**/*.ts\"", - "test": "vitest run", + "test": "VITE_CJS_IGNORE_WARNING=1 vitest run", "test:watch": "vitest" }, "dependencies": { diff --git a/apps/frontend/src/api/responses.ts b/apps/frontend/src/api/responses.ts index a7da5ea..c2da5c1 100644 --- a/apps/frontend/src/api/responses.ts +++ b/apps/frontend/src/api/responses.ts @@ -9,10 +9,10 @@ export interface ApiEnvelope { } export interface StockMarketData { - price: number; - change: number; - changePercent: number; - open: number; + price: number | null; + change: number | null; + changePercent: number | null; + open: number | null; high: number | null; low: number | null; volume: number; @@ -52,11 +52,11 @@ export interface ShareHistoryItem { } export interface BondMarketData { - price: number; + price: number | null; yieldToMaturity: number | null; duration: number | null; - accruedInt: number; - couponValue: number; + accruedInt: number | null; + couponValue: number | null; couponPercent: number | null; nextCouponDate: string | null; open: number; diff --git a/apps/frontend/src/components/BondDetails.tsx b/apps/frontend/src/components/BondDetails.tsx index ae3cf9f..07f8f90 100644 --- a/apps/frontend/src/components/BondDetails.tsx +++ b/apps/frontend/src/components/BondDetails.tsx @@ -28,7 +28,9 @@ export function BondDetails({ bond }: BondDetailsProps) {
{bond.isin}
-
{md.price.toFixed(2)}%
+
+ {md.price?.toFixed(2) ?? '—'}% +
@@ -44,7 +46,7 @@ export function BondDetails({ bond }: BondDetailsProps) {
Купон - {md.couponValue} ₽ {md.couponPercent != null ? `(${md.couponPercent}%)` : ''} + {md.couponValue ?? '—'} ₽ {md.couponPercent != null ? `(${md.couponPercent}%)` : ''}
@@ -57,7 +59,7 @@ export function BondDetails({ bond }: BondDetailsProps) {
НКД - {md.accruedInt.toFixed(2)} ₽ + {md.accruedInt?.toFixed(2) ?? '—'} ₽
Доходность к погашению diff --git a/apps/frontend/src/components/StockDetails.tsx b/apps/frontend/src/components/StockDetails.tsx index 2f4a177..0d4ba4b 100644 --- a/apps/frontend/src/components/StockDetails.tsx +++ b/apps/frontend/src/components/StockDetails.tsx @@ -13,7 +13,7 @@ const rowStyle: React.CSSProperties = { export function StockDetails({ stock }: StockDetailsProps) { const md = stock.marketData; - const isPositive = md.change >= 0; + const isPositive = (md.change ?? 0) >= 0; return (
- {md.price.toLocaleString('ru-RU', { minimumFractionDigits: 2 })}{' '} + {md.price?.toLocaleString('ru-RU', { minimumFractionDigits: 2 }) ?? '—'}{' '} {isPositive ? '+' : ''} - {md.change.toFixed(2)} ({md.changePercent.toFixed(2)}%) + {(md.change ?? 0).toFixed(2)} ({(md.changePercent ?? 0).toFixed(2)}%)
Открытие - {md.open.toFixed(2)} + {md.open?.toFixed(2) ?? '—'}
Максимум diff --git a/package-lock.json b/package-lock.json index cdbe94c..ab095fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "apps/backend": { "name": "@moex-vibe/backend", "version": "0.0.1", + "hasInstallScript": true, "dependencies": { "@libsql/client": "^0.17.3", "@nestjs/axios": "^3.0.0", -- 2.47.2 From a980520261fd9fab9c4a667e53fb3a0002213179 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 14 Jun 2026 11:14:04 +0300 Subject: [PATCH 2/2] feat: portfolio management with share/bond separation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Portfolio + Position models (Prisma + migrations) - Backend: PortfolioModule with CRUD, enrichment, type detection - Backend: enrichBondPosition returns 13 financial fields (YTM, duration, coupon, NCD, accrued interest, bid/offer, bondType, offerDate, etc.) - Frontend: portfolio pages, 4 TanStack Query hooks, split share/bond tables - Fix: MOEX bond marketdata board fallback (TQCB → TQOB for OFZ) - Frontend: clickable ticker links to /stocks/:secid and /bonds/:secid - Remove: target allocation, deviation, tags display from Phase 1 - Docs: ADR-009 (domain model), ADR-010 (price computation), portfolio backend doc, superpowers spec + plan --- .../migration.sql | 31 + .../migration.sql | 21 + apps/backend/prisma/schema.prisma | 43 +- apps/backend/src/app.module.ts | 2 + .../moex-client/moex-client.service.ts | 10 +- .../modules/portfolio/dto/add-position.dto.ts | 47 + .../portfolio/dto/create-portfolio.dto.ts | 24 + .../portfolio/dto/portfolio-response.dto.ts | 43 + .../portfolio/dto/position-response.dto.ts | 12 + .../portfolio/dto/update-portfolio.dto.ts | 25 + .../portfolio/dto/update-position.dto.ts | 33 + .../modules/portfolio/portfolio.controller.ts | 88 + .../src/modules/portfolio/portfolio.module.ts | 10 + .../modules/portfolio/portfolio.service.ts | 345 ++++ .../docs/docs/adr/ADR-009-portfolio-domain.md | 27 + .../adr/ADR-010-backend-price-computation.md | 26 + apps/docs/docs/backend/portfolio.md | 92 + apps/docs/sidebars.ts | 3 + apps/frontend/src/api/portfolio.ts | 74 + apps/frontend/src/api/responses.ts | 52 + apps/frontend/src/components/Layout.tsx | 11 + .../components/portfolios/BondPositionRow.tsx | 137 ++ .../portfolios/BondPositionTable.tsx | 226 +++ .../components/portfolios/PortfolioCard.tsx | 39 + .../components/portfolios/PortfolioForm.tsx | 119 ++ .../portfolios/PortfolioSummary.tsx | 44 + .../portfolios/SharePositionRow.tsx | 113 ++ .../portfolios/SharePositionTable.tsx | 116 ++ apps/frontend/src/hooks/usePortfolio.ts | 17 + .../src/hooks/usePortfolioMutations.ts | 45 + apps/frontend/src/hooks/usePortfolios.ts | 16 + .../src/hooks/usePositionMutations.ts | 61 + .../pages/portfolios/PortfolioDetailPage.tsx | 248 +++ .../pages/portfolios/PortfoliosListPage.tsx | 95 + apps/frontend/src/routes.tsx | 18 + .../plans/2026-06-14-portfolio-phase1.md | 1615 +++++++++++++++++ .../specs/2026-06-14-portfolio-design.md | 439 +++++ 37 files changed, 4360 insertions(+), 7 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260614071935_add_portfolio_position/migration.sql create mode 100644 apps/backend/prisma/migrations/20260614073903_add_position_type/migration.sql create mode 100644 apps/backend/src/modules/portfolio/dto/add-position.dto.ts create mode 100644 apps/backend/src/modules/portfolio/dto/create-portfolio.dto.ts create mode 100644 apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts create mode 100644 apps/backend/src/modules/portfolio/dto/position-response.dto.ts create mode 100644 apps/backend/src/modules/portfolio/dto/update-portfolio.dto.ts create mode 100644 apps/backend/src/modules/portfolio/dto/update-position.dto.ts create mode 100644 apps/backend/src/modules/portfolio/portfolio.controller.ts create mode 100644 apps/backend/src/modules/portfolio/portfolio.module.ts create mode 100644 apps/backend/src/modules/portfolio/portfolio.service.ts create mode 100644 apps/docs/docs/adr/ADR-009-portfolio-domain.md create mode 100644 apps/docs/docs/adr/ADR-010-backend-price-computation.md create mode 100644 apps/docs/docs/backend/portfolio.md create mode 100644 apps/frontend/src/api/portfolio.ts create mode 100644 apps/frontend/src/components/portfolios/BondPositionRow.tsx create mode 100644 apps/frontend/src/components/portfolios/BondPositionTable.tsx create mode 100644 apps/frontend/src/components/portfolios/PortfolioCard.tsx create mode 100644 apps/frontend/src/components/portfolios/PortfolioForm.tsx create mode 100644 apps/frontend/src/components/portfolios/PortfolioSummary.tsx create mode 100644 apps/frontend/src/components/portfolios/SharePositionRow.tsx create mode 100644 apps/frontend/src/components/portfolios/SharePositionTable.tsx create mode 100644 apps/frontend/src/hooks/usePortfolio.ts create mode 100644 apps/frontend/src/hooks/usePortfolioMutations.ts create mode 100644 apps/frontend/src/hooks/usePortfolios.ts create mode 100644 apps/frontend/src/hooks/usePositionMutations.ts create mode 100644 apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx create mode 100644 apps/frontend/src/pages/portfolios/PortfoliosListPage.tsx create mode 100644 docs/superpowers/plans/2026-06-14-portfolio-phase1.md create mode 100644 docs/superpowers/specs/2026-06-14-portfolio-design.md diff --git a/apps/backend/prisma/migrations/20260614071935_add_portfolio_position/migration.sql b/apps/backend/prisma/migrations/20260614071935_add_portfolio_position/migration.sql new file mode 100644 index 0000000..f62b917 --- /dev/null +++ b/apps/backend/prisma/migrations/20260614071935_add_portfolio_position/migration.sql @@ -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"); diff --git a/apps/backend/prisma/migrations/20260614073903_add_position_type/migration.sql b/apps/backend/prisma/migrations/20260614073903_add_position_type/migration.sql new file mode 100644 index 0000000..a4500d6 --- /dev/null +++ b/apps/backend/prisma/migrations/20260614073903_add_position_type/migration.sql @@ -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; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 335aed0..97f5bbd 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -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[] } diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 0c6646d..9329973 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -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 {} diff --git a/apps/backend/src/modules/moex-client/moex-client.service.ts b/apps/backend/src/modules/moex-client/moex-client.service.ts index ce83a64..b53e822 100644 --- a/apps/backend/src/modules/moex-client/moex-client.service.ts +++ b/apps/backend/src/modules/moex-client/moex-client.service.ts @@ -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 { diff --git a/apps/backend/src/modules/portfolio/dto/add-position.dto.ts b/apps/backend/src/modules/portfolio/dto/add-position.dto.ts new file mode 100644 index 0000000..31c7ed0 --- /dev/null +++ b/apps/backend/src/modules/portfolio/dto/add-position.dto.ts @@ -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[]; +} diff --git a/apps/backend/src/modules/portfolio/dto/create-portfolio.dto.ts b/apps/backend/src/modules/portfolio/dto/create-portfolio.dto.ts new file mode 100644 index 0000000..2071898 --- /dev/null +++ b/apps/backend/src/modules/portfolio/dto/create-portfolio.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts b/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts new file mode 100644 index 0000000..042650c --- /dev/null +++ b/apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/portfolio/dto/position-response.dto.ts b/apps/backend/src/modules/portfolio/dto/position-response.dto.ts new file mode 100644 index 0000000..7064ea1 --- /dev/null +++ b/apps/backend/src/modules/portfolio/dto/position-response.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/portfolio/dto/update-portfolio.dto.ts b/apps/backend/src/modules/portfolio/dto/update-portfolio.dto.ts new file mode 100644 index 0000000..86a7623 --- /dev/null +++ b/apps/backend/src/modules/portfolio/dto/update-portfolio.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/portfolio/dto/update-position.dto.ts b/apps/backend/src/modules/portfolio/dto/update-position.dto.ts new file mode 100644 index 0000000..cb47545 --- /dev/null +++ b/apps/backend/src/modules/portfolio/dto/update-position.dto.ts @@ -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[]; +} diff --git a/apps/backend/src/modules/portfolio/portfolio.controller.ts b/apps/backend/src/modules/portfolio/portfolio.controller.ts new file mode 100644 index 0000000..31cba5b --- /dev/null +++ b/apps/backend/src/modules/portfolio/portfolio.controller.ts @@ -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 } }; + } +} diff --git a/apps/backend/src/modules/portfolio/portfolio.module.ts b/apps/backend/src/modules/portfolio/portfolio.module.ts new file mode 100644 index 0000000..ed0f8cc --- /dev/null +++ b/apps/backend/src/modules/portfolio/portfolio.module.ts @@ -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 {} diff --git a/apps/backend/src/modules/portfolio/portfolio.service.ts b/apps/backend/src/modules/portfolio/portfolio.service.ts new file mode 100644 index 0000000..738ee7c --- /dev/null +++ b/apps/backend/src/modules/portfolio/portfolio.service.ts @@ -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 { + 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 { + 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 { + 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, + }; + } + } +} diff --git a/apps/docs/docs/adr/ADR-009-portfolio-domain.md b/apps/docs/docs/adr/ADR-009-portfolio-domain.md new file mode 100644 index 0000000..010a8bc --- /dev/null +++ b/apps/docs/docs/adr/ADR-009-portfolio-domain.md @@ -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 diff --git a/apps/docs/docs/adr/ADR-010-backend-price-computation.md b/apps/docs/docs/adr/ADR-010-backend-price-computation.md new file mode 100644 index 0000000..6898ee2 --- /dev/null +++ b/apps/docs/docs/adr/ADR-010-backend-price-computation.md @@ -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 diff --git a/apps/docs/docs/backend/portfolio.md b/apps/docs/docs/backend/portfolio.md new file mode 100644 index 0000000..48ccbc3 --- /dev/null +++ b/apps/docs/docs/backend/portfolio.md @@ -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 diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index a1def48..c5507c0 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -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', ], }, ], diff --git a/apps/frontend/src/api/portfolio.ts b/apps/frontend/src/api/portfolio.ts new file mode 100644 index 0000000..45f0e06 --- /dev/null +++ b/apps/frontend/src/api/portfolio.ts @@ -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('/api/v1/portfolios'); +} + +export function getPortfolio( + id: number, +): Promise<{ data: PortfolioDetail; meta: { cachedAt: string | null; fromCache: boolean } }> { + return request(`/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('/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(`/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(`/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(`/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(`/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(`/api/v1/portfolios/${portfolioId}/positions/${positionId}`, undefined, { + method: 'DELETE', + }); +} diff --git a/apps/frontend/src/api/responses.ts b/apps/frontend/src/api/responses.ts index c2da5c1..1d00dda 100644 --- a/apps/frontend/src/api/responses.ts +++ b/apps/frontend/src/api/responses.ts @@ -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; +} diff --git a/apps/frontend/src/components/Layout.tsx b/apps/frontend/src/components/Layout.tsx index 728682a..fdc1077 100644 --- a/apps/frontend/src/components/Layout.tsx +++ b/apps/frontend/src/components/Layout.tsx @@ -35,6 +35,17 @@ export function Layout() { MoexVibe + + Портфели +
{isAuthenticated ? ( <> diff --git a/apps/frontend/src/components/portfolios/BondPositionRow.tsx b/apps/frontend/src/components/portfolios/BondPositionRow.tsx new file mode 100644 index 0000000..869d7da --- /dev/null +++ b/apps/frontend/src/components/portfolios/BondPositionRow.tsx @@ -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 ( + + + + {position.secid} + + + + {position.shortName ?? '—'} + + + {position.bondType ? {position.bondType} : '—'} + + + {editing ? ( + 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, + }} + /> + ) : ( + { + setQty(String(position.quantity)); + setEditing(true); + }} + style={{ cursor: 'pointer', padding: '4px 0', display: 'inline-block' }} + > + {position.quantity.toLocaleString('ru-RU')} + + )} + + + {formatPct(position.currentPrice)} + + {formatPct(position.bid)} + {formatPct(position.offer)} + + {formatPct(position.yieldToMaturity)} + + + {position.duration != null ? `${position.duration.toFixed(2)}г` : '—'} + + + {formatRubles(position.couponValue)} + + + {formatPct(position.couponPercent)} + + + {position.couponPeriod ? `${position.couponPeriod}д` : '—'} + + + {totalAccrued != null + ? totalAccrued.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + : '—'} + + + {formatDate(position.nextCouponDate)} + + {formatDate(position.matDate)} + {formatDate(position.offerDate)} + + {position.weightPercent.toFixed(1)}% + + + + + + ); +} diff --git a/apps/frontend/src/components/portfolios/BondPositionTable.tsx b/apps/frontend/src/components/portfolios/BondPositionTable.tsx new file mode 100644 index 0000000..4b7e518 --- /dev/null +++ b/apps/frontend/src/components/portfolios/BondPositionTable.tsx @@ -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 ( +
+

+ Облигации +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + {positions.map((pos) => ( + onUpdatePosition(pos.id, data)} + onDelete={() => onDeletePosition(pos.id)} + /> + ))} + +
+ Тикер + + Название + + Тип + + Количество + + Цена + + Бид + + Оффер + + Доходность + + Дюрация + + Купон + + Куп. % + + Период + + НКД + + След. купон + + Погашение + + Оферта + + Доля +
+
+
+ ); +} diff --git a/apps/frontend/src/components/portfolios/PortfolioCard.tsx b/apps/frontend/src/components/portfolios/PortfolioCard.tsx new file mode 100644 index 0000000..0dc458f --- /dev/null +++ b/apps/frontend/src/components/portfolios/PortfolioCard.tsx @@ -0,0 +1,39 @@ +import { Link } from 'react-router-dom'; +import type { Portfolio } from '../../api/responses'; + +export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) { + return ( + (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')} + onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')} + > +

{portfolio.name}

+ {portfolio.description && ( +

+ {portfolio.description} +

+ )} + + {portfolio.currency} · обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')} + + + ); +} diff --git a/apps/frontend/src/components/portfolios/PortfolioForm.tsx b/apps/frontend/src/components/portfolios/PortfolioForm.tsx new file mode 100644 index 0000000..505c432 --- /dev/null +++ b/apps/frontend/src/components/portfolios/PortfolioForm.tsx @@ -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 ( +
+
+ + setName(e.target.value)} + required + maxLength={100} + style={{ + width: '100%', + padding: '8px 12px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 14, + }} + /> +
+
+ +