From dca8418843abd1168c9eb04640b315f0f515bbda Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 18 Jun 2026 21:49:36 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BA=20SDD=20=D0=BF=D0=BE=D0=B4=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + AGENTS.md | 228 +- docs/epics/Authentication.md | 4 + docs/epics/BrokerPortfolio.md | 8 + docs/epics/DevOps.md | 5 + docs/epics/Documentation.md | 5 + docs/epics/PortfolioDasboard.md | 9 + docs/epics/QualityAssurance.md | 5 + docs/features/auth-system/plan.md | 1186 ++++++ .../spec.md} | 0 .../broker-portfolio-display/plan.md} | 0 .../broker-portfolio-display/spec.md} | 0 .../spec.md} | 0 docs/features/ci-cd/plan.md | 110 + .../ci-cd/spec.md} | 0 .../docusaurus-docs/spec.md} | 0 docs/features/frontend-test-coverage/plan.md | 1920 +++++++++ .../frontend-test-coverage/spec.md} | 0 docs/features/moex-vibe/plan.md | 3456 +++++++++++++++++ .../moex-vibe/spec.md} | 0 .../pagination-loading-overlay/plan.md | 514 +++ .../pagination-loading-overlay/spec.md} | 0 .../portfolio-allocation-chart/plan.md | 261 ++ .../portfolio-allocation-chart/spec.md} | 0 docs/features/portfolio-analytics/plan.md | 1037 +++++ .../portfolio-analytics/spec.md} | 0 .../portfolio-enricher-optimization/plan.md | 365 ++ .../portfolio-enricher-optimization/spec.md} | 0 .../portfolio-list-enrichment/plan.md | 613 +++ .../portfolio-list-enrichment/spec.md} | 0 docs/features/portfolio/plan.md | 1615 ++++++++ .../portfolio/spec.md} | 0 docs/features/pre-commit-checks/plan.md | 187 + .../pre-commit-checks/spec.md} | 0 .../quality-gate-contract-docs/plan.md | 1401 +++++++ .../quality-gate-contract-docs/spec.md} | 0 .../spec.md} | 0 docs/features/security-screener/plan.md | 1614 ++++++++ .../security-screener/spec.md} | 0 .../tbank-broker-portfolios/plan.md} | 0 .../tbank-broker-portfolios/spec.md} | 0 .../tbank-deadline-queue-fix/spec.md} | 0 docs/inbox.md | 15 + docs/roadmap.md | 0 44 files changed, 14556 insertions(+), 3 deletions(-) create mode 100644 docs/epics/Authentication.md create mode 100644 docs/epics/BrokerPortfolio.md create mode 100644 docs/epics/DevOps.md create mode 100644 docs/epics/Documentation.md create mode 100644 docs/epics/PortfolioDasboard.md create mode 100644 docs/epics/QualityAssurance.md create mode 100644 docs/features/auth-system/plan.md rename docs/{superpowers/specs/2026-06-17-broker-operations-ui-improvements.md => features/broker-operations-ui-improvements/spec.md} (100%) rename docs/{superpowers/plans/2026-06-17-broker-portfolio-display.md => features/broker-portfolio-display/plan.md} (100%) rename docs/{superpowers/specs/2026-06-17-broker-portfolio-display.md => features/broker-portfolio-display/spec.md} (100%) rename docs/{superpowers/specs/2026-06-17-broker-positions-pagination-and-loading.md => features/broker-positions-pagination-and-loading/spec.md} (100%) create mode 100644 docs/features/ci-cd/plan.md rename docs/{superpowers/specs/2026-06-13-cicd-design.md => features/ci-cd/spec.md} (100%) rename docs/{superpowers/specs/2026-06-13-docusaurus-docs-design.md => features/docusaurus-docs/spec.md} (100%) create mode 100644 docs/features/frontend-test-coverage/plan.md rename docs/{superpowers/specs/2026-06-13-frontend-test-coverage.md => features/frontend-test-coverage/spec.md} (100%) create mode 100644 docs/features/moex-vibe/plan.md rename docs/{superpowers/specs/2026-06-13-moex-vibe-design.md => features/moex-vibe/spec.md} (100%) create mode 100644 docs/features/pagination-loading-overlay/plan.md rename docs/{superpowers/specs/2026-06-18-pagination-loading-overlay-design.md => features/pagination-loading-overlay/spec.md} (100%) create mode 100644 docs/features/portfolio-allocation-chart/plan.md rename docs/{superpowers/specs/2026-06-14-portfolio-allocation-chart-design.md => features/portfolio-allocation-chart/spec.md} (100%) create mode 100644 docs/features/portfolio-analytics/plan.md rename docs/{superpowers/specs/2026-06-14-portfolio-analytics-design.md => features/portfolio-analytics/spec.md} (100%) create mode 100644 docs/features/portfolio-enricher-optimization/plan.md rename docs/{superpowers/specs/2026-06-14-portfolio-enricher-optimization.md => features/portfolio-enricher-optimization/spec.md} (100%) create mode 100644 docs/features/portfolio-list-enrichment/plan.md rename docs/{superpowers/specs/2026-06-14-portfolio-list-enrichment.md => features/portfolio-list-enrichment/spec.md} (100%) create mode 100644 docs/features/portfolio/plan.md rename docs/{superpowers/specs/2026-06-14-portfolio-design.md => features/portfolio/spec.md} (100%) create mode 100644 docs/features/pre-commit-checks/plan.md rename docs/{superpowers/specs/2026-06-14-pre-commit-checks-design.md => features/pre-commit-checks/spec.md} (100%) create mode 100644 docs/features/quality-gate-contract-docs/plan.md rename docs/{superpowers/specs/2026-06-14-quality-gate-contract-docs-design.md => features/quality-gate-contract-docs/spec.md} (100%) rename docs/{superpowers/specs/2026-06-15-russian-docs-and-architecture-diagrams.md => features/russian-docs-and-architecture-diagrams/spec.md} (100%) create mode 100644 docs/features/security-screener/plan.md rename docs/{superpowers/specs/2026-06-14-security-screener-design.md => features/security-screener/spec.md} (100%) rename docs/{superpowers/plans/2026-06-16-tbank-broker-portfolios.md => features/tbank-broker-portfolios/plan.md} (100%) rename docs/{superpowers/specs/2026-06-16-tbank-broker-portfolios-design.md => features/tbank-broker-portfolios/spec.md} (100%) rename docs/{superpowers/specs/2026-06-17-tbank-deadline-queue-fix.md => features/tbank-deadline-queue-fix/spec.md} (100%) create mode 100644 docs/inbox.md create mode 100644 docs/roadmap.md diff --git a/.gitignore b/.gitignore index f54e210..d8fbc28 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ vite.config.d.ts vite.config.js apps/docs/.docusaurus/ apps/docs/build/ +.idea diff --git a/AGENTS.md b/AGENTS.md index 8c50cb8..bd9d0e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,231 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr - **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче. - **Visual Companion**: при обсуждении дизайна UI (mockups, макеты, варианты внешнего вида) использовать visual companion в браузере. +# Процесс разработки + +Проект использует подход Specification-Driven Development (SDD). + +## Структура документации + +```text +docs/ + +├── inbox.md +├── roadmap.md +│ +├── research/ +│ +├── epics/ +│ └── {epic-name}.md +│ +└── features/ + └── {feature-name}/ + ├── spec.md + ├── plan.md + └── tasks.md + +``` + +## Назначение документов +### inbox.md + +Содержит идеи и мысли, которые появились во время работы над проектом. + +Записи в inbox не являются требованиями и не должны реализовываться напрямую. + +### roadmap.md + +Содержит список запланированных эпиков и фич. + +Наличие задачи в roadmap не означает, что её нужно немедленно реализовать. + +### research/ + +Содержит результаты исследований и экспериментов. + +Документы могут содержать гипотезы, предположения и открытые вопросы. + +Результаты исследований необходимо проверять перед реализацией. + +### epics/ + +Эпик представляет собой крупную продуктовую возможность или модуль. + +Эпик может состоять из нескольких фич. + +### features/{feature-name}/spec.md + +Описывает ЧТО должно быть реализовано. + +Спецификация должна содержать: +- цель +- требования +- ограничения +- критерии приемки (Acceptance Criteria) + +Спецификация не должна содержать деталей реализации. + +### features/{feature-name}/plan.md + +Описывает КАК будет реализована фича. + +План может содержать: +- архитектурные решения +- API контракты +- потоки данных +- технический подход + +### features/{feature-name}/tasks.md + +Содержит список задач для реализации. + +Задачи должны быть: +- небольшими +- конкретными +- независимыми по возможности + + +## Правила разработки + + +### Правило 1 + +Нельзя начинать реализацию без спецификации. + +Если спецификации нет: + +- Провести исследование при необходимости. +- Создать spec.md. +- Уточнить требования. +- Только после этого переходить к реализации. + +### Правило 2 + +Реализация должна соответствовать spec.md. + +Если в процессе разработки выясняется, что требования неполные или ошибочные: + +Не изменять поведение системы молча. + +Сначала обновить: +- spec.md +- plan.md + +И только потом продолжать реализацию. + +### Правило 3 + +Спецификация является источником истины. + +Если plan.md противоречит spec.md: +Приоритет имеет spec.md. + +### Правило 4 + +Не добавлять функциональность, которая отсутствует в спецификации. + +Если появилась новая идея: + +- обновить спецификацию; +- либо создать новую фичу. + +### Правило 5 + +Исправления ошибок можно выполнять напрямую. + +Новая функциональность должна проходить через спецификацию. + + +## Процесс работы над фичей + +При реализации фичи необходимо: + +1) Ознакомиться с эпиком, если он существует. +2) Прочитать spec.md. +3) Прочитать plan.md. +4) Прочитать tasks.md. +5) Выполнять задачи последовательно. +6) Отмечать выполненные задачи. +7) Обновлять plan.md при изменении технических решений. +8) Обновлять spec.md при изменении требований. + +## Работа с новыми идеями + +Если во время реализации появилась новая идея: + +Не реализовывать её автоматически. + +Необходимо определить, является ли она: +- багом; +- улучшением существующей функциональности; +- новой фичей. + +Если это улучшение или новая фича: + +Добавить её в: +- inbox.md + +или создать отдельную фичу. + +## Работа с существующими фичами + +Улучшения существующей функциональности обычно остаются внутри текущего эпика. + +Пример: + +Portfolio Dashboard +- История операций +- Пагинация истории операций +- Фильтрация истории операций +- Экспорт истории операций + +Все перечисленные возможности относятся к одному эпику. + +Новый эпик создаётся только при появлении новой продуктовой возможности или нового домена. + +## Поддержание документации + +Документация должна соответствовать текущему состоянию проекта. + +После значимых изменений необходимо обновлять: +- spec.md +- plan.md +- tasks.md +- ADR +- архитектурную документацию + +Документация не должна отставать от реализации. + +## Поведение AI-агентов + +Перед написанием кода необходимо: +1) Изучить спецификацию фичи. +2) Проверить полноту требований. +3) Найти неоднозначности и противоречия. +4) При необходимости запросить уточнения. + +Запрещено: +- придумывать требования; +- додумывать поведение системы; +- реализовывать неописанную функциональность. + +Если информации недостаточно: +- Остановиться и запросить уточнение вместо того, чтобы делать предположения. + +## Приоритет источников информации + +При возникновении противоречий использовать следующий порядок приоритетов: + +1. Текущая задача пользователя. +2. spec.md фичи. +3. plan.md фичи. +4. ADR. +5. Архитектурная документация. +6. roadmap.md. +7. inbox.md. + +roadmap.md и inbox.md никогда не являются основанием для реализации функциональности. + ## Git workflow - Для каждой самостоятельной фичи создавать отдельную feature branch и вести разработку внутри неё. @@ -20,11 +245,8 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr ## Документация и SDD-артефакты - `apps/docs` — единственная опубликованная человекочитаемая документация проекта (Docusaurus). -- Root `docs` хранит только согласованные SDD-спецификации в `docs/superpowers/specs/`. -- Все SDD spec-файлы в `docs/superpowers/specs/` пишутся на русском языке; англоязычные термины допустимы для API, кода, протоколов и официальных названий. - ADR для опубликованной документации находятся в `apps/docs/docs/adr/`. - OpenAPI source of truth — live Swagger JSON бэкенда на `/api/docs-json`; frontend generated types находятся в `apps/frontend/src/api/types.ts`. -- Superpowers plans и временные execution logs не коммитить по умолчанию. Если нужен план для ревью, держать его кратким и переносить устойчивые решения в spec/ADR/docs. ## Команды diff --git a/docs/epics/Authentication.md b/docs/epics/Authentication.md new file mode 100644 index 0000000..e81d11e --- /dev/null +++ b/docs/epics/Authentication.md @@ -0,0 +1,4 @@ +Авторизация + +Features: +- auth-system diff --git a/docs/epics/BrokerPortfolio.md b/docs/epics/BrokerPortfolio.md new file mode 100644 index 0000000..c6d3a26 --- /dev/null +++ b/docs/epics/BrokerPortfolio.md @@ -0,0 +1,8 @@ +Портфель брокера + +Features: +- broker-operations-ui-improvements +- broker-portfolio-display +- broker-positions-pagination-and-loading +- tbank-broker-portfolios +- tbank-deadline-queue-fix diff --git a/docs/epics/DevOps.md b/docs/epics/DevOps.md new file mode 100644 index 0000000..31653c2 --- /dev/null +++ b/docs/epics/DevOps.md @@ -0,0 +1,5 @@ +Developer Operations + +Features: +- ci-cd +- pre-commit-checks diff --git a/docs/epics/Documentation.md b/docs/epics/Documentation.md new file mode 100644 index 0000000..fecac13 --- /dev/null +++ b/docs/epics/Documentation.md @@ -0,0 +1,5 @@ +Документация + +Features: +- docusaurus-docs +- russian-docs-and-architecture-diagrams diff --git a/docs/epics/PortfolioDasboard.md b/docs/epics/PortfolioDasboard.md new file mode 100644 index 0000000..7ecabfc --- /dev/null +++ b/docs/epics/PortfolioDasboard.md @@ -0,0 +1,9 @@ +Страница портфеля + +Features: +- pagination-loading-overlay +- portfolio +- portfolio-allocation-chart +- portfolio-analytics +- portfolio-enricher-optimization +- portfolio-list-enrichment diff --git a/docs/epics/QualityAssurance.md b/docs/epics/QualityAssurance.md new file mode 100644 index 0000000..37929b5 --- /dev/null +++ b/docs/epics/QualityAssurance.md @@ -0,0 +1,5 @@ +Контроль качества + +Features: +- frontend-test-coverage +- quality-gate-contract-docs diff --git a/docs/features/auth-system/plan.md b/docs/features/auth-system/plan.md new file mode 100644 index 0000000..c6100e4 --- /dev/null +++ b/docs/features/auth-system/plan.md @@ -0,0 +1,1186 @@ +# Auth System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add full authentication (register/login/logout/refresh) and role-based authorization (user/admin) to MoexVibe. + +**Architecture:** Backend — NestJS + Prisma (SQLite) for user storage, JWT access tokens (15m) + httpOnly refresh cookies (7d) with rotation. Frontend — React context for auth state, TanStack Query for API, auto-refresh on 401 with retry. + +**Tech Stack:** @prisma/client, @nestjs/jwt, bcrypt, cookie-parser (backend); React Context + fetch (frontend) + +--- + +### Task 1: Backend dependencies + Prisma init + +**Files:** +- Modify: `apps/backend/package.json` +- Create: `apps/backend/prisma/schema.prisma` +- Create: `apps/backend/.gitignore` (or modify root) +- Create: `apps/backend/src/modules/prisma/prisma.service.ts` +- Create: `apps/backend/src/modules/prisma/prisma.module.ts` + +- [ ] **Step 1: Install dependencies** + +```bash +npm install @prisma/client @nestjs/jwt bcrypt cookie-parser -w apps/backend +npm install -D prisma @types/bcrypt @types/cookie-parser -w apps/backend +``` + +- [ ] **Step 2: Init Prisma with SQLite** + +```bash +npx prisma init --datasource-provider sqlite +``` + +Update the generated `apps/backend/prisma/schema.prisma`: + +```prisma +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + password String + name String? + role String @default("user") + refreshToken String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} +``` + +- [ ] **Step 3: Add DATABASE_URL to .env** + +```env +DATABASE_URL="file:./dev.db" +``` + +Add `.env` to backend `.gitignore`: + +```gitignore +node_modules/ +dist/ +.env +*.db +*.db-journal +``` + +- [ ] **Step 4: PrismaService** + +```typescript +// apps/backend/src/modules/prisma/prisma.service.ts +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} +``` + +- [ ] **Step 5: PrismaModule** + +```typescript +// apps/backend/src/modules/prisma/prisma.module.ts +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} +``` + +- [ ] **Step 6: Run migration** + +```bash +npx prisma migrate dev --name init +``` + +- [ ] **Step 7: Commit** + +```bash +git add apps/backend/prisma/ apps/backend/src/modules/prisma/ apps/backend/package.json apps/backend/.env apps/backend/.gitignore +git commit -m "feat: add Prisma ORM with SQLite and User model" +``` + +--- + +### Task 2: AuthService — register, login, refresh, logout (TDD) + +**Files:** +- Create: `apps/backend/src/modules/auth/interfaces/jwt-payload.interface.ts` +- Create: `apps/backend/src/modules/auth/auth.service.spec.ts` +- Create: `apps/backend/src/modules/auth/auth.service.ts` +- Create: `apps/backend/src/modules/auth/dto/register.dto.ts` +- Create: `apps/backend/src/modules/auth/dto/login.dto.ts` +- Create: `apps/backend/src/modules/auth/dto/update-profile.dto.ts` + +- [ ] **Step 1: Write JwtPayload interface** + +```typescript +// apps/backend/src/modules/auth/interfaces/jwt-payload.interface.ts +export interface JwtPayload { + sub: number; + email: string; + role: string; +} +``` + +- [ ] **Step 2: Write DTOs** + +```typescript +// apps/backend/src/modules/auth/dto/register.dto.ts +import { IsEmail, IsString, MinLength, MaxLength, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class RegisterDto { + @ApiProperty({ example: 'user@example.com' }) + @IsEmail() + email: string; + + @ApiProperty({ example: 'securePass123' }) + @IsString() + @MinLength(6) + @MaxLength(100) + password: string; + + @ApiPropertyOptional({ example: 'John' }) + @IsString() + @IsOptional() + @MinLength(1) + @MaxLength(100) + name?: string; +} +``` + +```typescript +// apps/backend/src/modules/auth/dto/login.dto.ts +import { IsEmail, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class LoginDto { + @ApiProperty({ example: 'user@example.com' }) + @IsEmail() + email: string; + + @ApiProperty({ example: 'securePass123' }) + @IsString() + password: string; +} +``` + +```typescript +// apps/backend/src/modules/auth/dto/update-profile.dto.ts +import { IsString, IsOptional, MinLength, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateProfileDto { + @ApiPropertyOptional({ example: 'John Doe' }) + @IsString() + @IsOptional() + @MinLength(1) + @MaxLength(100) + name?: string; +} +``` + +- [ ] **Step 3: Write failing auth service tests** + +```typescript +// apps/backend/src/modules/auth/auth.service.spec.ts +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule, JwtService } from '@nestjs/jwt'; +import { AuthService } from './auth.service'; +import { PrismaService } from '../prisma/prisma.service'; +import * as bcrypt from 'bcrypt'; +import { ConflictException, UnauthorizedException } from '@nestjs/common'; + +describe('AuthService', () => { + let service: AuthService; + let prisma: PrismaService; + let jwtService: JwtService; + + const testUser = { + email: 'test@example.com', + password: 'testPass123', + name: 'Test User', + }; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + JwtModule.register({ + secret: 'test-secret', + signOptions: { expiresIn: '15m' }, + }), + ], + providers: [ + AuthService, + { + provide: PrismaService, + useValue: { + user: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }, + }, + { + provide: ConfigService, + useValue: { + get: vi.fn((key: string) => { + const config: Record = { + 'auth.jwtSecret': 'test-secret', + 'auth.jwtAccessExpires': '15m', + 'auth.jwtRefreshExpires': '7d', + }; + return config[key]; + }), + }, + }, + ], + }).compile(); + + service = module.get(AuthService); + prisma = module.get(PrismaService); + jwtService = module.get(JwtService); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('register', () => { + it('should register a new user and return tokens', async () => { + const hashedPassword = await bcrypt.hash(testUser.password, 12); + const mockUser = { + id: 1, + email: testUser.email, + password: hashedPassword, + name: testUser.name, + role: 'user', + refreshToken: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.create).mockResolvedValue(mockUser); + + const result = await service.register(testUser); + + expect(result.user.id).toBe(1); + expect(result.user.email).toBe(testUser.email); + expect(result.user.name).toBe(testUser.name); + expect(result.user.role).toBe('user'); + expect(result.accessToken).toBeDefined(); + expect(result.refreshToken).toBeDefined(); + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { email: testUser.email }, + }); + expect(prisma.user.create).toHaveBeenCalledWith({ + data: { + email: testUser.email, + password: expect.any(String), + name: testUser.name, + }, + }); + }); + + it('should throw ConflictException if email already exists', async () => { + const mockUser = { id: 1, email: testUser.email, password: 'hash', name: 'Test', role: 'user', refreshToken: null, createdAt: new Date(), updatedAt: new Date() }; + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser); + + await expect(service.register(testUser)).rejects.toThrow(ConflictException); + expect(prisma.user.create).not.toHaveBeenCalled(); + }); + + it('should register a user without name', async () => { + const hashedPassword = await bcrypt.hash(testUser.password, 12); + const mockUser = { + id: 2, + email: 'noname@example.com', + password: hashedPassword, + name: null, + role: 'user', + refreshToken: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.create).mockResolvedValue(mockUser); + + const result = await service.register({ email: 'noname@example.com', password: testUser.password }); + + expect(result.user.name).toBeNull(); + expect(result.accessToken).toBeDefined(); + }); + }); + + describe('login', () => { + it('should login with valid credentials', async () => { + const passwordHash = await bcrypt.hash(testUser.password, 12); + const mockUser = { + id: 1, + email: testUser.email, + password: passwordHash, + name: testUser.name, + role: 'user', + refreshToken: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser); + vi.mocked(prisma.user.update).mockResolvedValue({ ...mockUser, refreshToken: 'some-hash' }); + + const result = await service.login({ email: testUser.email, password: testUser.password }); + + expect(result.user.id).toBe(1); + expect(result.accessToken).toBeDefined(); + expect(result.refreshToken).toBeDefined(); + }); + + it('should throw UnauthorizedException for wrong password', async () => { + const mockUser = { + id: 1, + email: testUser.email, + password: await bcrypt.hash(testUser.password, 12), + name: testUser.name, + role: 'user', + refreshToken: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser); + + await expect(service.login({ email: testUser.email, password: 'wrongPassword' })).rejects.toThrow(UnauthorizedException); + }); + + it('should throw UnauthorizedException for non-existent email', async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + + await expect(service.login({ email: 'nonexistent@example.com', password: 'pass' })).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('refresh', () => { + it('should rotate tokens on valid refresh', async () => { + const passwordHash = await bcrypt.hash(testUser.password, 12); + const oldRefreshToken = 'valid-refresh-token-jwt'; + const oldRefreshHash = await bcrypt.hash(oldRefreshToken, 12); + + const mockUser = { + id: 1, + email: testUser.email, + password: passwordHash, + name: testUser.name, + role: 'user', + refreshToken: oldRefreshHash, + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser); + vi.mocked(prisma.user.update).mockResolvedValue(mockUser); + + const decoded = { sub: 1, jti: 'some-jti' }; + vi.spyOn(jwtService, 'verifyAsync').mockResolvedValue(decoded as any); + + const result = await service.refresh(oldRefreshToken); + + expect(result.accessToken).toBeDefined(); + expect(result.refreshToken).toBeDefined(); + expect(result.refreshToken).not.toBe(oldRefreshToken); + expect(prisma.user.update).toHaveBeenCalled(); + }); + + it('should throw on invalid refresh token', async () => { + vi.spyOn(jwtService, 'verifyAsync').mockRejectedValue(new Error('jwt expired')); + + await expect(service.refresh('bad-token')).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('logout', () => { + it('should clear refreshToken', async () => { + vi.mocked(prisma.user.update).mockResolvedValue({ + id: 1, + email: testUser.email, + password: 'hash', + name: testUser.name, + role: 'user', + refreshToken: null, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await service.logout(1); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { refreshToken: null }, + }); + }); + }); + + describe('updateProfile', () => { + it('should update user name', async () => { + const mockUser = { + id: 1, + email: testUser.email, + password: 'hash', + name: 'Updated Name', + role: 'user', + refreshToken: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(prisma.user.update).mockResolvedValue(mockUser); + + const result = await service.updateProfile(1, { name: 'Updated Name' }); + + expect(result.name).toBe('Updated Name'); + }); + + it('should return user without changes if no fields provided', async () => { + const mockUser = { + id: 1, + email: testUser.email, + password: 'hash', + name: 'Test User', + role: 'user', + refreshToken: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + vi.mocked(prisma.user.update).mockResolvedValue(mockUser); + + const result = await service.updateProfile(1, {}); + + expect(result.name).toBe('Test User'); + }); + }); +}); +``` + +- [ ] **Step 4: Run test to verify it fails** + +```bash +npx vitest run apps/backend/src/modules/auth/auth.service.spec.ts -w apps/backend +``` + +Expected: FAIL — "AuthService not defined" or similar. + +- [ ] **Step 5: Write minimal AuthService** + +```typescript +// apps/backend/src/modules/auth/auth.service.ts +import { Injectable, ConflictException, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcrypt'; +import { PrismaService } from '../prisma/prisma.service'; +import { RegisterDto } from './dto/register.dto'; +import { LoginDto } from './dto/login.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { JwtPayload } from './interfaces/jwt-payload.interface'; + +const SALT_ROUNDS = 12; + +@Injectable() +export class AuthService { + constructor( + private readonly prisma: PrismaService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async register(dto: RegisterDto) { + const existing = await this.prisma.user.findUnique({ where: { email: dto.email } }); + if (existing) { + throw new ConflictException('Email already registered'); + } + + const passwordHash = await bcrypt.hash(dto.password, SALT_ROUNDS); + const user = await this.prisma.user.create({ + data: { + email: dto.email, + password: passwordHash, + name: dto.name ?? null, + }, + }); + + return this.generateTokens(user); + } + + async login(dto: LoginDto) { + const user = await this.prisma.user.findUnique({ where: { email: dto.email } }); + if (!user) { + throw new UnauthorizedException('Invalid email or password'); + } + + const isValid = await bcrypt.compare(dto.password, user.password); + if (!isValid) { + throw new UnauthorizedException('Invalid email or password'); + } + + return this.generateTokens(user); + } + + async refresh(refreshToken: string) { + try { + const payload = await this.jwtService.verifyAsync<{ sub: number; jti: string }>(refreshToken, { + secret: this.configService.get('auth.jwtRefreshSecret'), + }); + + const user = await this.prisma.user.findUnique({ where: { id: payload.sub } }); + if (!user?.refreshToken) { + throw new UnauthorizedException('Invalid refresh token'); + } + + const isValid = await bcrypt.compare(refreshToken, user.refreshToken); + if (!isValid) { + throw new UnauthorizedException('Invalid refresh token'); + } + + return this.generateTokens(user); + } catch { + throw new UnauthorizedException('Invalid refresh token'); + } + } + + async logout(userId: number) { + await this.prisma.user.update({ + where: { id: userId }, + data: { refreshToken: null }, + }); + } + + async getProfile(userId: number) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new UnauthorizedException('User not found'); + } + return this.sanitizeUser(user); + } + + async updateProfile(userId: number, dto: UpdateProfileDto) { + const user = await this.prisma.user.update({ + where: { id: userId }, + data: { + ...(dto.name !== undefined && { name: dto.name }), + }, + }); + return this.sanitizeUser(user); + } + + private async generateTokens(user: { id: number; email: string; role: string }) { + const accessPayload: JwtPayload = { sub: user.id, email: user.email, role: user.role }; + const accessToken = await this.jwtService.signAsync(accessPayload, { + secret: this.configService.get('auth.jwtSecret'), + expiresIn: this.configService.get('auth.jwtAccessExpires'), + }); + + const jti = crypto.randomUUID(); + const refreshPayload = { sub: user.id, jti }; + const refreshToken = await this.jwtService.signAsync(refreshPayload, { + secret: this.configService.get('auth.jwtRefreshSecret'), + expiresIn: this.configService.get('auth.jwtRefreshExpires'), + }); + + const refreshHash = await bcrypt.hash(refreshToken, SALT_ROUNDS); + await this.prisma.user.update({ + where: { id: user.id }, + data: { refreshToken: refreshHash }, + }); + + return { + user: this.sanitizeUser(user), + accessToken, + refreshToken, + }; + } + + private sanitizeUser(user: { id: number; email: string; name: string | null; role: string }) { + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + }; + } +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +```bash +npx vitest run apps/backend/src/modules/auth/auth.service.spec.ts -w apps/backend +``` + +Expected: PASS + +--- + +### Task 3: Auth guards, decorators, controller, module + +**Files:** +- Create: `apps/backend/src/modules/auth/decorators/public.decorator.ts` +- Create: `apps/backend/src/modules/auth/decorators/current-user.decorator.ts` +- Create: `apps/backend/src/modules/auth/decorators/roles.decorator.ts` +- Create: `apps/backend/src/modules/auth/guards/jwt-auth.guard.ts` +- Create: `apps/backend/src/modules/auth/guards/roles.guard.ts` +- Create: `apps/backend/src/modules/auth/auth.controller.ts` +- Create: `apps/backend/src/modules/auth/auth.module.ts` + +- [ ] **Step 1: Public decorator** + +```typescript +// apps/backend/src/modules/auth/decorators/public.decorator.ts +import { SetMetadata } from '@nestjs/common'; + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); +``` + +- [ ] **Step 2: CurrentUser decorator** + +```typescript +// apps/backend/src/modules/auth/decorators/current-user.decorator.ts +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +export const CurrentUser = createParamDecorator( + (data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + return request.user; + }, +); +``` + +- [ ] **Step 3: Roles decorator** + +```typescript +// apps/backend/src/modules/auth/decorators/roles.decorator.ts +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); +``` + +- [ ] **Step 4: JwtAuthGuard** + +```typescript +// apps/backend/src/modules/auth/guards/jwt-auth.guard.ts +import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; + +@Injectable() +export class JwtAuthGuard { + constructor( + private readonly reflector: Reflector, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const token = this.extractToken(request); + + if (!token) { + throw new UnauthorizedException('Authentication required'); + } + + try { + const payload = await this.jwtService.verifyAsync(token, { + secret: this.configService.get('auth.jwtSecret'), + }); + request.user = payload; + return true; + } catch { + throw new UnauthorizedException('Invalid or expired token'); + } + } + + private extractToken(request: any): string | null { + const auth = request.headers?.authorization; + if (!auth) return null; + const [type, token] = auth.split(' '); + return type === 'Bearer' ? token : null; + } +} +``` + +- [ ] **Step 5: RolesGuard** + +```typescript +// apps/backend/src/modules/auth/guards/roles.guard.ts +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + return requiredRoles.includes(request.user?.role); + } +} +``` + +- [ ] **Step 6: AuthController** + +```typescript +// apps/backend/src/modules/auth/auth.controller.ts +import { Controller, Post, Get, Patch, Body, Req, Res, HttpCode, HttpStatus } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { AuthService } from './auth.service'; +import { RegisterDto } from './dto/register.dto'; +import { LoginDto } from './dto/login.dto'; +import { UpdateProfileDto } from './dto/update-profile.dto'; +import { CurrentUser } from './decorators/current-user.decorator'; +import { Public } from './decorators/public.decorator'; +import { JwtPayload } from './interfaces/jwt-payload.interface'; + +const REFRESH_COOKIE = 'refresh_token'; +const COOKIE_OPTIONS = { + httpOnly: true, + sameSite: 'lax' as const, + secure: process.env.NODE_ENV === 'production', + path: '/api/v1/auth', + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days +}; + +@ApiTags('Auth') +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Public() + @Post('register') + @ApiOperation({ summary: 'Register new user' }) + async register(@Body() dto: RegisterDto, @Res({ passthrough: true }) res: Response) { + const result = await this.authService.register(dto); + res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); + return { + data: { + user: result.user, + accessToken: result.accessToken, + }, + meta: { fromCache: false, cachedAt: null }, + }; + } + + @Public() + @Post('login') + @ApiOperation({ summary: 'Login with email and password' }) + async login(@Body() dto: LoginDto, @Res({ passthrough: true }) res: Response) { + const result = await this.authService.login(dto); + res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); + return { + data: { + user: result.user, + accessToken: result.accessToken, + }, + meta: { fromCache: false, cachedAt: null }, + }; + } + + @Public() + @Post('refresh') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Refresh access token' }) + async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) { + const token = req.cookies?.[REFRESH_COOKIE]; + const result = await this.authService.refresh(token); + res.cookie(REFRESH_COOKIE, result.refreshToken, COOKIE_OPTIONS); + return { + data: { + user: result.user, + accessToken: result.accessToken, + }, + meta: { fromCache: false, cachedAt: null }, + }; + } + + @Post('logout') + @HttpCode(HttpStatus.OK) + @ApiBearerAuth() + @ApiOperation({ summary: 'Logout (clear refresh token)' }) + async logout(@CurrentUser() user: JwtPayload, @Res({ passthrough: true }) res: Response) { + await this.authService.logout(user.sub); + res.clearCookie(REFRESH_COOKIE, { path: '/api/v1/auth' }); + return { + data: { message: 'Logged out successfully' }, + meta: { fromCache: false, cachedAt: null }, + }; + } + + @Get('me') + @ApiBearerAuth() + @ApiOperation({ summary: 'Get current user profile' }) + async getProfile(@CurrentUser() user: JwtPayload) { + const profile = await this.authService.getProfile(user.sub); + return { + data: profile, + meta: { fromCache: false, cachedAt: null }, + }; + } + + @Patch('me') + @ApiBearerAuth() + @ApiOperation({ summary: 'Update current user profile' }) + async updateProfile(@CurrentUser() user: JwtPayload, @Body() dto: UpdateProfileDto) { + const profile = await this.authService.updateProfile(user.sub, dto); + return { + data: profile, + meta: { fromCache: false, cachedAt: null }, + }; + } +} +``` + +- [ ] **Step 7: AuthModule** + +```typescript +// apps/backend/src/modules/auth/auth.module.ts +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { RolesGuard } from './guards/roles.guard'; + +@Module({ + imports: [ + JwtModule.register({}), + ], + controllers: [AuthController], + providers: [ + AuthService, + JwtAuthGuard, + RolesGuard, + ], + exports: [JwtAuthGuard, RolesGuard], +}) +export class AuthModule {} +``` + +--- + +### Task 4: Integrate AuthModule into app + +**Files:** +- Modify: `apps/backend/src/app.module.ts` +- Modify: `apps/backend/src/main.ts` +- Modify: `apps/backend/src/config/configuration.ts` +- Modify: `apps/backend/package.json` + +- [ ] **Step 1: Update configuration.ts** + +```typescript +// apps/backend/src/config/configuration.ts — add auth section +import { registerAs } from '@nestjs/config'; + +export default registerAs('app', () => ({ + port: parseInt(process.env.PORT || '3000', 10), + // ... existing config ... + auth: { + jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production', + jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-in-production', + jwtAccessExpires: process.env.JWT_ACCESS_EXPIRES || '15m', + jwtRefreshExpires: process.env.JWT_REFRESH_EXPIRES || '7d', + }, + // ... rest of config ... +})); +``` + +- [ ] **Step 2: Update app.module.ts** + +```typescript +// apps/backend/src/app.module.ts — add imports +imports: [ + ConfigModule.forRoot({ load: [configuration], isGlobal: true }), + PrismaModule, + CacheModule, + MoexClientModule, + HealthModule, + AuthModule, + SecuritiesModule, + SharesModule, + BondsModule, + CandlesModule, +], +``` + +- [ ] **Step 3: Update main.ts — add cookie-parser + Swagger bearer** + +```typescript +// apps/backend/src/main.ts +import * as cookieParser from 'cookie-parser'; + +// After app creation: +app.use(cookieParser()); + +// Swagger config: +const config = new DocumentBuilder() + .setTitle('MoexVibe API') + .setVersion('1.0.0') + .addBearerAuth() + .build(); +``` + +- [ ] **Step 4: Update .env with JWT secrets** + +``` +JWT_SECRET=dev-jwt-secret-change-in-production +JWT_REFRESH_SECRET=dev-refresh-secret-change-in-production +JWT_ACCESS_EXPIRES=15m +JWT_REFRESH_EXPIRES=7d +``` + +--- + +### Task 5: Frontend — auth types + refactored client + +**Files:** +- Modify: `apps/frontend/src/api/responses.ts` +- Modify: `apps/frontend/src/api/client.ts` +- Create: `apps/frontend/src/api/auth.ts` + +- [ ] **Step 1: Add auth types to responses.ts** + +```typescript +// apps/frontend/src/api/responses.ts — add: +export interface UserResponse { + id: number; + email: string; + name: string | null; + role: string; +} + +export interface AuthResponse { + user: UserResponse; + accessToken: string; +} +``` + +- [ ] **Step 2: Refactor client.ts — add auth interceptor pattern** + +```typescript +// apps/frontend/src/api/client.ts +import type { ApiEnvelope, ApiResponseMeta } from './responses'; + +const BASE = ''; + +let accessToken: string | null = null; +let onUnauthorized: (() => void) | null = null; + +export function setAccessToken(token: string | null) { + accessToken = token; +} + +export function getAccessToken(): string | null { + return accessToken; +} + +export function setOnUnauthorized(cb: () => void) { + onUnauthorized = cb; +} + +async function request( + path: string, + params?: Record, + options?: { skipAuth?: boolean }, +): Promise<{ data: T; meta: ApiResponseMeta }> { + const url = new URL(`${BASE}${path}`, window.location.origin); + if (params) { + for (const [k, v] of Object.entries(params)) { + if (v !== undefined) url.searchParams.set(k, v); + } + } + + const headers: Record = {}; + if (!options?.skipAuth && accessToken) { + headers['Authorization'] = `Bearer ${accessToken}`; + } + + const res = await fetch(url.toString(), { + headers, + credentials: 'include', + }); + + if (res.status === 401 && !options?.skipAuth) { + // Try to refresh + try { + const refreshRes = await fetch(`${BASE}/api/v1/auth/refresh`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + }); + + if (refreshRes.ok) { + const refreshJson: ApiEnvelope<{ data: AuthResponse; meta: ApiResponseMeta }> = await refreshRes.json(); + const authData = refreshJson.data.data; + setAccessToken(authData.accessToken); + // Retry original request with new token + headers['Authorization'] = `Bearer ${authData.accessToken}`; + const retryRes = await fetch(url.toString(), { headers, credentials: 'include' }); + if (!retryRes.ok) throw new Error(`API error: ${retryRes.status}`); + const retryJson: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await retryRes.json(); + return retryJson.data; + } else { + setAccessToken(null); + onUnauthorized?.(); + throw new Error('Session expired'); + } + } catch { + onUnauthorized?.(); + throw new Error('Session expired'); + } + } + + if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`); + const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json(); + return json.data; +} + +// Need to import AuthResponse type +import type { AuthResponse } from './responses'; + +// ... rest of existing functions stay the same +``` + +- [ ] **Step 3: Create auth.ts** + +```typescript +// apps/frontend/src/api/auth.ts +import { request, setAccessToken } from './client'; +import type { AuthResponse, UserResponse } from './responses'; + +export async function login(email: string, password: string) { + const result = await request('/api/v1/auth/login', undefined, { + method: 'POST', + body: { email, password }, + }); + setAccessToken(result.data.accessToken); + return result.data; +} + +export async function register(email: string, password: string, name?: string) { + const result = await request('/api/v1/auth/register', undefined, { + method: 'POST', + body: { email, password, name }, + }); + setAccessToken(result.data.accessToken); + return result.data; +} + +export async function refreshTokens() { + const result = await request('/api/v1/auth/refresh', undefined, { + skipAuth: true, + }); + setAccessToken(result.data.accessToken); + return result.data; +} + +export async function logout() { + await request<{ message: string }>('/api/v1/auth/logout', undefined, { + method: 'POST', + }); + setAccessToken(null); +} + +export async function getMe() { + return request('/api/v1/auth/me'); +} + +export async function updateProfile(data: { name?: string }) { + return request('/api/v1/auth/me', undefined, { + method: 'PATCH', + body: data, + }); +} +``` + +Wait — I need to rethink the client.ts approach. Currently `request()` uses GET with query params. For POST/PATCH requests I need to handle body. Let me redesign. + +Let me restructure: keep existing API functions untouched (they all use GET), and add a new `authRequest` function or modify `request` to support other methods. + +I'll modify the `request` function signature slightly: + +```typescript +async function request( + path: string, + params?: Record | { method: string; body?: unknown }, + options?: { method?: string; body?: unknown; skipAuth?: boolean }, +): Promise<{ data: T; meta: ApiResponseMeta }> +``` + +This is getting messy. Better approach: create a separate `authRequest` function or modify the existing request. Let me think... + +Actually the cleanest approach: modify the existing `request` to accept an options bag with `method`, `body`, `params` as separate fields: + +```typescript +interface RequestOptions { + params?: Record; + method?: string; + body?: unknown; + skipAuth?: boolean; +} +``` + +This way the existing callers still work with the old signature (params as second arg). + +Let me plan this properly in the implementation.<|end▁of▁thinking|> + +<||DSML||tool_calls> +<||DSML||invoke name="todowrite"> +<||DSML||parameter name="todos" string="false">[{"priority":"high","content":"Save implementation plan to docs/superpowers/plans/","status":"completed"},{"priority":"high","content":"Install backend dependencies (Prisma, JWT, bcrypt, cookie-parser)","status":"in_progress"},{"priority":"high","content":"Set up Prisma schema + PrismaService + PrismaModule","status":"pending"},{"priority":"high","content":"Build AuthService (register, login, refresh, logout) with TDD","status":"pending"},{"priority":"high","content":"Build Auth guards, decorators, DTOs, controller","status":"pending"},{"priority":"high","content":"Integrate AuthModule into app.module, main.ts, configuration","status":"pending"},{"priority":"high","content":"Frontend: refactor client.ts + add auth types","status":"pending"},{"priority":"high","content":"Frontend: AuthContext + useAuth hook","status":"pending"},{"priority":"high","content":"Frontend: Login, Register, Profile pages + ProtectedRoute","status":"pending"},{"priority":"high","content":"Frontend: Layout + routing changes","status":"pending"},{"priority":"high","content":"Documentation (backend auth.md, database.md, ADR, api.md, architecture.md)","status":"pending"},{"priority":"high","content":"Verification: run tests, lint, build","status":"pending"}] diff --git a/docs/superpowers/specs/2026-06-17-broker-operations-ui-improvements.md b/docs/features/broker-operations-ui-improvements/spec.md similarity index 100% rename from docs/superpowers/specs/2026-06-17-broker-operations-ui-improvements.md rename to docs/features/broker-operations-ui-improvements/spec.md diff --git a/docs/superpowers/plans/2026-06-17-broker-portfolio-display.md b/docs/features/broker-portfolio-display/plan.md similarity index 100% rename from docs/superpowers/plans/2026-06-17-broker-portfolio-display.md rename to docs/features/broker-portfolio-display/plan.md diff --git a/docs/superpowers/specs/2026-06-17-broker-portfolio-display.md b/docs/features/broker-portfolio-display/spec.md similarity index 100% rename from docs/superpowers/specs/2026-06-17-broker-portfolio-display.md rename to docs/features/broker-portfolio-display/spec.md diff --git a/docs/superpowers/specs/2026-06-17-broker-positions-pagination-and-loading.md b/docs/features/broker-positions-pagination-and-loading/spec.md similarity index 100% rename from docs/superpowers/specs/2026-06-17-broker-positions-pagination-and-loading.md rename to docs/features/broker-positions-pagination-and-loading/spec.md diff --git a/docs/features/ci-cd/plan.md b/docs/features/ci-cd/plan.md new file mode 100644 index 0000000..c94e210 --- /dev/null +++ b/docs/features/ci-cd/plan.md @@ -0,0 +1,110 @@ +# CI/CD Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add Gitea Actions CI pipeline with lint, test, and build for the MoexVibe monorepo. + +**Architecture:** Single `.gitea/workflows/ci.yml` file with three parallel jobs (lint, test, build) triggered on push/PR to main. One script addition to root `package.json` for format checking. + +**Tech Stack:** Gitea Actions (GitHub Actions-compatible YAML), Node.js 20, npm workspaces + +--- + +### Task 1: Add `format:check` script to root package.json + +**Files:** +- Modify: `package.json` (root) + +- [ ] **Step 1: Read root package.json** + +- [ ] **Step 2: Add format:check script** + +Edit `package.json`: add `"format:check": "prettier --check \"**/*.{ts,tsx}\""` to the `scripts` section, after `format`. + +- [ ] **Step 3: Verify the script runs** + +Run: `npm run format:check` +Expected: exits 0 (all files already formatted) or lists formatting errors + +- [ ] **Step 4: Commit** + +```bash +git add package.json +git commit -m "ci: add format:check script for CI pipeline" +``` + +--- + +### Task 2: Create Gitea Actions workflow + +**Files:** +- Create: `.gitea/workflows/ci.yml` + +- [ ] **Step 1: Create workflow directory** + +Run: `mkdir -p .gitea/workflows` + +- [ ] **Step 2: Create ci.yml with full pipeline** + +Create `.gitea/workflows/ci.yml`: + +```yaml +name: CI +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + NODE_VERSION: 20 + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - run: npm ci + - run: npm run lint + - run: npx prettier --check "**/*.{ts,tsx}" + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - run: npm ci + - run: npm run test:backend + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + - run: npm ci + - run: npm run build:backend + - run: npm run build:frontend +``` + +- [ ] **Step 3: Commit** + +```bash +git add .gitea/workflows/ci.yml +git commit -m "ci: add Gitea Actions pipeline with lint, test, build" +``` + +--- + +### Verification + +После пуша в `main` (или создания PR) проверить на https://git.ksv741.keenetic.pro/moex/moex-vibe/actions что pipeline запустился и все 3 job'а зелёные. diff --git a/docs/superpowers/specs/2026-06-13-cicd-design.md b/docs/features/ci-cd/spec.md similarity index 100% rename from docs/superpowers/specs/2026-06-13-cicd-design.md rename to docs/features/ci-cd/spec.md diff --git a/docs/superpowers/specs/2026-06-13-docusaurus-docs-design.md b/docs/features/docusaurus-docs/spec.md similarity index 100% rename from docs/superpowers/specs/2026-06-13-docusaurus-docs-design.md rename to docs/features/docusaurus-docs/spec.md diff --git a/docs/features/frontend-test-coverage/plan.md b/docs/features/frontend-test-coverage/plan.md new file mode 100644 index 0000000..e7106bb --- /dev/null +++ b/docs/features/frontend-test-coverage/plan.md @@ -0,0 +1,1920 @@ +# Frontend Test Coverage Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add comprehensive frontend unit tests (vitest + RTL + MSW) across all existing modules + +**Architecture:** MSW intercepts fetch at the network level, so no production code changes are needed. Each test uses `renderWithProviders` (QueryClient + AuthProvider + MemoryRouter wrapper). Fresh QueryClient per test with `retry: false`. + +**Tech Stack:** vitest, @testing-library/react, @testing-library/jest-dom, @testing-library/user-event, jsdom, msw v2 + +--- + +### Task 1: Infrastructure — vitest config + test helpers + +**Files:** +- Create: `apps/frontend/vitest.config.ts` +- Create: `apps/frontend/src/test/setup.ts` +- Create: `apps/frontend/src/test/server.ts` +- Create: `apps/frontend/src/test/handlers.ts` +- Create: `apps/frontend/src/test/test-utils.tsx` +- Create: `apps/frontend/src/test/factories.ts` +- Modify: `apps/frontend/package.json` (add test scripts) +- Modify: `package.json` (add root workspace script) + +- [ ] **Step 1: Create vitest.config.ts** + +```ts +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + globals: true, + }, +}); +``` + +- [ ] **Step 2: Create test/setup.ts** + +```ts +import '@testing-library/jest-dom'; +import { server } from './server'; + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), +}); +``` + +- [ ] **Step 3: Create test/server.ts** + +```ts +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; + +export const server = setupServer(...handlers); +``` + +- [ ] **Step 4: Create test/handlers.ts** + +```ts +import { http, HttpResponse } from 'msw'; +import type { + ShareResponse, + BondResponse, + CandleItem, + DividendItem, + SearchResultItem, + AuthResponse, + UserResponse, +} from '../api/responses'; + +const API = '/api/v1'; + +const mockUser: UserResponse = { + id: 1, + email: 'user@test.com', + name: 'Test User', + role: 'user', +}; + +const mockAuth: AuthResponse = { + user: mockUser, + accessToken: 'mock-access-token', +}; + +const mockShare: ShareResponse = { + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбер Банк', + shortName: 'Сбер', + latName: 'Sberbank', + listLevel: 1, + issueSize: 21586900000, + faceValue: 3, + faceUnit: 'RUB', + type: 'common_share', + marketData: { + price: 289.5, + change: 2.5, + changePercent: 0.87, + open: 287, + high: 291, + low: 286.5, + volume: 15000000, + value: 4350000000, + issueCapitalization: 6250000000000, + updatedAt: '2024-01-15T10:00:00Z', + }, +}; + +const mockBond: BondResponse = { + secid: 'SU26238RMFS5', + isin: 'RU000A101XU7', + name: 'ОФЗ 26238', + shortName: 'ОФЗ 26238', + latName: null, + listLevel: 1, + issueSize: 500000000, + faceValue: 1000, + faceUnit: 'RUB', + matDate: '2027-05-15', + couponValue: 36.9, + couponPercent: 7.5, + couponPeriod: 182, + nextCoupon: '2024-07-15', + accruedInt: 8.45, + bondType: 'ОФЗ', + bondSubType: 'ОФЗ-ПД', + offerDate: null, + buybackDate: null, + marketData: { + price: 98.5, + yieldToMaturity: 8.2, + duration: 3.5, + accruedInt: 8.45, + couponValue: 36.9, + couponPercent: 7.5, + nextCouponDate: '2024-07-15', + open: 98.0, + high: 99.0, + low: 97.5, + volume: 1000000, + updatedAt: '2024-01-15T10:00:00Z', + }, +}; + +const mockCandles: CandleItem[] = [ + { open: 280, high: 290, low: 278, close: 289.5, volume: 1000000, value: 280000000, begin: '2024-01-15T10:00:00Z', end: '2024-01-15T18:00:00Z' }, + { open: 289, high: 292, low: 285, close: 288, volume: 800000, value: 231200000, begin: '2024-01-16T10:00:00Z', end: '2024-01-16T18:00:00Z' }, +]; + +const mockDividends: DividendItem[] = [ + { registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' }, + { registryCloseDate: '2023-10-05', value: 30.0, currency: 'RUB' }, +]; + +const mockSearchResults: SearchResultItem[] = [ + { secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбер', type: 'share', listLevel: 1, currency: 'RUB', price: 289.5 }, + { secid: 'VTBR', isin: 'RU000A0JP5V6', shortName: 'ВТБ', type: 'share', listLevel: 1, currency: 'RUB', price: 0.0234 }, +]; + +export const handlers = [ + http.get(`${API}/securities/search`, ({ request }) => { + const url = new URL(request.url); + const q = url.searchParams.get('q') || ''; + if (q.length < 2) return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); + const filtered = mockSearchResults.filter(r => + r.secid.toLowerCase().includes(q.toLowerCase()) || + r.shortName.toLowerCase().includes(q.toLowerCase()), + ); + return HttpResponse.json({ data: { data: filtered, meta: { fromCache: false, cachedAt: null } } }); + }), + + http.get(`${API}/securities/shares/:secid`, ({ params }) => { + const { secid } = params; + if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 }); + return HttpResponse.json({ data: { data: { ...mockShare, secid } as ShareResponse, meta: { fromCache: false, cachedAt: null } } }); + }), + + http.get(`${API}/securities/shares/:secid/candles`, () => + HttpResponse.json({ data: { data: mockCandles, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.get(`${API}/securities/shares/:secid/dividends`, () => + HttpResponse.json({ data: { data: mockDividends, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.get(`${API}/securities/bonds/:secid`, ({ params }) => { + const { secid } = params; + if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 }); + return HttpResponse.json({ data: { data: { ...mockBond, secid } as BondResponse, meta: { fromCache: false, cachedAt: null } } }); + }), + + http.get(`${API}/securities/bonds/:secid/candles`, () => + HttpResponse.json({ data: { data: mockCandles, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.get(`${API}/auth/me`, () => + HttpResponse.json({ data: { data: mockUser, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.post(`${API}/auth/login`, () => + HttpResponse.json({ data: { data: mockAuth, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.post(`${API}/auth/register`, () => + HttpResponse.json({ data: { data: mockAuth, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.post(`${API}/auth/refresh`, () => + HttpResponse.json({ data: { data: mockAuth, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.post(`${API}/auth/logout`, () => new HttpResponse(null, { status: 204 })), + + http.patch(`${API}/auth/me`, () => + HttpResponse.json({ data: { data: { ...mockUser, name: 'Updated' }, meta: { fromCache: false, cachedAt: null } } }), + ), + + http.get(`${API}/health`, () => + HttpResponse.json({ data: { data: { status: 'ok', timestamp: new Date().toISOString(), uptime: 12345 }, meta: { fromCache: false, cachedAt: null } } }), + ), +]; + +export { mockUser, mockAuth, mockShare, mockBond, mockCandles, mockDividends, mockSearchResults }; +``` + +- [ ] **Step 5: Create test/test-utils.tsx** + +```tsx +import { type ReactElement } from 'react'; +import { render, type RenderOptions } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; +import { AuthProvider } from '../context/AuthContext'; + +interface CustomRenderOptions extends Omit { + queryClient?: QueryClient; + route?: string; +} + +function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }); +} + +export function renderWithProviders( + ui: ReactElement, + { queryClient = createTestQueryClient(), route = '/', ...renderOptions }: CustomRenderOptions = {}, +) { + function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ); + } + + return { ...render(ui, { wrapper: Wrapper, ...renderOptions }), queryClient }; +} +``` + +- [ ] **Step 6: Create test/factories.ts** + +```ts +import type { ShareResponse, BondResponse, CandleItem, DividendItem, SearchResultItem, UserResponse, AuthResponse } from '../api/responses'; + +export function createMockShare(overrides: Partial = {}): ShareResponse { + return { + secid: 'SBER', + isin: 'RU0009029540', + name: 'Сбер Банк', + shortName: 'Сбер', + latName: 'Sberbank', + listLevel: 1, + issueSize: 21586900000, + faceValue: 3, + faceUnit: 'RUB', + type: 'common_share', + marketData: { + price: 289.5, + change: 2.5, + changePercent: 0.87, + open: 287, + high: 291, + low: 286.5, + volume: 15000000, + value: 4350000000, + issueCapitalization: 6250000000000, + updatedAt: '2024-01-15T10:00:00Z', + }, + ...overrides, + }; +} + +export function createMockBond(overrides: Partial = {}): BondResponse { + return { + secid: 'SU26238RMFS5', + isin: 'RU000A101XU7', + name: 'ОФЗ 26238', + shortName: 'ОФЗ 26238', + latName: null, + listLevel: 1, + issueSize: 500000000, + faceValue: 1000, + faceUnit: 'RUB', + matDate: '2027-05-15', + couponValue: 36.9, + couponPercent: 7.5, + couponPeriod: 182, + nextCoupon: '2024-07-15', + accruedInt: 8.45, + bondType: 'ОФЗ', + bondSubType: 'ОФЗ-ПД', + offerDate: null, + buybackDate: null, + marketData: { + price: 98.5, + yieldToMaturity: 8.2, + duration: 3.5, + accruedInt: 8.45, + couponValue: 36.9, + couponPercent: 7.5, + nextCouponDate: '2024-07-15', + open: 98.0, + high: 99.0, + low: 97.5, + volume: 1000000, + updatedAt: '2024-01-15T10:00:00Z', + }, + ...overrides, + }; +} + +export function createMockUser(overrides: Partial = {}): UserResponse { + return { id: 1, email: 'user@test.com', name: 'Test User', role: 'user', ...overrides }; +} + +export function createMockAuth(overrides: Partial = {}): AuthResponse { + return { user: createMockUser(), accessToken: 'mock-token', ...overrides }; +} + +export function createMockCandles(count = 2): CandleItem[] { + return Array.from({ length: count }, (_, i) => ({ + open: 280 + i, high: 290 + i, low: 278 + i, close: 289.5 + i, + volume: 1000000, value: 280000000, + begin: `2024-01-${15 + i}T10:00:00Z`, + end: `2024-01-${15 + i}T18:00:00Z`, + })); +} + +export function createMockDividends(): DividendItem[] { + return [ + { registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' }, + ]; +} + +export function createMockSearchResults(): SearchResultItem[] { + return [ + { secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбер', type: 'share', listLevel: 1, currency: 'RUB', price: 289.5 }, + { secid: 'VTBR', isin: 'RU000A0JP5V6', shortName: 'ВТБ', type: 'share', listLevel: 1, currency: 'RUB', price: 0.0234 }, + ]; +} +``` + +- [ ] **Step 7: Add scripts to apps/frontend/package.json** + +```json +"test": "vitest run", +"test:watch": "vitest" +``` + +- [ ] **Step 8: Add to root package.json** + +```json +"test:frontend": "npm run test -w apps/frontend" +``` + +- [ ] **Step 9: Install dependencies** + +Run: `npm install -w apps/frontend vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom msw @types/node` + +- [ ] **Step 10: Verify setup** + +Run: `npm run test:frontend` +Expected: "No test files found, exiting with code 0" (no tests yet, but config works) + +--- + +### Task 2: API client tests + +**Files:** +- Create: `apps/frontend/src/api/client.test.ts` + +- [ ] **Step 1: Create client.test.ts** + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { request, setAccessToken, getAccessToken, setOnUnauthorized } from './client'; + +const API = '/api/v1'; + +beforeEach(() => { + setAccessToken(null); +}); + +describe('request', () => { + it('makes GET request and returns data', async () => { + const result = await request<{ message: string }>('/api/v1/health'); + expect(result.data).toEqual({ + status: 'ok', + timestamp: expect.any(String), + uptime: 12345, + }); + }); + + it('includes Authorization header when token is set', async () => { + setAccessToken('test-token'); + let capturedAuth: string | null = null; + server.use( + http.get(`${API}/test-auth`, ({ request }) => { + capturedAuth = request.headers.get('Authorization'); + return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } }); + }), + ); + await request('/api/v1/test-auth'); + expect(capturedAuth).toBe('Bearer test-token'); + }); + + it('retries on 401 and succeeds after refresh', async () => { + setAccessToken('expired-token'); + let attempts = 0; + server.use( + http.get(`${API}/test-retry`, ({ request }) => { + attempts++; + const auth = request.headers.get('Authorization'); + if (auth === 'Bearer expired-token') { + return new HttpResponse(null, { status: 401 }); + } + return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } }); + }), + http.post(`${API}/auth/refresh`, () => + HttpResponse.json({ data: { data: { user: { id: 1, email: 'user@test.com', name: null, role: 'user' }, accessToken: 'new-token' }, meta: { fromCache: false, cachedAt: null } } }), + ), + ); + const result = await request<{ ok: boolean }>('/api/v1/test-retry'); + expect(attempts).toBe(2); + expect(result.data).toEqual({ ok: true }); + expect(getAccessToken()).toBe('new-token'); + }); + + it('throws on persistent 401 and clears token', async () => { + setAccessToken('expired-token'); + let unauthorizedCalled = false; + setOnUnauthorized(() => { unauthorizedCalled = true; }); + server.use( + http.get(`${API}/test-fail`, () => new HttpResponse(null, { status: 401 })), + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + await expect(request('/api/v1/test-fail')).rejects.toThrow('Сессия истекла'); + expect(getAccessToken()).toBeNull(); + expect(unauthorizedCalled).toBe(true); + }); + + it('throws on non-ok response with status text', async () => { + server.use( + http.get(`${API}/test-error`, () => new HttpResponse('Not found', { status: 404, statusText: 'Not Found' })), + ); + await expect(request('/api/v1/test-error')).rejects.toThrow('Ошибка API: 404'); + }); + + it('sends JSON body for POST requests', async () => { + let capturedBody: string | null = null; + server.use( + http.post(`${API}/test-post`, async ({ request }) => { + capturedBody = await request.text(); + return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } }); + }), + ); + await request('/api/v1/test-post', undefined, { method: 'POST', body: { foo: 'bar' } }); + expect(capturedBody).toBe(JSON.stringify({ foo: 'bar' })); + }); + + it('does not send auth header when skipAuth is true', async () => { + setAccessToken('test-token'); + let capturedAuth: string | null = null; + server.use( + http.get(`${API}/test-skip`, ({ request }) => { + capturedAuth = request.headers.get('Authorization'); + return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } }); + }), + ); + await request('/api/v1/test-skip', undefined, { skipAuth: true }); + expect(capturedAuth).toBeNull(); + }); + + it('sets query params correctly', async () => { + let capturedUrl = ''; + server.use( + http.get(`${API}/test-params`, ({ request }) => { + capturedUrl = request.url; + return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } }); + }), + ); + await request('/api/v1/test-params', { q: 'sber', type: 'share' }); + expect(capturedUrl).toContain('q=sber'); + expect(capturedUrl).toContain('type=share'); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run apps/frontend/src/api/client.test.ts -w apps/frontend` +Expected: all passing + +--- + +### Task 3: Auth API tests + +**Files:** +- Create: `apps/frontend/src/api/auth.test.ts` + +- [ ] **Step 1: Create auth.test.ts** + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { setAccessToken, getAccessToken } from './client'; +import { login, register, refresh, logout, getMe, updateProfile } from './auth'; + +const API = '/api/v1'; + +beforeEach(() => { + setAccessToken(null); +}); + +describe('login', () => { + it('returns auth data and sets access token', async () => { + const result = await login('user@test.com', 'password'); + expect(result.user.email).toBe('user@test.com'); + expect(result.accessToken).toBe('mock-access-token'); + expect(getAccessToken()).toBe('mock-access-token'); + }); + + it('throws on invalid credentials', async () => { + server.use( + http.post(`${API}/auth/login`, () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' })), + ); + await expect(login('wrong@test.com', 'wrong')).rejects.toThrow(); + }); +}); + +describe('register', () => { + it('returns auth data and sets access token', async () => { + const result = await register('new@test.com', 'password', 'New User'); + expect(result.user.email).toBe('user@test.com'); + expect(getAccessToken()).toBe('mock-access-token'); + }); +}); + +describe('refresh', () => { + it('returns auth data and sets access token', async () => { + const result = await refresh(); + expect(result.accessToken).toBe('mock-access-token'); + expect(getAccessToken()).toBe('mock-access-token'); + }); +}); + +describe('logout', () => { + it('clears access token', async () => { + setAccessToken('test-token'); + await logout(); + expect(getAccessToken()).toBeNull(); + }); +}); + +describe('getMe', () => { + it('returns current user', async () => { + const result = await getMe(); + expect(result.email).toBe('user@test.com'); + }); +}); + +describe('updateProfile', () => { + it('updates and returns user', async () => { + const result = await updateProfile({ name: 'Updated' }); + expect(result.name).toBe('Updated'); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run apps/frontend/src/api/auth.test.ts -w apps/frontend` +Expected: all passing + +--- + +### Task 4: AuthContext tests + +**Files:** +- Create: `apps/frontend/src/context/AuthContext.test.tsx` + +- [ ] **Step 1: Create AuthContext.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { AuthProvider, AuthContext, type AuthContextValue } from './AuthContext'; +import { useContext } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const API = '/api/v1'; + +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + {ui} + , + ); +} + +function TestConsumer() { + const ctx = useContext(AuthContext); + if (!ctx) return
no context
; + return ( +
+ {ctx.isAuthenticated ? 'authenticated' : 'anonymous'} + {ctx.user?.email ?? ''} + + + + +
+ ); +} + +describe('AuthContext', () => { + it('starts unauthenticated when refresh fails', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + renderWithProviders( + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('auth')).toHaveTextContent('anonymous'); + }); + }); + + it('restores session on mount when refresh succeeds', async () => { + renderWithProviders( + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('auth')).toHaveTextContent('authenticated'); + expect(screen.getByTestId('email')).toHaveTextContent('user@test.com'); + }); + }); + + it('updates state after login', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('anonymous')); + await user.click(screen.getByRole('button', { name: 'login' })); + await waitFor(() => { + expect(screen.getByTestId('auth')).toHaveTextContent('authenticated'); + }); + }); + + it('updates state after logout', async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('authenticated')); + await user.click(screen.getByRole('button', { name: 'logout' })); + await waitFor(() => { + expect(screen.getByTestId('auth')).toHaveTextContent('anonymous'); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +Run: `npx vitest run apps/frontend/src/context/AuthContext.test.tsx -w apps/frontend` +Expected: all passing + +--- + +### Task 5: Hook tests + +**Files:** +- Create: `apps/frontend/src/hooks/useAuth.test.tsx` +- Create: `apps/frontend/src/hooks/useSearch.test.tsx` +- Create: `apps/frontend/src/hooks/useStock.test.tsx` +- Create: `apps/frontend/src/hooks/useStockCandles.test.tsx` +- Create: `apps/frontend/src/hooks/useStockDividends.test.tsx` +- Create: `apps/frontend/src/hooks/useBond.test.tsx` +- Create: `apps/frontend/src/hooks/useBondCandles.test.tsx` + +- [ ] **Step 1: Create useAuth.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useAuth } from './useAuth'; +import { renderWithProviders } from '../test/test-utils'; + +describe('useAuth', () => { + it('returns auth context values', async () => { + const { result } = renderHook(() => useAuth(), { wrapper: ({ children }) => { + const { Wrapper } = renderWithProviders(<>{children}); + return <>{children}; + }}); + // Actually use renderWithProviders via wrapper + }); + + it('throws when used outside AuthProvider', () => { + const { renderHook: render } = await import('@testing-library/react'); + // Will be tested inline + }); +}); +``` + +Wait — actually let me write proper, complete tests. Let me re-do this. + +- [ ] **Step 1: Create useAuth.test.tsx properly** + +```tsx +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { AuthProvider } from '../context/AuthContext'; +import { useAuth } from './useAuth'; +import { type ReactNode } from 'react'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +describe('useAuth', () => { + it('returns auth context with user after mount', async () => { + const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); + await waitFor(() => { + expect(result.current.isAuthenticated).toBe(true); + }); + expect(result.current.user?.email).toBe('user@test.com'); + expect(result.current.accessToken).toBe('mock-access-token'); + }); + + it('provides login function', async () => { + const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + expect(typeof result.current.login).toBe('function'); + }); + + it('provides logout function', async () => { + const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + expect(typeof result.current.logout).toBe('function'); + }); + + it('provides register function', async () => { + const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + expect(typeof result.current.register).toBe('function'); + }); + + it('throws when used without AuthProvider', () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + expect(() => { + renderHook(() => useAuth(), { + wrapper: ({ children }) => ( + {children} + ), + }); + }).toThrow('useAuth must be used within an AuthProvider'); + }); +}); +``` + +- [ ] **Step 2: Create useSearch.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useSearch } from './useSearch'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useSearch', () => { + it('does not fetch when query is empty', () => { + const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() }); + expect(result.current.isFetching).toBe(false); + expect(result.current.data).toBeUndefined(); + }); + + it('does not fetch when query is too short', () => { + const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() }); + expect(result.current.data).toBeUndefined(); + }); + + it('returns search results for valid query', async () => { + const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() }); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(result.current.data).toBeDefined(); + expect(result.current.data?.length).toBeGreaterThan(0); + expect(result.current.data?.[0].secid).toBe('SBER'); + }); + + it('returns empty array when no results', async () => { + server.use( + http.get(`${API}/securities/search`, () => { + return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); + }), + ); + const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() }); + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(result.current.data).toEqual([]); + }); + + it('returns error state on network failure', async () => { + server.use( + http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 })), + ); + const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() }); + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + }); +}); +``` + +- [ ] **Step 3: Create useStock.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useStock } from './useStock'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useStock', () => { + it('returns share data', async () => { + const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.secid).toBe('SBER'); + expect(result.current.data?.shortName).toBe('Сбер'); + expect(result.current.data?.marketData.price).toBe(289.5); + }); + + it('returns error for not found', async () => { + const { result } = renderHook(() => useStock('NOTFOUND'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isError).toBe(true)); + }); + + it('starts in loading state', () => { + const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() }); + expect(result.current.isLoading).toBe(true); + }); +}); +``` + +- [ ] **Step 4: Create useStockCandles.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useStockCandles } from './useStockCandles'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useStockCandles', () => { + it('returns candle data', async () => { + const { result } = renderHook( + () => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(2); + expect(result.current.data?.[0].open).toBe(280); + }); + + it('returns empty array when no candles', async () => { + server.use( + http.get(`${API}/securities/shares/:secid/candles`, () => { + return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); + }), + ); + const { result } = renderHook( + () => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + }); +}); +``` + +- [ ] **Step 5: Create useStockDividends.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useStockDividends } from './useStockDividends'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useStockDividends', () => { + it('returns dividend data', async () => { + const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(2); + expect(result.current.data?.[0].value).toBe(35); + }); + + it('returns empty array when no dividends', async () => { + server.use( + http.get(`${API}/securities/shares/:secid/dividends`, () => { + return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); + }), + ); + const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + }); +}); +``` + +- [ ] **Step 6: Create useBond.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useBond } from './useBond'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useBond', () => { + it('returns bond data', async () => { + const { result } = renderHook(() => useBond('SU26238RMFS5'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.shortName).toBe('ОФЗ 26238'); + expect(result.current.data?.marketData.price).toBe(98.5); + }); + + it('returns error on 404', async () => { + const { result } = renderHook(() => useBond('NOTFOUND'), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isError).toBe(true)); + }); +}); +``` + +- [ ] **Step 7: Create useBondCandles.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { useBondCandles } from './useBondCandles'; +import { type ReactNode } from 'react'; + +const API = '/api/v1'; + +function createWrapper() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe('useBondCandles', () => { + it('returns candle data', async () => { + const { result } = renderHook( + () => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toHaveLength(2); + }); + + it('returns empty array when no candles', async () => { + server.use( + http.get(`${API}/securities/bonds/:secid/candles`, () => { + return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); + }), + ); + const { result } = renderHook( + () => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual([]); + }); +}); +``` + +- [ ] **Step 8: Run all hook tests** + +Run: `npx vitest run apps/frontend/src/hooks/ -w apps/frontend` +Expected: all passing + +--- + +### Task 6: UI Component tests + +**Files:** +- Create: `apps/frontend/src/components/SearchBar.test.tsx` +- Create: `apps/frontend/src/components/ProtectedRoute.test.tsx` +- Create: `apps/frontend/src/components/StockDetails.test.tsx` +- Create: `apps/frontend/src/components/BondDetails.test.tsx` +- Create: `apps/frontend/src/components/PriceChart.test.tsx` +- Create: `apps/frontend/src/components/Layout.test.tsx` + +- [ ] **Step 1: Create SearchBar.test.tsx** + +```tsx +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../test/test-utils'; + +describe('SearchBar', () => { + it('renders input with placeholder', () => { + renderWithProviders(
); // SearchBar is inside Layout, test it within + }); +}); +``` + +Actually the SearchBar is rendered through Layout but can be tested standalone. Let me fix: + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { SearchBar } from './SearchBar'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +function renderSearchBar() { + return renderWithProviders(); +} + +describe('SearchBar', () => { + it('renders search input', () => { + renderSearchBar(); + expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument(); + }); + + it('shows dropdown on focus', async () => { + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + await userEvent.type(input, 'sber'); + await waitFor(() => { + expect(screen.getByText('Сбер')).toBeInTheDocument(); + }); + }); + + it('shows loading state while fetching', async () => { + server.use( + http.get(`${API}/securities/search`, () => { + return new Promise(() => {}); // never resolves + }), + ); + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + await userEvent.type(input, 'sber'); + await waitFor(() => { + expect(screen.getByText('Загрузка...')).toBeInTheDocument(); + }); + }); + + it('shows no results message', async () => { + server.use( + http.get(`${API}/securities/search`, () => { + return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); + }), + ); + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + await userEvent.type(input, 'zzzzz'); + await waitFor(() => { + expect(screen.getByText('Ничего не найдено')).toBeInTheDocument(); + }); + }); + + it('hides dropdown when clicking outside', async () => { + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + await userEvent.type(input, 'sber'); + await waitFor(() => { + expect(screen.getByText('Сбер')).toBeInTheDocument(); + }); + await userEvent.click(document.body); + await waitFor(() => { + expect(screen.queryByText('Сбер')).not.toBeInTheDocument(); + }); + }); + + it('navigates to stock page on share result click', async () => { + const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() })); + vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { ...(actual as object), useNavigate: () => mockNavigate }; + }); + + renderSearchBar(); + const input = screen.getByPlaceholderText('Поиск акций и облигаций...'); + await userEvent.type(input, 'sber'); + await waitFor(() => { + expect(screen.getByText('Сбер')).toBeInTheDocument(); + }); + await userEvent.click(screen.getByText('Сбер')); + expect(mockNavigate).toHaveBeenCalledWith('/stocks/SBER'); + }); +}); +``` + +- [ ] **Step 2: Create ProtectedRoute.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ProtectedRoute } from './ProtectedRoute'; + +describe('ProtectedRoute', () => { + it('renders children when authenticated', async () => { + // Renders through AuthProvider which auto-authenticates via refresh + const { renderWithProviders } = await import('../test/test-utils'); + renderWithProviders( + +
Secret
+
, + ); + const content = await screen.findByTestId('protected-content'); + expect(content).toBeInTheDocument(); + }); + + it('redirects to login when not authenticated', async () => { + const { http, HttpResponse } = await import('msw'); + const { server } = await import('../test/server'); + const API = '/api/v1'; + + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + + const { renderWithProviders } = await import('../test/test-utils'); + renderWithProviders( + +
Secret
+
, + { route: '/profile' }, + ); + + await screen.findByText('Загрузка...'); + + // After loading, should redirect to login + // Use findByText for async redirect check + }); +}); +``` + +Actually this imports are messy with dynamic imports. Let me write cleaner: + +```tsx +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { ProtectedRoute } from './ProtectedRoute'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('ProtectedRoute', () => { + it('renders children when authenticated', async () => { + renderWithProviders( + +
Secret
+
, + ); + expect(await screen.findByTestId('protected-content')).toBeInTheDocument(); + }); + + it('shows loading state initially then redirects when not authenticated', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + + renderWithProviders( + +
Secret
+
, + { route: '/profile' }, + ); + + expect(screen.getByText('Загрузка...')).toBeInTheDocument(); + + await waitFor(() => { + // After auth resolves, should redirect — we check the protected content is gone + expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument(); + }); + }); +}); +``` + +- [ ] **Step 3: Create StockDetails.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { StockDetails } from './StockDetails'; +import { createMockShare } from '../test/factories'; + +describe('StockDetails', () => { + it('renders stock details', () => { + const stock = createMockShare(); + render(); + expect(screen.getByText('Сбер (SBER)')).toBeInTheDocument(); + expect(screen.getByText('Сбер Банк · RU0009029540')).toBeInTheDocument(); + }); + + it('displays positive change in green', () => { + const stock = createMockShare({ marketData: { ...createMockShare().marketData, change: 5, changePercent: 2 } }); + render(); + const changeEl = screen.getByText('+5.00 (2.00%)'); + expect(changeEl).toBeInTheDocument(); + expect(changeEl).toHaveStyle({ color: 'var(--color-positive)' }); + }); + + it('displays negative change in red', () => { + const stock = createMockShare({ marketData: { ...createMockShare().marketData, change: -3, changePercent: -1 } }); + render(); + const changeEl = screen.getByText('-3.00 (-1.00%)'); + expect(changeEl).toBeInTheDocument(); + expect(changeEl).toHaveStyle({ color: 'var(--color-negative)' }); + }); + + it('shows price formatted', () => { + const stock = createMockShare(); + render(); + expect(screen.getByText('289,50')).toBeInTheDocument(); + }); + + it('shows dash for null high', () => { + const stock = createMockShare({ marketData: { ...createMockShare().marketData, high: null } }); + render(); + const rows = screen.getAllByText('—'); + expect(rows.length).toBeGreaterThanOrEqual(1); + }); + + it('shows capitalization in billions', () => { + const stock = createMockShare(); + render(); + expect(screen.getByText('6250,00 млрд ₽')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 4: Create BondDetails.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { BondDetails } from './BondDetails'; +import { createMockBond } from '../test/factories'; + +describe('BondDetails', () => { + it('renders bond details', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument(); + expect(screen.getByText('RU000A101XU7')).toBeInTheDocument(); + }); + + it('shows price as percentage', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('98.50%')).toBeInTheDocument(); + }); + + it('renders maturity date', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('2027-05-15')).toBeInTheDocument(); + }); + + it('shows coupon with percentage', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('36.9 ₽ (7.5%)')).toBeInTheDocument(); + }); + + it('shows coupon without percentage when null', () => { + const bond = createMockBond({ marketData: { ...createMockBond().marketData, couponPercent: null } }); + render(); + expect(screen.getByText('36.9 ₽')).toBeInTheDocument(); + }); + + it('shows dash for missing next coupon date', () => { + const bond = createMockBond({ marketData: { ...createMockBond().marketData, nextCouponDate: null } }); + render(); + const dashes = screen.getAllByText('—'); + expect(dashes.length).toBeGreaterThanOrEqual(1); + }); + + it('shows dash for null yieldToMaturity', () => { + const bond = createMockBond({ marketData: { ...createMockBond().marketData, yieldToMaturity: null } }); + render(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('shows bond type', () => { + const bond = createMockBond(); + render(); + expect(screen.getByText('ОФЗ')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 5: Create PriceChart.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { PriceChart } from './PriceChart'; + +describe('PriceChart', () => { + it('renders chart container', () => { + const { container } = render(); + expect(container.querySelector('div')).toBeInTheDocument(); + }); + + it('renders with candle data', () => { + const data = [ + { open: 100, high: 110, low: 95, close: 105, begin: '2024-01-15T10:00:00Z' }, + ]; + const { container } = render(); + expect(container.querySelector('div')).toBeInTheDocument(); + }); + + it('accepts custom height', () => { + const { container } = render(); + expect(container.querySelector('div')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 6: Create Layout.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { Layout } from './Layout'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('Layout', () => { + it('renders logo and search bar', async () => { + renderWithProviders(); + expect(screen.getByText('MoexVibe')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument(); + }); + + it('shows login link when not authenticated', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Войти')).toBeInTheDocument(); + }); + }); + + it('shows user name and logout when authenticated', async () => { + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('Test User')).toBeInTheDocument(); + expect(screen.getByText('Выйти')).toBeInTheDocument(); + }); + }); + + it('shows email when user has no name', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => { + return HttpResponse.json({ + data: { + data: { user: { id: 1, email: 'user@test.com', name: null, role: 'user' }, accessToken: 'mock' }, + meta: { fromCache: false, cachedAt: null }, + }, + }); + }), + ); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByText('user@test.com')).toBeInTheDocument(); + }); + }); +}); +``` + +- [ ] **Step 7: Run component tests** + +Run: `npx vitest run apps/frontend/src/components/ -w apps/frontend` +Expected: all passing + +--- + +### Task 7: Page tests + +**Files:** +- Create: `apps/frontend/src/pages/HomePage.test.tsx` +- Create: `apps/frontend/src/pages/StockPage.test.tsx` +- Create: `apps/frontend/src/pages/BondPage.test.tsx` +- Create: `apps/frontend/src/pages/LoginPage.test.tsx` +- Create: `apps/frontend/src/pages/RegisterPage.test.tsx` +- Create: `apps/frontend/src/pages/ProfilePage.test.tsx` + +- [ ] **Step 1: Create HomePage.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { HomePage } from './HomePage'; + +describe('HomePage', () => { + it('renders welcome title', () => { + render(); + expect(screen.getByText('MoexVibe')).toBeInTheDocument(); + }); + + it('renders description', () => { + render(); + expect(screen.getByText('Анализ акций и облигаций Московской биржи')).toBeInTheDocument(); + }); + + it('renders hint text', () => { + render(); + expect(screen.getByText(/Введите название или тикер/)).toBeInTheDocument(); + }); + + it('renders data delay notice', () => { + render(); + expect(screen.getByText(/Данные задерживаются на 15 минут/)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Create StockPage.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { StockPage } from './StockPage'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('StockPage', () => { + it('shows loading state', () => { + server.use( + http.get(`${API}/securities/shares/:secid`, () => new Promise(() => {})), + ); + renderWithProviders(, { route: '/stocks/SBER' }); + expect(screen.getByText('Загрузка...')).toBeInTheDocument(); + }); + + it('renders stock details after loading', async () => { + renderWithProviders(, { route: '/stocks/SBER' }); + expect(await screen.findByText('Сбер (SBER)')).toBeInTheDocument(); + }); + + it('renders price chart', async () => { + renderWithProviders(, { route: '/stocks/SBER' }); + expect(await screen.findByText('График цены')).toBeInTheDocument(); + }); + + it('renders dividends section', async () => { + renderWithProviders(, { route: '/stocks/SBER' }); + expect(await screen.findByText('Дивиденды')).toBeInTheDocument(); + expect(await screen.findByText('Дата закрытия реестра')).toBeInTheDocument(); + }); + + it('hides dividends section when empty', async () => { + server.use( + http.get(`${API}/securities/shares/:secid/dividends`, () => { + return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } }); + }), + ); + renderWithProviders(, { route: '/stocks/SBER' }); + await waitFor(() => { + expect(screen.queryByText('Дивиденды')).not.toBeInTheDocument(); + }); + }); + + it('shows error state for not found', async () => { + server.use( + http.get(`${API}/securities/shares/:secid`, () => new HttpResponse(null, { status: 404 })), + http.get(`${API}/securities/shares/:secid/candles`, () => new HttpResponse(null, { status: 404 })), + http.get(`${API}/securities/shares/:secid/dividends`, () => new HttpResponse(null, { status: 404 })), + ); + renderWithProviders(, { route: '/stocks/NOTFOUND' }); + expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 3: Create BondPage.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { screen } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { BondPage } from './BondPage'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('BondPage', () => { + it('shows loading state', () => { + server.use( + http.get(`${API}/securities/bonds/:secid`, () => new Promise(() => {})), + ); + renderWithProviders(, { route: '/bonds/SU26238RMFS5' }); + expect(screen.getByText('Загрузка...')).toBeInTheDocument(); + }); + + it('renders bond details after loading', async () => { + renderWithProviders(, { route: '/bonds/SU26238RMFS5' }); + expect(await screen.findByText('ОФЗ 26238')).toBeInTheDocument(); + }); + + it('renders price chart', async () => { + renderWithProviders(, { route: '/bonds/SU26238RMFS5' }); + expect(await screen.findByText('График цены')).toBeInTheDocument(); + }); + + it('shows error state for not found', async () => { + server.use( + http.get(`${API}/securities/bonds/:secid`, () => new HttpResponse(null, { status: 404 })), + http.get(`${API}/securities/bonds/:secid/candles`, () => new HttpResponse(null, { status: 404 })), + ); + renderWithProviders(, { route: '/bonds/NOTFOUND' }); + expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 4: Create LoginPage.test.tsx** + +```tsx +import { describe, it, expect, vi } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { LoginPage } from './LoginPage'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('LoginPage', () => { + it('renders login form', () => { + // Mock refresh to fail so we don't auto-auth + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + renderWithProviders(, { route: '/login' }); + expect(screen.getByText('Вход')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument(); + }); + + it('redirects to home on successful login', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/login' }); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); + await user.type(screen.getByPlaceholderText('••••••••'), 'password'); + await user.click(screen.getByRole('button', { name: 'Войти' })); + + await waitFor(() => { + // Login succeeded, should redirect + expect(screen.queryByText('Вход')).not.toBeInTheDocument(); + }); + }); + + it('shows error on failed login', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + http.post(`${API}/auth/login`, () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' })), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/login' }); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'bad@test.com'); + await user.type(screen.getByPlaceholderText('••••••••'), 'wrong'); + await user.click(screen.getByRole('button', { name: 'Войти' })); + + expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument(); + }); + + it('shows loading state while submitting', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + http.post(`${API}/auth/login`, () => new Promise(() => {})), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/login' }); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); + await user.type(screen.getByPlaceholderText('••••••••'), 'password'); + await user.click(screen.getByRole('button', { name: 'Войти' })); + + expect(screen.getByText('Вход...')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 5: Create RegisterPage.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { RegisterPage } from './RegisterPage'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('RegisterPage', () => { + it('renders registration form', () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + renderWithProviders(, { route: '/register' }); + expect(screen.getByText('Регистрация')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Иван Иванов')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument(); + }); + + it('shows password mismatch error', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/register' }); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); + await user.type(screen.getAllByPlaceholderText(/Пароль/)[0], 'password1'); + await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password2'); + await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })); + + expect(await screen.findByText('Пароли не совпадают')).toBeInTheDocument(); + }); + + it('redirects on successful registration', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/register' }); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com'); + await user.type(screen.getAllByPlaceholderText(/Пароль/)[0], 'password'); + await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password'); + await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })); + + await waitFor(() => { + expect(screen.queryByText('Регистрация')).not.toBeInTheDocument(); + }); + }); + + it('shows error on failed registration', async () => { + server.use( + http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })), + http.post(`${API}/auth/register`, () => new HttpResponse(null, { status: 409, statusText: 'Conflict' })), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/register' }); + + await user.type(screen.getByPlaceholderText('email@example.com'), 'existing@test.com'); + await user.type(screen.getAllByPlaceholderText(/Пароль/)[0], 'password'); + await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password'); + await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' })); + + expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 6: Create ProfilePage.test.tsx** + +```tsx +import { describe, it, expect } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../test/server'; +import { ProfilePage } from './ProfilePage'; +import { renderWithProviders } from '../test/test-utils'; + +const API = '/api/v1'; + +describe('ProfilePage', () => { + it('renders user profile', async () => { + renderWithProviders( + , + { route: '/profile' }, + ); + expect(await screen.findByText('Профиль')).toBeInTheDocument(); + expect(await screen.findByText('user@test.com')).toBeInTheDocument(); + expect(await screen.findByText('user')).toBeInTheDocument(); + }); + + it('shows user name in input', async () => { + renderWithProviders(, { route: '/profile' }); + const input = await screen.findByDisplayValue('Test User'); + expect(input).toBeInTheDocument(); + }); + + it('updates profile on save', async () => { + const user = userEvent.setup(); + renderWithProviders(, { route: '/profile' }); + + const input = await screen.findByDisplayValue('Test User'); + await user.clear(input); + await user.type(input, 'Updated User'); + await user.click(screen.getByRole('button', { name: 'Сохранить' })); + + expect(await screen.findByText('Профиль обновлён')).toBeInTheDocument(); + }); + + it('shows error message on failed update', async () => { + server.use( + http.patch(`${API}/auth/me`, () => new HttpResponse(null, { status: 500 })), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/profile' }); + + const input = await screen.findByDisplayValue('Test User'); + await user.clear(input); + await user.type(input, 'New Name'); + await user.click(screen.getByRole('button', { name: 'Сохранить' })); + + expect(await screen.findByText('Не удалось обновить профиль')).toBeInTheDocument(); + }); + + it('shows saving state', async () => { + server.use( + http.patch(`${API}/auth/me`, () => new Promise(() => {})), + ); + const user = userEvent.setup(); + renderWithProviders(, { route: '/profile' }); + + const input = await screen.findByDisplayValue('Test User'); + await user.clear(input); + await user.type(input, 'New Name'); + await user.click(screen.getByRole('button', { name: 'Сохранить' })); + + expect(await screen.findByText('Сохранение...')).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 7: Run all page tests** + +Run: `npx vitest run apps/frontend/src/pages/ -w apps/frontend` +Expected: all passing + +--- + +### Task 8: Final verification + +- [ ] **Step 1: Run all tests** + +Run: `npm run test:frontend` +Expected: all tests passing + +- [ ] **Step 2: Run format check** + +Run: `npm run format:check` +Expected: no formatting issues diff --git a/docs/superpowers/specs/2026-06-13-frontend-test-coverage.md b/docs/features/frontend-test-coverage/spec.md similarity index 100% rename from docs/superpowers/specs/2026-06-13-frontend-test-coverage.md rename to docs/features/frontend-test-coverage/spec.md diff --git a/docs/features/moex-vibe/plan.md b/docs/features/moex-vibe/plan.md new file mode 100644 index 0000000..b801a60 --- /dev/null +++ b/docs/features/moex-vibe/plan.md @@ -0,0 +1,3456 @@ +# MoexVibe MVP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a working MVP of MoexVibe — web application for analyzing MOEX stocks and bonds. + +**Architecture:** NestJS monolith serving normalized REST API (OpenAPI 3.0), in-memory cache, rate-limited MOEX ISS client. React SPA with TanStack Query and openapi-typescript codegen. Dev-only single server, production via Docker. + +**Tech Stack:** NestJS, React, Vite, TypeScript, TanStack Query, React Router, @nestjs/cache-manager, node-fetch/axios, lightweight-charts, vitest, Docusaurus + +--- + +## File Structure + +``` +moex-vibe/ +├── package.json # Root npm workspaces +├── tsconfig.base.json +├── .gitignore +├── .prettierrc +├── .eslintrc.cjs +├── apps/ +│ ├── backend/ +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ ├── nest-cli.json +│ │ └── src/ +│ │ ├── main.ts +│ │ ├── app.module.ts +│ │ ├── config/ +│ │ │ └── configuration.ts +│ │ ├── common/ +│ │ │ ├── dto/ +│ │ │ │ ├── api-response.dto.ts +│ │ │ │ └── pagination.dto.ts +│ │ │ ├── filters/ +│ │ │ │ └── http-exception.filter.ts +│ │ │ ├── interceptors/ +│ │ │ │ ├── logging.interceptor.ts +│ │ │ │ └── transform.interceptor.ts +│ │ │ └── middleware/ +│ │ │ └── request-logging.middleware.ts +│ │ └── modules/ +│ │ ├── moex-client/ +│ │ │ ├── moex-client.module.ts +│ │ │ ├── moex-client.service.ts +│ │ │ ├── moex-client.service.spec.ts +│ │ │ └── moex-client.types.ts +│ │ ├── cache/ +│ │ │ ├── cache.module.ts +│ │ │ └── cache.service.ts +│ │ ├── securities/ +│ │ │ ├── securities.module.ts +│ │ │ ├── securities.controller.ts +│ │ │ ├── securities.controller.spec.ts +│ │ │ ├── securities.service.ts +│ │ │ ├── securities.service.spec.ts +│ │ │ └── dto/ +│ │ │ └── search-query.dto.ts +│ │ ├── shares/ +│ │ │ ├── shares.module.ts +│ │ │ ├── shares.controller.ts +│ │ │ ├── shares.controller.spec.ts +│ │ │ ├── shares.service.ts +│ │ │ ├── shares.service.spec.ts +│ │ │ └── dto/ +│ │ │ ├── share-response.dto.ts +│ │ │ ├── share-marketdata-response.dto.ts +│ │ │ ├── dividends-response.dto.ts +│ │ │ └── history-query.dto.ts +│ │ ├── bonds/ +│ │ │ ├── bonds.module.ts +│ │ │ ├── bonds.controller.ts +│ │ │ ├── bonds.controller.spec.ts +│ │ │ ├── bonds.service.ts +│ │ │ ├── bonds.service.spec.ts +│ │ │ └── dto/ +│ │ │ ├── bond-response.dto.ts +│ │ │ ├── bond-marketdata-response.dto.ts +│ │ │ └── bond-history.dto.ts +│ │ ├── candles/ +│ │ │ ├── candles.module.ts +│ │ │ ├── candles.controller.ts +│ │ │ ├── candles.controller.spec.ts +│ │ │ ├── candles.service.ts +│ │ │ ├── candles.service.spec.ts +│ │ │ └── dto/ +│ │ │ └── candles-query.dto.ts +│ │ └── health/ +│ │ └── health.controller.ts +│ └── frontend/ +│ ├── package.json +│ ├── tsconfig.json +│ ├── tsconfig.node.json +│ ├── vite.config.ts +│ ├── index.html +│ └── src/ +│ ├── main.tsx +│ ├── App.tsx +│ ├── routes.tsx +│ ├── styles.css +│ ├── api/ +│ │ └── (generated by openapi-typescript) +│ ├── hooks/ +│ │ ├── useSearch.ts +│ │ ├── useStock.ts +│ │ ├── useStockCandles.ts +│ │ ├── useStockDividends.ts +│ │ ├── useBond.ts +│ │ └── useBondCandles.ts +│ ├── pages/ +│ │ ├── HomePage.tsx +│ │ ├── StockPage.tsx +│ │ └── BondPage.tsx +│ ├── components/ +│ │ ├── Layout.tsx +│ │ ├── SearchBar.tsx +│ │ ├── SecurityCard.tsx +│ │ ├── PriceChart.tsx +│ │ ├── StockDetails.tsx +│ │ └── BondDetails.tsx +│ ├── types/ +│ │ └── (generated, re-exported) +│ └── vite-env.d.ts +├── docker/ +│ ├── Dockerfile.backend +│ ├── Dockerfile.frontend +│ └── nginx.conf +├── docker-compose.yml +└── docs/ + ├── architecture/adr/ + ├── openapi/openapi.yaml + └── superpowers/specs/2026-06-13-moex-vibe-design.md +``` + +--- + +## SPRINT 1: Backend Foundation + +### Task 1.1: Initialize project root with npm workspaces + +**Files:** +- Create: `package.json` +- Create: `tsconfig.base.json` +- Create: `.gitignore` +- Create: `.prettierrc` + +- [ ] **Create root package.json with workspaces** + +```json +{ + "name": "moex-vibe", + "private": true, + "workspaces": [ + "apps/backend", + "apps/frontend" + ], + "scripts": { + "dev:backend": "npm run start:dev -w apps/backend", + "dev:frontend": "npm run dev -w apps/frontend", + "build:backend": "npm run build -w apps/backend", + "build:frontend": "npm run build -w apps/frontend", + "test:backend": "npm run test -w apps/backend", + "lint": "npm run lint -w apps/backend", + "format": "prettier --write \"**/*.ts\"" + }, + "devDependencies": { + "prettier": "^3.0.0" + } +} +``` + +- [ ] **Create tsconfig.base.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + } +} +``` + +- [ ] **Create .gitignore** + +``` +node_modules/ +dist/ +.env +*.log +.DS_Store +``` + +- [ ] **Create .prettierrc** + +```json +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "semi": true +} +``` + +- [ ] **Run `npm install` at root** to create lockfile and workspace links. + +- [ ] **Commit** + +```bash +git add package.json tsconfig.base.json .gitignore .prettierrc +git commit -m "chore: initialize monorepo with npm workspaces" +``` + +### Task 1.2: Scaffold NestJS backend + +**Files:** +- Create: `apps/backend/package.json` +- Create: `apps/backend/tsconfig.json` +- Create: `apps/backend/nest-cli.json` +- Create: `apps/backend/src/main.ts` +- Create: `apps/backend/src/app.module.ts` + +- [ ] **Create apps/backend/package.json** + +```json +{ + "name": "@moex-vibe/backend", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "nest build", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,test}/**/*.ts\"", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/config": "^3.0.0", + "@nestjs/swagger": "^7.0.0", + "@nestjs/axios": "^3.0.0", + "@nestjs/cache-manager": "^2.0.0", + "cache-manager": "^5.0.0", + "axios": "^1.6.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.0", + "class-validator": "^0.14.0", + "class-transformer": "^0.5.0", + "p-queue": "^7.3.0", + "swagger-ui-express": "^5.0.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/express": "^4.17.0", + "@types/node": "^20.0.0", + "typescript": "^5.3.0", + "vitest": "^1.0.0", + "eslint": "^8.0.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0" + } +} +``` + +- [ ] **Create apps/backend/tsconfig.json** + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "outDir": "./dist", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "baseUrl": "./", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"] +} +``` + +- [ ] **Create apps/backend/nest-cli.json** + +```json +{ + "collection": "@nestjs/schematics", + "sourceRoot": "src" +} +``` + +- [ ] **Create apps/backend/src/main.ts** + +```typescript +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import { TransformInterceptor } from './common/interceptors/transform.interceptor'; +import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware'; +import { ValidationPipe } from '@nestjs/common'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.setGlobalPrefix('api/v1'); + + app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalInterceptors(new TransformInterceptor()); + app.use(new RequestLoggingMiddleware().use); + + app.enableCors(); + + const config = new DocumentBuilder() + .setTitle('MoexVibe API') + .setVersion('1.0.0') + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + + const port = process.env.PORT || 3000; + await app.listen(port); + console.log(`MoexVibe API running on http://localhost:${port}/api/v1`); + console.log(`Swagger docs: http://localhost:${port}/api/docs`); +} +bootstrap(); +``` + +- [ ] **Create apps/backend/src/app.module.ts** + +```typescript +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { CacheModule } from './modules/cache/cache.module'; +import { MoexClientModule } from './modules/moex-client/moex-client.module'; +import { HealthModule } from './modules/health/health.module'; +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 configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ load: [configuration], isGlobal: true }), + CacheModule, + MoexClientModule, + HealthModule, + SecuritiesModule, + SharesModule, + BondsModule, + CandlesModule, + ], +}) +export class AppModule {} +``` + +- [ ] **Commit** + +```bash +git add apps/backend/ +git commit -m "feat: scaffold NestJS backend with Swagger, validation, CORS" +``` + +### Task 1.3: Configuration module + +**Files:** +- Create: `apps/backend/src/config/configuration.ts` + +- [ ] **Create configuration.ts** + +```typescript +import { registerAs } from '@nestjs/config'; + +export default registerAs('app', () => ({ + port: parseInt(process.env.PORT || '3000', 10), + moex: { + baseUrl: process.env.MOEX_BASE_URL || 'https://iss.moex.com/iss', + rateLimit: parseInt(process.env.MOEX_RATE_LIMIT || '10', 10), + circuitBreakerThreshold: parseInt( + process.env.MOEX_CIRCUIT_BREAKER_THRESHOLD || '5', + 10, + ), + circuitBreakerResetSeconds: parseInt( + process.env.MOEX_CIRCUIT_BREAKER_RESET_SECONDS || '30', + 10, + ), + }, + cache: { + marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10), + historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10), + candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10), + securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10), + searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10), + dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10), + }, +})); +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/config/ +git commit -m "feat: add configuration module with env vars" +``` + +### Task 1.4: Common DTOs, filters, interceptors, middleware + +**Files:** +- Create: `apps/backend/src/common/dto/api-response.dto.ts` +- Create: `apps/backend/src/common/dto/pagination.dto.ts` +- Create: `apps/backend/src/common/filters/http-exception.filter.ts` +- Create: `apps/backend/src/common/interceptors/transform.interceptor.ts` +- Create: `apps/backend/src/common/middleware/request-logging.middleware.ts` + +- [ ] **Create common/dto/api-response.dto.ts** + +```typescript +import { ApiProperty } from '@nestjs/swagger'; + +export class ApiResponseMeta { + @ApiProperty({ nullable: true }) + cachedAt: string | null; + + @ApiProperty() + fromCache: boolean; +} + +export class ApiResponse { + data: T; + meta: ApiResponseMeta; + + constructor(data: T, fromCache = false, cachedAt: string | null = null) { + this.data = data; + this.meta = { cachedAt, fromCache }; + } +} +``` + +- [ ] **Create common/dto/pagination.dto.ts** + +```typescript +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsOptional, IsInt, Min, Max } from 'class-validator'; + +export class PaginationDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} +``` + +- [ ] **Create common/filters/http-exception.filter.ts** + +```typescript +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { Response } from 'express'; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Internal server error'; + let error = 'Internal Server Error'; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const res = exception.getResponse(); + if (typeof res === 'string') { + message = res; + error = exception.name; + } else if (typeof res === 'object') { + const r = res as Record; + message = (r.message as string) || message; + error = (r.error as string) || exception.name; + } + } else if (exception instanceof Error) { + message = exception.message; + } + + response.status(status).json({ + statusCode: status, + message, + error, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} +``` + +- [ ] **Create common/interceptors/transform.interceptor.ts** + +```typescript +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ApiResponse } from '../dto/api-response.dto'; + +@Injectable() +export class TransformInterceptor + implements NestInterceptor> +{ + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { + return next.handle().pipe( + map((data) => { + if (data instanceof ApiResponse) return data; + return new ApiResponse(data, false, null); + }), + ); + } +} +``` + +- [ ] **Create common/middleware/request-logging.middleware.ts** + +```typescript +import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; + +@Injectable() +export class RequestLoggingMiddleware implements NestMiddleware { + private logger = new Logger('HTTP'); + + use(req: Request, res: Response, next: NextFunction): void { + const { method, originalUrl } = req; + const start = Date.now(); + + res.on('finish', () => { + const { statusCode } = res; + const duration = Date.now() - start; + this.logger.log(`${method} ${originalUrl} ${statusCode} ${duration}ms`); + }); + + next(); + } +} +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/common/ +git commit -m "feat: add common DTOs, exception filter, transform interceptor, logging middleware" +``` + +### Task 1.5: Health check endpoint + +**Files:** +- Create: `apps/backend/src/modules/health/health.controller.ts` + +- [ ] **Create health.controller.ts** + +```typescript +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; + +@ApiTags('Health') +@Controller('health') +export class HealthController { + @Get() + @ApiOperation({ summary: 'Проверка состояния сервиса' }) + check() { + return { + status: 'ok', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + }; + } +} +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/modules/health/ +git commit -m "feat: add health check endpoint" +``` + +### Task 1.6: MoexClient module with rate limiting + +**Files:** +- Create: `apps/backend/src/modules/moex-client/moex-client.module.ts` +- Create: `apps/backend/src/modules/moex-client/moex-client.service.ts` +- Create: `apps/backend/src/modules/moex-client/moex-client.types.ts` +- Create: `apps/backend/src/modules/moex-client/moex-client.service.spec.ts` + +- [ ] **Create moex-client.types.ts** + +```typescript +export interface MoexSecurityDescription { + secid: string; + isin: string; + name: string; + shortName: string; + latName: string | null; + listLevel: number; + issueSize: number; + faceValue: number; + faceUnit: string; + issueDate: string; + typeName: string; + group: string; + type: string; + isQualifiedInvestors: boolean; + morningSession: boolean; + eveningSession: boolean; +} + +export interface MoexShareMarketData { + secid: string; + boardid: string; + bid: number | null; + offer: number | null; + open: number | null; + low: number | null; + high: number | null; + last: number | null; + lastChange: number | null; + lastChangePrcnt: number | null; + volume: number; + value: number; + waprice: number | null; + numtrades: number; + issueCapitalization: number | null; + tradingStatus: string; + updateTime: string; +} + +export interface MoexBondData { + secid: string; + boardid: string; + shortName: string; + prevWaprice: number | null; + yieldAtPrevWaprice: number | null; + couponValue: number | null; + nextCoupon: string | null; + accruedInt: number | null; + prevPrice: number | null; + lotSize: number; + faceValue: number; + matDate: string; + couponPeriod: number; + issueSize: number; + isin: string; + couponPercent: number | null; + offerDate: string | null; + buybackDate: string | null; + bondType: string; + bondSubType: string; + listLevel: number; +} + +export interface MoexBondMarketData { + secid: string; + bid: number | null; + offer: number | null; + open: number | null; + low: number | null; + high: number | null; + last: number | null; + yield: number | null; + waprice: number | null; + yieldAtWaprice: number | null; + duration: number | null; + volume: number; + value: number; + numtrades: number; + tradingStatus: string; + updateTime: string; +} + +export interface MoexDividend { + secid: string; + isin: string; + registryCloseDate: string; + value: number; + currencyId: string; +} + +export interface MoexCandle { + open: number; + close: number; + high: number; + low: number; + value: number; + volume: number; + begin: string; + end: string; +} + +export interface MoexHistoryEntry { + tradeDate: string; + open: number | null; + low: number | null; + high: number | null; + close: number | null; + waprice: number | null; + volume: number; + value: number; + numtrades: number; +} + +export interface MoexBondHistoryEntry { + tradeDate: string; + close: number | null; + legalClosePrice: number | null; + waprice: number | null; + yieldClose: number | null; + duration: number | null; + accruedInt: number | null; +} + +export interface MoexBoard { + secid: string; + boardid: string; + title: string; + isPrimary: boolean; + isTraded: boolean; + currencyid: string; +} +``` + +- [ ] **Create moex-client.service.ts** + +```typescript +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios, { AxiosInstance } from 'axios'; +import PQueue from 'p-queue'; +import { + MoexSecurityDescription, + MoexShareMarketData, + MoexBondData, + MoexBondMarketData, + MoexDividend, + MoexCandle, + MoexHistoryEntry, + MoexBondHistoryEntry, +} from './moex-client.types'; + +@Injectable() +export class MoexClientService { + private readonly logger = new Logger(MoexClientService.name); + private readonly client: AxiosInstance; + private readonly queue: PQueue; + private circuitOpen = false; + private circuitErrorCount = 0; + private readonly threshold: number; + private readonly resetMs: number; + + constructor(private configService: ConfigService) { + const baseUrl = this.configService.get('app.moex.baseUrl')!; + this.threshold = this.configService.get( + 'app.moex.circuitBreakerThreshold', + 5, + ); + this.resetMs = + this.configService.get( + 'app.moex.circuitBreakerResetSeconds', + 30, + ) * 1000; + const rateLimit = this.configService.get( + 'app.moex.rateLimit', + 10, + ); + + this.client = axios.create({ + baseURL: baseUrl, + timeout: 10000, + paramsSerializer: { indexes: null }, + }); + + this.queue = new PQueue({ + interval: 1000, + intervalCap: rateLimit, + }); + } + + private async request(path: string, params?: Record): Promise { + if (this.circuitOpen) { + throw new Error('Circuit breaker is open — MOEX requests paused'); + } + + return this.queue.add(async () => { + try { + const response = await this.client.get(path, { + params: { ...params, 'iss.meta': 'off' }, + }); + this.circuitErrorCount = 0; + return response.data as T; + } catch (error) { + this.circuitErrorCount++; + if (this.circuitErrorCount >= this.threshold) { + this.circuitOpen = true; + this.logger.warn(`Circuit breaker opened after ${this.threshold} errors`); + setTimeout(() => { + this.circuitOpen = false; + this.circuitErrorCount = 0; + this.logger.log('Circuit breaker reset'); + }, this.resetMs); + } + throw error; + } + }) as Promise; + } + + private extractTable(data: Record, name: string): Record[] { + const table = data[name] as Record | undefined; + if (!table || !table.columns || !table.data) return []; + const columns = table.columns as string[]; + const rows = table.data as unknown[][]; + return rows.map((row) => { + const obj: Record = {}; + columns.forEach((col, i) => { + obj[col] = row[i]; + }); + return obj; + }); + } + + async searchSecurities(query: string): Promise { + const data = await this.request>('/securities', { + q: query, + }); + return this.extractTable(data, 'securities').map((s) => ({ + secid: s.secid as string, + isin: s.isin as string, + name: s.name as string, + shortName: s.shortName as string, + latName: (s.latName as string) || null, + listLevel: parseInt(s.listLevel as string, 10) || 0, + issueSize: parseInt(s.issuesize as string, 10) || 0, + faceValue: parseFloat(s.facevalue as string) || 0, + faceUnit: (s.faceunit as string) || '', + issueDate: (s.issuedate as string) || '', + typeName: (s.typename as string) || '', + group: (s.group as string) || '', + type: (s.type as string) || '', + isQualifiedInvestors: (s.isqualifiedinvestors as string) === '1', + morningSession: (s.morningsession as string) === '1', + eveningSession: (s.eveningsession as string) === '1', + })); + } + + async getSecurityDescription(secid: string): Promise { + const data = await this.request>(`/securities/${secid}`); + const rows = this.extractTable(data, 'description'); + if (rows.length === 0) return null; + const map = new Map(rows.map((r) => [r.name, r.value])); + return { + secid, + isin: (map.get('ISIN') as string) || '', + name: (map.get('NAME') as string) || '', + shortName: (map.get('SHORTNAME') as string) || '', + latName: (map.get('LATNAME') as string) || null, + listLevel: parseInt((map.get('LISTLEVEL') as string) || '0', 10), + issueSize: parseInt((map.get('ISSUESIZE') as string) || '0', 10), + faceValue: parseFloat((map.get('FACEVALUE') as string) || '0'), + faceUnit: (map.get('FACEUNIT') as string) || '', + issueDate: (map.get('ISSUEDATE') as string) || '', + typeName: (map.get('TYPENAME') as string) || '', + group: (map.get('GROUP') as string) || '', + type: (map.get('TYPE') as string) || '', + isQualifiedInvestors: (map.get('ISQUALIFIEDINVESTORS') as string) === '1', + morningSession: (map.get('MORNINGSESSION') as string) === '1', + eveningSession: (map.get('EVENINGSESSION') as string) === '1', + }; + } + + async getShareMarketData(secid: string, boardId = 'TQBR'): Promise { + const data = await this.request>( + `/engines/stock/markets/shares/securities/${secid}`, + { boards: boardId }, + ); + const rows = this.extractTable(data, 'securities'); + const share = rows.find((r) => r.BOARDID === boardId); + if (!share) return null; + + const mktRows = this.extractTable(data, 'marketdata'); + const mkt = mktRows.find((r) => r.BOARDID === boardId); + + return { + secid, + boardid: boardId, + bid: mkt ? parseFloat((mkt.BID as string) || '') : null, + offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null, + open: mkt ? parseFloat((mkt.OPEN as string) || '') : null, + low: mkt ? parseFloat((mkt.LOW as string) || '') : null, + high: mkt ? parseFloat((mkt.HIGH as string) || '') : null, + last: mkt ? parseFloat((mkt.LAST as string) || '') : parseFloat((share.PREVPRICE as string) || ''), + lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null, + lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null, + volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0, + value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0, + waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null, + numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0, + issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null, + tradingStatus: (mkt?.TRADINGSTATUS as string) || '', + updateTime: (mkt?.UPDATETIME as string) || '', + }; + } + + async getBondData(secid: string, boardId = 'TQCB'): Promise { + const data = await this.request>( + `/engines/stock/markets/bonds/securities/${secid}`, + { boards: boardId }, + ); + const rows = this.extractTable(data, 'securities'); + const bond = rows.find((r) => r.BOARDID === boardId); + if (!bond) return null; + + return { + secid, + boardid: boardId, + shortName: (bond.SHORTNAME as string) || '', + prevWaprice: parseFloat((bond.PREVWAPRICE as string) || '') || null, + yieldAtPrevWaprice: parseFloat((bond.YIELDATPREVWAPRICE as string) || '') || null, + couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null, + nextCoupon: (bond.NEXTCOUPON as string) || null, + accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null, + prevPrice: parseFloat((bond.PREVPRICE as string) || '') || null, + lotSize: parseInt((bond.LOTSIZE as string) || '1', 10), + faceValue: parseFloat((bond.FACEVALUE as string) || '1000'), + matDate: (bond.MATDATE as string) || '', + couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10), + issueSize: parseInt((bond.ISSUESIZE as string) || '0', 10), + isin: (bond.ISIN as string) || '', + couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null, + offerDate: (bond.OFFERDATE as string) || null, + buybackDate: (bond.BUYBACKDATE as string) || null, + bondType: (bond.BONDTYPE as string) || '', + bondSubType: (bond.BONDSUBTYPE as string) || '', + listLevel: parseInt((bond.LISTLEVEL as string) || '0', 10), + }; + } + + async getBondMarketData(secid: string, boardId = 'TQCB'): Promise { + const data = await this.request>( + `/engines/stock/markets/bonds/securities/${secid}`, + { boards: boardId }, + ); + const mktRows = this.extractTable(data, 'marketdata'); + const mkt = mktRows.find((r) => r.SECID === secid); + if (!mkt) return null; + + return { + secid, + bid: mkt.BID != null ? parseFloat(mkt.BID as string) : null, + offer: mkt.OFFER != null ? parseFloat(mkt.OFFER as string) : null, + open: mkt.OPEN != null ? parseFloat(mkt.OPEN as string) : null, + low: mkt.LOW != null ? parseFloat(mkt.LOW as string) : null, + high: mkt.HIGH != null ? parseFloat(mkt.HIGH as string) : null, + last: mkt.LAST != null ? parseFloat(mkt.LAST as string) : null, + yield: mkt.YIELD != null ? parseFloat(mkt.YIELD as string) : null, + waprice: mkt.WAPRICE != null ? parseFloat(mkt.WAPRICE as string) : null, + yieldAtWaprice: mkt.YIELDATWAPRICE != null ? parseFloat(mkt.YIELDATWAPRICE as string) : null, + duration: mkt.DURATION != null ? parseFloat(mkt.DURATION as string) : null, + volume: parseInt((mkt.VOLTODAY as string) || '0', 10), + value: parseFloat((mkt.VALTODAY as string) || '0'), + numtrades: parseInt((mkt.NUMTRADES as string) || '0', 10), + tradingStatus: (mkt.TRADINGSTATUS as string) || '', + updateTime: (mkt.UPDATETIME as string) || '', + }; + } + + async getDividends(secid: string): Promise { + const data = await this.request>(`/securities/${secid}/dividends`); + return this.extractTable(data, 'dividends').map((d) => ({ + secid: d.secid as string, + isin: d.isin as string, + registryCloseDate: d.registryclosedate as string, + value: parseFloat(d.value as string), + currencyId: (d.currencyid as string) || 'RUB', + })); + } + + async getCandles( + engine: 'stock', + market: 'shares' | 'bonds', + secid: string, + interval: 1 | 10 | 60 | 24, + from: string, + till: string, + ): Promise { + const intervalMap: Record = { + 1: '1min', + 10: '10min', + 60: '1hour', + 24: '24hours', + }; + const data = await this.request>( + `/engines/${engine}/markets/${market}/securities/${secid}/candles`, + { + interval: String(interval), + from, + till, + }, + ); + return this.extractTable(data, 'candles').map((c) => ({ + open: parseFloat(c.open as string), + close: parseFloat(c.close as string), + high: parseFloat(c.high as string), + low: parseFloat(c.low as string), + value: parseFloat(c.value as string), + volume: parseInt(c.volume as string, 10), + begin: c.begin as string, + end: c.end as string, + })); + } + + async getHistory( + secid: string, + from: string, + till: string, + ): Promise { + const data = await this.request>( + `/engines/stock/markets/shares/securities/${secid}`, + { from, till }, + ); + const tableName = Object.keys(data).find( + (k) => k.startsWith('history') && !k.includes('cursor'), + ); + if (!tableName) return []; + return this.extractTable(data, tableName).map((h) => ({ + tradeDate: h.TRADEDATE as string, + open: h.OPEN != null ? parseFloat(h.OPEN as string) : null, + low: h.LOW != null ? parseFloat(h.LOW as string) : null, + high: h.HIGH != null ? parseFloat(h.HIGH as string) : null, + close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null, + waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null, + volume: parseInt((h.VOLUME as string) || '0', 10), + value: parseFloat((h.VALUE as string) || '0'), + numtrades: parseInt((h.NUMTRADES as string) || '0', 10), + })); + } + + async getBondHistory( + secid: string, + from: string, + till: string, + ): Promise { + const data = await this.request>( + `/engines/stock/markets/bonds/securities/${secid}`, + { from, till }, + ); + const tableName = Object.keys(data).find( + (k) => k.startsWith('history') && !k.includes('cursor'), + ); + if (!tableName) return []; + return this.extractTable(data, tableName).map((h) => ({ + tradeDate: h.TRADEDATE as string, + close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null, + legalClosePrice: + h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null, + waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null, + yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null, + duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null, + accruedInt: h.ACCINT != null ? parseFloat(h.ACCINT as string) : null, + })); + } +} +``` + +- [ ] **Create moex-client.module.ts** + +```typescript +import { Global, Module } from '@nestjs/common'; +import { MoexClientService } from './moex-client.service'; + +@Global() +@Module({ + providers: [MoexClientService], + exports: [MoexClientService], +}) +export class MoexClientModule {} +``` + +- [ ] **Create moex-client.service.spec.ts** + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { MoexClientService } from './moex-client.service'; +import configuration from '../../config/configuration'; + +describe('MoexClientService', () => { + let service: MoexClientService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ load: [configuration] }), + ], + providers: [MoexClientService], + }).compile(); + + service = module.get(MoexClientService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('searchSecurities', () => { + it('should return results for SBER query', async () => { + const results = await service.searchSecurities('SBER'); + expect(Array.isArray(results)).toBe(true); + if (results.length > 0) { + expect(results[0].secid).toBeDefined(); + } + }, 15000); + }); + + describe('getShareMarketData', () => { + it('should return market data for SBER', async () => { + const data = await service.getShareMarketData('SBER'); + expect(data).toBeDefined(); + expect(data!.secid).toBe('SBER'); + }, 15000); + }); +}); +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/modules/moex-client/ +git commit -m "feat: add MoexClient with rate-limited HTTP client, circuit breaker, and MOEX ISS data methods" +``` + +### Task 1.7: Cache module + +**Files:** +- Create: `apps/backend/src/modules/cache/cache.module.ts` +- Create: `apps/backend/src/modules/cache/cache.service.ts` + +- [ ] **Create cache.module.ts** + +```typescript +import { Module, CacheModule as NestCacheModule } from '@nestjs/cache-manager'; +import { CacheService } from './cache.service'; + +@Module({ + imports: [ + NestCacheModule.register({ + ttl: 900, + max: 1000, + isGlobal: true, + }), + ], + providers: [CacheService], + exports: [CacheService], +}) +export class CacheModule {} +``` + +- [ ] **Create cache.service.ts** + +```typescript +import { Injectable, Inject } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class CacheService { + constructor( + @Inject(CACHE_MANAGER) private cacheManager: Cache, + private configService: ConfigService, + ) {} + + async get(key: string): Promise { + return this.cacheManager.get(key); + } + + async set(key: string, value: unknown, ttl?: number): Promise { + await this.cacheManager.set(key, value, ttl); + } + + private buildKey(...parts: string[]): string { + return parts.join(':'); + } + + async getOrFetch( + keyPrefix: string, + keyParts: string[], + fetchFn: () => Promise, + ttlConfigKey: string, + ): Promise<{ data: T; fromCache: boolean; cachedAt: string | null }> { + const key = this.buildKey(keyPrefix, ...keyParts); + const ttl = this.configService.get(`app.cache.${ttlConfigKey}`, 900); + + const cached = await this.get(key); + if (cached !== undefined) { + return { data: cached, fromCache: true, cachedAt: null }; + } + + const data = await fetchFn(); + await this.set(key, data, ttl); + + return { data, fromCache: false, cachedAt: new Date().toISOString() }; + } +} +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/modules/cache/ +git commit -m "feat: add cache module with getOrFetch pattern and configurable TTL" +``` + +--- + +## SPRINT 2: Securities API + +### Task 2.1: Securities search module + +**Files:** +- Create: `apps/backend/src/modules/securities/dto/search-query.dto.ts` +- Create: `apps/backend/src/modules/securities/securities.controller.ts` +- Create: `apps/backend/src/modules/securities/securities.service.ts` +- Create: `apps/backend/src/modules/securities/securities.module.ts` +- Create: `apps/backend/src/modules/securities/securities.controller.spec.ts` +- Create: `apps/backend/src/modules/securities/securities.service.spec.ts` + +- [ ] **Create search-query.dto.ts** + +```typescript +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsEnum, MinLength, MaxLength } from 'class-validator'; + +export enum SecurityType { + ALL = 'all', + SHARE = 'share', + BOND = 'bond', +} + +export class SearchQueryDto { + @ApiProperty({ description: 'Поисковый запрос (тикер, название, ISIN)' }) + @IsString() + @MinLength(1) + @MaxLength(100) + q: string; + + @ApiPropertyOptional({ enum: SecurityType, default: SecurityType.ALL }) + @IsOptional() + @IsEnum(SecurityType) + type?: SecurityType = SecurityType.ALL; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + limit?: number = 20; +} +``` + +- [ ] **Create search result DTO inline or reuse** — add to controller response via transform interceptor. + +- [ ] **Create securities.service.ts** + +```typescript +import { Injectable } from '@nestjs/common'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import { SecurityType } from './dto/search-query.dto'; + +export interface SearchResultItem { + secid: string; + isin: string; + shortName: string; + type: 'share' | 'bond'; + listLevel: number; + currency: string | null; + price: number | null; +} + +@Injectable() +export class SecuritiesService { + constructor( + private readonly moexClient: MoexClientService, + private readonly cache: CacheService, + ) {} + + async search(query: string, type: SecurityType, limit: number): Promise { + const { data } = await this.cache.getOrFetch( + 'search', + [query.toLowerCase()], + async () => { + const results = await this.moexClient.searchSecurities(query); + return results.map((s) => ({ + secid: s.secid, + isin: s.isin, + shortName: s.shortName, + type: (s.group === 'stock_shares' || s.type === 'common_share' || s.type === 'preferred_share') + ? 'share' as const + : (s.group === 'stock_bonds' ? 'bond' as const : null), + listLevel: s.listLevel, + currency: s.faceUnit === 'SUR' ? 'RUB' : s.faceUnit || null, + price: null, + })).filter((r): r is SearchResultItem => r.type !== null); + }, + 'searchTtl', + ); + + let filtered = data; + if (type === SecurityType.SHARE) { + filtered = data.filter((r) => r.type === 'share'); + } else if (type === SecurityType.BOND) { + filtered = data.filter((r) => r.type === 'bond'); + } + + return filtered.slice(0, limit); + } + + async getShareBrief(secid: string): Promise { + try { + const desc = await this.moexClient.getSecurityDescription(secid); + if (!desc) return null; + return { + secid: desc.secid, + isin: desc.isin, + shortName: desc.shortName, + type: 'share', + listLevel: desc.listLevel, + currency: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit, + price: null, + }; + } catch { + return null; + } + } +} +``` + +- [ ] **Create securities.controller.ts** + +```typescript +import { Controller, Get, Query, ValidationPipe } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { SecuritiesService } from './securities.service'; +import { SearchQueryDto, SecurityType } from './dto/search-query.dto'; + +@ApiTags('Securities') +@Controller('securities') +export class SecuritiesController { + constructor(private readonly securitiesService: SecuritiesService) {} + + @Get('search') + @ApiOperation({ summary: 'Поиск по инструментам' }) + async search(@Query(ValidationPipe) query: SearchQueryDto) { + const results = await this.securitiesService.search( + query.q, + query.type || SecurityType.ALL, + query.limit || 20, + ); + return { data: results, meta: { cachedAt: null, fromCache: false } }; + } +} +``` + +- [ ] **Create securities.module.ts** + +```typescript +import { Module } from '@nestjs/common'; +import { SecuritiesController } from './securities.controller'; +import { SecuritiesService } from './securities.service'; + +@Module({ + controllers: [SecuritiesController], + providers: [SecuritiesService], + exports: [SecuritiesService], +}) +export class SecuritiesModule {} +``` + +- [ ] **Create securities.service.spec.ts** + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { SecuritiesService } from './securities.service'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import configuration from '../../config/configuration'; +import { SecurityType } from './dto/search-query.dto'; + +describe('SecuritiesService', () => { + let service: SecuritiesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ load: [configuration] })], + providers: [ + SecuritiesService, + MoexClientService, + { + provide: 'CACHE_MANAGER', + useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() }, + }, + CacheService, + ], + }).compile(); + + service = module.get(SecuritiesService); + }); + + it('should return search results for SBER', async () => { + const results = await service.search('SBER', SecurityType.ALL, 5); + expect(results.length).toBeGreaterThan(0); + expect(results[0].secid).toBeDefined(); + }, 15000); +}); +``` + +- [ ] **Create securities.controller.spec.ts** — similar pattern with mocked service. + +- [ ] **Commit** + +```bash +git add apps/backend/src/modules/securities/ +git commit -m "feat: add securities search endpoint" +``` + +### Task 2.2: Shares module — spec + marketdata + +**Files:** +- Create: `apps/backend/src/modules/shares/dto/share-response.dto.ts` +- Create: `apps/backend/src/modules/shares/dto/share-marketdata-response.dto.ts` +- Create: `apps/backend/src/modules/shares/shares.service.ts` +- Create: `apps/backend/src/modules/shares/shares.controller.ts` +- Create: `apps/backend/src/modules/shares/shares.module.ts` + +- [ ] **Create share-response.dto.ts** + +```typescript +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class StockMarketDataDto { + @ApiProperty({ example: 322.35 }) + price: number; + + @ApiProperty({ example: 1.15 }) + change: number; + + @ApiProperty({ example: 0.36 }) + changePercent: number; + + @ApiProperty({ example: 321.3 }) + open: number; + + @ApiPropertyOptional({ example: 322.66 }) + high: number | null; + + @ApiPropertyOptional({ example: 321.2 }) + low: number | null; + + @ApiProperty({ example: 1925163 }) + volume: number; + + @ApiProperty({ example: 620184479 }) + value: number; + + @ApiPropertyOptional({ example: 6958336818320 }) + issueCapitalization: number | null; + + @ApiProperty() + updatedAt: string; +} + +export class ShareResponseDto { + @ApiProperty({ example: 'SBER' }) + secid: string; + + @ApiProperty({ example: 'RU0009029540' }) + isin: string; + + @ApiProperty({ example: 'Сбербанк России ПАО ао' }) + name: string; + + @ApiProperty({ example: 'Сбербанк' }) + shortName: string; + + @ApiPropertyOptional() + latName: string | null; + + @ApiProperty({ example: 1 }) + listLevel: number; + + @ApiProperty({ example: 21586948000 }) + issueSize: number; + + @ApiProperty({ example: 3 }) + faceValue: number; + + @ApiProperty({ example: 'RUB' }) + faceUnit: string; + + @ApiProperty({ example: 'common_share' }) + type: string; + + @ApiProperty() + marketData: StockMarketDataDto; +} +``` + +- [ ] **Create share-marketdata-response.dto.ts** + +```typescript +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { StockMarketDataDto } from './share-response.dto'; + +export class ShareMarketDataResponseDto extends StockMarketDataDto {} +``` + +- [ ] **Create shares.service.ts** + +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; + +@Injectable() +export class SharesService { + constructor( + private readonly moexClient: MoexClientService, + private readonly cache: CacheService, + ) {} + + async getShare(secid: string) { + const desc = await this.moexClient.getSecurityDescription(secid); + if (!desc || !(desc.group === 'stock_shares' || desc.type === 'common_share' || desc.type === 'preferred_share')) { + throw new NotFoundException(`Share ${secid} not found`); + } + + const { data: marketData } = await this.cache.getOrFetch( + 'marketdata', + ['shares', secid], + () => this.moexClient.getShareMarketData(secid), + 'marketDataTtl', + ); + + const price = marketData?.last ?? (marketData ? null : 0); + const prevPrice = 0; // not stored separately, but available from securities table + const change = marketData?.lastChange ?? 0; + const changePercent = marketData?.lastChangePrcnt ?? 0; + + return { + secid: desc.secid, + isin: desc.isin, + name: desc.name, + shortName: desc.shortName, + latName: desc.latName, + listLevel: desc.listLevel, + issueSize: desc.issueSize, + faceValue: desc.faceValue, + faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit, + type: desc.type, + marketData: { + price: price ?? 0, + change, + changePercent, + open: marketData?.open ?? 0, + high: marketData?.high ?? null, + low: marketData?.low ?? null, + volume: marketData?.volume ?? 0, + value: marketData?.value ?? 0, + issueCapitalization: marketData?.issueCapitalization ?? null, + updatedAt: marketData?.updateTime + ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime + : new Date().toISOString(), + }, + }; + } + + async getMarketData(secid: string) { + const { data: marketData, fromCache, cachedAt } = await this.cache.getOrFetch( + 'marketdata', + ['shares', secid], + () => this.moexClient.getShareMarketData(secid), + 'marketDataTtl', + ); + + if (!marketData) { + throw new NotFoundException(`Market data for ${secid} not found`); + } + + return { + data: { + price: marketData.last ?? 0, + change: marketData.lastChange ?? 0, + changePercent: marketData.lastChangePrcnt ?? 0, + open: marketData.open ?? 0, + high: marketData.high ?? null, + low: marketData.low ?? null, + volume: marketData.volume ?? 0, + value: marketData.value ?? 0, + issueCapitalization: marketData.issueCapitalization ?? null, + updatedAt: marketData.updateTime + ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime + : new Date().toISOString(), + }, + meta: { fromCache, cachedAt }, + }; + } + + async getDividends(secid: string) { + const { data, fromCache, cachedAt } = await this.cache.getOrFetch( + 'dividends', + [secid], + () => this.moexClient.getDividends(secid), + 'dividendsTtl', + ); + + return { + data: data.map((d) => ({ + registryCloseDate: d.registryCloseDate, + value: d.value, + currency: d.currencyId, + })), + meta: { fromCache, cachedAt }, + }; + } +} +``` + +- [ ] **Create shares.controller.ts** + +```typescript +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { SharesService } from './shares.service'; + +@ApiTags('Shares') +@Controller('securities/shares') +export class SharesController { + constructor(private readonly sharesService: SharesService) {} + + @Get(':secid') + @ApiOperation({ summary: 'Получить спецификацию акции' }) + async getShare(@Param('secid') secid: string) { + const share = await this.sharesService.getShare(secid); + return { data: share, meta: { cachedAt: null, fromCache: false } }; + } + + @Get(':secid/marketdata') + @ApiOperation({ summary: 'Получить рыночные данные акции' }) + async getMarketData(@Param('secid') secid: string) { + return this.sharesService.getMarketData(secid); + } + + @Get(':secid/dividends') + @ApiOperation({ summary: 'Получить дивиденды' }) + async getDividends(@Param('secid') secid: string) { + return this.sharesService.getDividends(secid); + } +} +``` + +- [ ] **Create shares.module.ts** + +```typescript +import { Module } from '@nestjs/common'; +import { SharesController } from './shares.controller'; +import { SharesService } from './shares.service'; + +@Module({ + controllers: [SharesController], + providers: [SharesService], + exports: [SharesService], +}) +export class SharesModule {} +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/modules/shares/ +git commit -m "feat: add shares endpoint with market data and dividends" +``` + +--- + +## SPRINT 3: Bonds + History + Candles + +### Task 3.1: Bonds module + +**Files:** +- Create: `apps/backend/src/modules/bonds/dto/bond-response.dto.ts` +- Create: `apps/backend/src/modules/bonds/dto/bond-marketdata-response.dto.ts` +- Create: `apps/backend/src/modules/bonds/bonds.service.ts` +- Create: `apps/backend/src/modules/bonds/bonds.controller.ts` +- Create: `apps/backend/src/modules/bonds/bonds.module.ts` + +- [ ] **Create bond-response.dto.ts** + +```typescript +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class BondMarketDataDto { + @ApiProperty({ description: 'Цена в % от номинала', example: 100.45 }) + price: number; + + @ApiPropertyOptional({ example: 12.71 }) + yieldToMaturity: number | null; + + @ApiPropertyOptional() + duration: number | null; + + @ApiProperty({ example: 29.48 }) + accruedInt: number; + + @ApiProperty({ example: 40.64 }) + couponValue: number; + + @ApiPropertyOptional({ example: 8.15 }) + couponPercent: number | null; + + @ApiPropertyOptional({ example: '2026-08-05' }) + nextCouponDate: string | null; + + @ApiProperty() + open: number; + + @ApiPropertyOptional() + high: number | null; + + @ApiPropertyOptional() + low: number | null; + + @ApiProperty() + volume: number; + + @ApiProperty() + updatedAt: string; +} + +export class BondResponseDto { + @ApiProperty({ example: 'SU26207RMFS9' }) + secid: string; + + @ApiProperty({ example: 'RU000A0JS3W6' }) + isin: string; + + @ApiProperty({ example: 'ОФЗ-ПД 26207 03/02/27' }) + name: string; + + @ApiProperty({ example: 'ОФЗ 26207' }) + shortName: string; + + @ApiPropertyOptional() + latName: string | null; + + @ApiProperty({ example: 1 }) + listLevel: number; + + @ApiProperty({ example: 370200604 }) + issueSize: number; + + @ApiProperty({ example: 1000 }) + faceValue: number; + + @ApiProperty({ example: 'RUB' }) + faceUnit: string; + + @ApiProperty({ example: '2027-02-03' }) + matDate: string; + + @ApiProperty({ example: 40.64 }) + couponValue: number; + + @ApiPropertyOptional({ example: 8.15 }) + couponPercent: number | null; + + @ApiProperty({ example: 182 }) + couponPeriod: number; + + @ApiProperty({ example: '2026-08-05' }) + nextCoupon: string | null; + + @ApiProperty({ example: 29.48 }) + accruedInt: number; + + @ApiProperty({ example: 'Фикс с известным купоном' }) + bondType: string; + + @ApiProperty({ example: 'До погашения' }) + bondSubType: string; + + @ApiPropertyOptional() + offerDate: string | null; + + @ApiPropertyOptional() + buybackDate: string | null; + + @ApiProperty() + marketData: BondMarketDataDto; +} +``` + +- [ ] **Create bonds.service.ts** + +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; + +@Injectable() +export class BondsService { + constructor( + private readonly moexClient: MoexClientService, + private readonly cache: CacheService, + ) {} + + async getBond(secid: string) { + const { data: bond, fromCache, cachedAt } = await this.cache.getOrFetch( + 'bond', + [secid], + () => this.moexClient.getBondData(secid), + 'securityTtl', + ); + + if (!bond) { + throw new NotFoundException(`Bond ${secid} not found`); + } + + const { data: mkt } = await this.cache.getOrFetch( + 'marketdata', + ['bonds', secid], + () => this.moexClient.getBondMarketData(secid), + 'marketDataTtl', + ); + + return { + data: { + secid: bond.secid, + isin: bond.isin, + name: bond.shortName, + shortName: bond.shortName, + latName: null, + listLevel: bond.listLevel, + issueSize: bond.issueSize, + faceValue: bond.faceValue, + faceUnit: bond.isin.startsWith('XS') ? 'USD' : 'RUB', + matDate: bond.matDate, + couponValue: bond.couponValue ?? 0, + couponPercent: bond.couponPercent, + couponPeriod: bond.couponPeriod, + nextCoupon: bond.nextCoupon, + accruedInt: bond.accruedInt ?? 0, + bondType: bond.bondType, + bondSubType: bond.bondSubType, + offerDate: bond.offerDate, + buybackDate: bond.buybackDate, + marketData: { + price: mkt?.last ?? bond.prevPrice ?? 0, + yieldToMaturity: mkt?.yield ?? bond.yieldAtPrevWaprice ?? null, + duration: mkt?.duration ?? null, + accruedInt: bond.accruedInt ?? 0, + couponValue: bond.couponValue ?? 0, + couponPercent: bond.couponPercent, + nextCouponDate: bond.nextCoupon, + open: mkt?.open ?? 0, + high: mkt?.high ?? null, + low: mkt?.low ?? null, + volume: mkt?.volume ?? 0, + updatedAt: mkt?.updateTime + ? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime + : new Date().toISOString(), + }, + }, + meta: { fromCache, cachedAt }, + }; + } + + async getMarketData(secid: string) { + const { data: mkt, fromCache, cachedAt } = await this.cache.getOrFetch( + 'marketdata', + ['bonds', secid], + () => this.moexClient.getBondMarketData(secid), + 'marketDataTtl', + ); + + if (!mkt) { + throw new NotFoundException(`Market data for bond ${secid} not found`); + } + + return { + data: { + price: mkt.last ?? 0, + yieldToMaturity: mkt.yield ?? null, + duration: mkt.duration ?? null, + accruedInt: 0, + couponValue: 0, + couponPercent: null, + nextCouponDate: null, + open: mkt.open ?? 0, + high: mkt.high ?? null, + low: mkt.low ?? null, + volume: mkt.volume ?? 0, + updatedAt: mkt.updateTime + ? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime + : new Date().toISOString(), + }, + meta: { fromCache, cachedAt }, + }; + } + + async getHistory(secid: string, from: string, till: string) { + const { data, fromCache, cachedAt } = await this.cache.getOrFetch( + 'history', + ['bonds', secid, from, till], + () => this.moexClient.getBondHistory(secid, from, till), + 'historyTtl', + ); + + return { + data: data.map((h) => ({ + date: h.tradeDate, + closePrice: h.legalClosePrice ?? h.close ?? 0, + yieldClose: h.yieldClose ?? null, + duration: h.duration ?? null, + })), + meta: { fromCache, cachedAt }, + }; + } +} +``` + +- [ ] **Create bonds.controller.ts** + +```typescript +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { BondsService } from './bonds.service'; + +@ApiTags('Bonds') +@Controller('securities/bonds') +export class BondsController { + constructor(private readonly bondsService: BondsService) {} + + @Get(':secid') + @ApiOperation({ summary: 'Получить спецификацию облигации' }) + async getBond(@Param('secid') secid: string) { + return this.bondsService.getBond(secid); + } + + @Get(':secid/marketdata') + @ApiOperation({ summary: 'Получить рыночные данные облигации' }) + async getMarketData(@Param('secid') secid: string) { + return this.bondsService.getMarketData(secid); + } + + @Get(':secid/history') + @ApiOperation({ summary: 'Получить дневную историю торгов облигации' }) + async getHistory( + @Param('secid') secid: string, + @Query('from') from: string, + @Query('till') till: string, + ) { + return this.bondsService.getHistory(secid, from, till); + } +} +``` + +- [ ] **Create bonds.module.ts** + +```typescript +import { Module } from '@nestjs/common'; +import { BondsController } from './bonds.controller'; +import { BondsService } from './bonds.service'; + +@Module({ + controllers: [BondsController], + providers: [BondsService], + exports: [BondsService], +}) +export class BondsModule {} +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/modules/bonds/ +git commit -m "feat: add bonds endpoint with market data and history" +``` + +### Task 3.2: Candles module (shared by shares + bonds) + +**Files:** +- Create: `apps/backend/src/modules/candles/dto/candles-query.dto.ts` +- Create: `apps/backend/src/modules/candles/candles.service.ts` +- Create: `apps/backend/src/modules/candles/candles.controller.ts` +- Create: `apps/backend/src/modules/candles/candles.module.ts` + +- [ ] **Create candles-query.dto.ts** + +```typescript +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsEnum, IsDateString } from 'class-validator'; + +export enum CandleInterval { + HOUR = '1h', + DAY = '24h', +} + +export class CandlesQueryDto { + @ApiProperty({ enum: CandleInterval }) + @IsEnum(CandleInterval) + interval: CandleInterval; + + @ApiProperty({ format: 'date', example: '2025-06-13' }) + @IsDateString() + from: string; + + @ApiProperty({ format: 'date', example: '2026-06-13' }) + @IsDateString() + till: string; +} +``` + +- [ ] **Create candles.service.ts** + +```typescript +import { Injectable } from '@nestjs/common'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import { CandleInterval } from './dto/candles-query.dto'; + +@Injectable() +export class CandlesService { + constructor( + private readonly moexClient: MoexClientService, + private readonly cache: CacheService, + ) {} + + private mapInterval(interval: CandleInterval): 60 | 24 { + return interval === CandleInterval.HOUR ? 60 : 24; + } + + async getCandles( + market: 'shares' | 'bonds', + secid: string, + interval: CandleInterval, + from: string, + till: string, + ) { + const moexInterval = this.mapInterval(interval); + const { data, fromCache, cachedAt } = await this.cache.getOrFetch( + 'candles', + [market, secid, String(moexInterval), from, till], + () => + this.moexClient.getCandles('stock', market, secid, moexInterval, from, till), + 'candlesTtl', + ); + + return { + data: data.map((c) => ({ + open: c.open, + high: c.high, + low: c.low, + close: c.close, + volume: c.volume, + value: c.value, + begin: c.begin, + end: c.end, + })), + meta: { fromCache, cachedAt }, + }; + } +} +``` + +- [ ] **Create candles.controller.ts** + +```typescript +import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { CandlesService } from './candles.service'; +import { CandlesQueryDto } from './dto/candles-query.dto'; + +@ApiTags('Candles') +@Controller('securities') +export class CandlesController { + constructor(private readonly candlesService: CandlesService) {} + + @Get('shares/:secid/candles') + @ApiOperation({ summary: 'Получить свечи акции' }) + async getShareCandles( + @Param('secid') secid: string, + @Query(ValidationPipe) query: CandlesQueryDto, + ) { + return this.candlesService.getCandles('shares', secid, query.interval, query.from, query.till); + } + + @Get('bonds/:secid/candles') + @ApiOperation({ summary: 'Получить свечи облигации' }) + async getBondCandles( + @Param('secid') secid: string, + @Query(ValidationPipe) query: CandlesQueryDto, + ) { + return this.candlesService.getCandles('bonds', secid, query.interval, query.from, query.till); + } +} +``` + +- [ ] **Create candles.module.ts** + +```typescript +import { Module } from '@nestjs/common'; +import { CandlesController } from './candles.controller'; +import { CandlesService } from './candles.service'; + +@Module({ + controllers: [CandlesController], + providers: [CandlesService], + exports: [CandlesService], +}) +export class CandlesModule {} +``` + +- [ ] **Add OpenAPI decorators to share history endpoint** in `shares.controller.ts`: + +```typescript +@Get(':secid/history') +@ApiOperation({ summary: 'Получить дневную историю торгов акции' }) +async getHistory( + @Param('secid') secid: string, + @Query('from') from: string, + @Query('till') till: string, +) { + return this.sharesService.getHistory(secid, from, till); +} +``` + +- [ ] **Add getHistory method to SharesService**: + +```typescript +async getHistory(secid: string, from: string, till: string) { + const { data, fromCache, cachedAt } = await this.cache.getOrFetch( + 'history', + ['shares', secid, from, till], + () => this.moexClient.getHistory(secid, from, till), + 'historyTtl', + ); + + return { + data: data.map((h) => ({ + date: h.tradeDate, + open: h.open ?? 0, + high: h.high ?? 0, + low: h.low ?? 0, + close: h.close ?? 0, + volume: h.volume, + value: h.value, + })), + meta: { fromCache, cachedAt }, + }; +} +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/modules/candles/ +git commit -m "feat: add candles module with 1h/24h intervals for shares and bonds" +``` + +--- + +## SPRINT 4: Frontend Foundation + +### Task 4.1: Scaffold React + Vite frontend + +**Files:** +- Create: `apps/frontend/package.json` +- Create: `apps/frontend/tsconfig.json` +- Create: `apps/frontend/tsconfig.node.json` +- Create: `apps/frontend/vite.config.ts` +- Create: `apps/frontend/index.html` +- Create: `apps/frontend/src/vite-env.d.ts` +- Create: `apps/frontend/src/main.tsx` +- Create: `apps/frontend/src/App.tsx` +- Create: `apps/frontend/src/routes.tsx` +- Create: `apps/frontend/src/styles.css` + +- [ ] **Create apps/frontend/package.json** + +```json +{ + "name": "@moex-vibe/frontend", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts" + }, + "dependencies": { + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^6.20.0", + "@tanstack/react-query": "^5.20.0", + "openapi-fetch": "^0.9.0", + "lightweight-charts": "^4.1.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.3.0", + "vite": "^5.4.0", + "openapi-typescript": "^7.0.0" + } +} +``` + +- [ ] **Create apps/frontend/tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} +``` + +- [ ] **Create apps/frontend/tsconfig.node.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true + }, + "include": ["vite.config.ts"] +} +``` + +- [ ] **Create apps/frontend/vite.config.ts** + +```typescript +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, +}); +``` + +- [ ] **Create apps/frontend/index.html** + +```html + + + + + + MoexVibe + + +
+ + + +``` + +- [ ] **Create apps/frontend/src/vite-env.d.ts** + +```typescript +/// +``` + +- [ ] **Create apps/frontend/src/main.tsx** + +```typescript +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import App from './App'; +import './styles.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 2, + staleTime: 900_000, + refetchOnWindowFocus: false, + }, + }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +); +``` + +- [ ] **Create apps/frontend/src/App.tsx** + +```typescript +import { BrowserRouter } from 'react-router-dom'; +import { AppRoutes } from './routes'; + +export default function App() { + return ( + + + + ); +} +``` + +- [ ] **Create apps/frontend/src/routes.tsx** + +```typescript +import { Routes, Route } from 'react-router-dom'; +import { Layout } from './components/Layout'; +import { HomePage } from './pages/HomePage'; +import { StockPage } from './pages/StockPage'; +import { BondPage } from './pages/BondPage'; + +export function AppRoutes() { + return ( + + }> + } /> + } /> + } /> + + + ); +} +``` + +- [ ] **Create apps/frontend/src/styles.css** — minimal reset: + +```css +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --color-bg: #f5f5f5; + --color-surface: #ffffff; + --color-text: #1a1a1a; + --color-text-secondary: #666; + --color-primary: #1976d2; + --color-positive: #2e7d32; + --color-negative: #c62828; + --border-radius: 8px; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.12); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--color-bg); + color: var(--color-text); + line-height: 1.6; +} + +a { + color: var(--color-primary); + text-decoration: none; +} +``` + +- [ ] **Commit** + +```bash +git add apps/frontend/ +git commit -m "feat: scaffold React + Vite frontend with routing" +``` + +### Task 4.2: Generate API client from OpenAPI schema + +- [ ] **Start backend**, then run: + +```bash +npm run codegen -w apps/frontend +``` + +This creates `apps/frontend/src/api/types.ts` with all typed DTOs. + +- [ ] **Create apps/frontend/src/api/client.ts** — typed fetch wrapper: + +```typescript +import createClient from 'openapi-fetch'; +import type { paths } from './types'; + +export const apiClient = createClient({ + baseUrl: '/api/v1', +}); + +export type ApiResponse = { + data: T; + meta: { + cachedAt: string | null; + fromCache: boolean; + }; +}; +``` + +- [ ] **Commit** + +```bash +git add apps/frontend/src/api/ +git commit -m "feat: add openapi-typescript generated types and API client" +``` + +### Task 4.3: Layout component + +**Files:** +- Create: `apps/frontend/src/components/Layout.tsx` + +- [ ] **Create Layout.tsx** + +```typescript +import { Outlet, Link } from 'react-router-dom'; + +const headerStyle: React.CSSProperties = { + background: 'var(--color-surface)', + borderBottom: '1px solid #e0e0e0', + padding: '12px 24px', + display: 'flex', + alignItems: 'center', + gap: 24, + position: 'sticky', + top: 0, + zIndex: 100, +}; + +const mainStyle: React.CSSProperties = { + maxWidth: 1200, + margin: '0 auto', + padding: '24px 16px', +}; + +export function Layout() { + return ( +
+
+ + MoexVibe + +
+
+ +
+
+ ); +} +``` + +- [ ] **Commit** + +```bash +git add apps/frontend/src/components/Layout.tsx +git commit -m "feat: add Layout component with header" +``` + +### Task 4.4: Search hook + HomePage + +**Files:** +- Create: `apps/frontend/src/hooks/useSearch.ts` +- Create: `apps/frontend/src/components/SearchBar.tsx` +- Create: `apps/frontend/src/components/SecurityCard.tsx` +- Create: `apps/frontend/src/pages/HomePage.tsx` + +- [ ] **Create useSearch.ts** + +```typescript +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '../api/client'; + +export function useSearch(query: string) { + return useQuery({ + queryKey: ['search', query], + queryFn: async () => { + const { data } = await apiClient.GET('/securities/search', { + params: { query: { q: query, limit: 20 } }, + }); + return data?.data ?? []; + }, + enabled: query.length >= 1, + staleTime: 60_000, + }); +} +``` + +- [ ] **Create SearchBar.tsx** + +```typescript +import { useState, useCallback } from 'react'; + +interface SearchBarProps { + onSearch: (query: string) => void; +} + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '12px 16px', + fontSize: 16, + border: '1px solid #ddd', + borderRadius: 'var(--border-radius)', + outline: 'none', +}; + +export function SearchBar({ onSearch }: SearchBarProps) { + const [value, setValue] = useState(''); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const v = e.target.value; + setValue(v); + onSearch(v); + }, + [onSearch], + ); + + return ( + + ); +} +``` + +- [ ] **Create SecurityCard.tsx** + +```typescript +import { Link } from 'react-router-dom'; + +interface SecurityCardProps { + secid: string; + shortName: string; + type: 'share' | 'bond'; + isin: string; + listLevel: number; + currency: string | null; + price: number | null; +} + +const cardStyle: React.CSSProperties = { + background: 'var(--color-surface)', + borderRadius: 'var(--border-radius)', + boxShadow: 'var(--shadow)', + padding: 16, + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}; + +const badgeStyle: React.CSSProperties = { + fontSize: 12, + padding: '2px 8px', + borderRadius: 4, + fontWeight: 600, +}; + +export function SecurityCard({ secid, shortName, type, isin, currency, price }: SecurityCardProps) { + const linkTo = type === 'share' ? `/stocks/${secid}` : `/bonds/${secid}`; + + return ( + +
+
+
{secid}
+
+ {shortName} · {isin} +
+
+
+ + {type === 'share' ? 'Акция' : 'Облигация'} + + {price != null && ( +
+ {price.toLocaleString('ru-RU')} {currency || ''} +
+ )} +
+
+ + ); +} +``` + +- [ ] **Create HomePage.tsx** + +```typescript +import { useState } from 'react'; +import { SearchBar } from '../components/SearchBar'; +import { SecurityCard } from '../components/SecurityCard'; +import { useSearch } from '../hooks/useSearch'; + +export function HomePage() { + const [query, setQuery] = useState(''); + const { data: results, isLoading } = useSearch(query); + + return ( +
+

Поиск инструментов

+ + + {isLoading &&
Загрузка...
} + + {results && results.length === 0 && query.length > 0 && ( +
+ Ничего не найдено +
+ )} + + {results && ( +
+ {results.map((item) => ( + + ))} +
+ )} +
+ ); +} +``` + +- [ ] **Commit** + +```bash +git add apps/frontend/src/hooks/useSearch.ts apps/frontend/src/components/SearchBar.tsx apps/frontend/src/components/SecurityCard.tsx apps/frontend/src/pages/HomePage.tsx +git commit -m "feat: add search page with SecurityCard and SearchBar" +``` + +--- + +## SPRINT 5: Frontend Details + +### Task 5.1: Stock page hook + +**Files:** +- Create: `apps/frontend/src/hooks/useStock.ts` +- Create: `apps/frontend/src/hooks/useStockCandles.ts` +- Create: `apps/frontend/src/hooks/useStockDividends.ts` + +- [ ] **Create useStock.ts** + +```typescript +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '../api/client'; + +export function useStock(secid: string) { + return useQuery({ + queryKey: ['stock', secid], + queryFn: async () => { + const { data } = await apiClient.GET('/securities/shares/{secid}', { + params: { path: { secid } }, + }); + return data?.data ?? null; + }, + staleTime: 900_000, + }); +} + +export function useStockMarketData(secid: string) { + return useQuery({ + queryKey: ['stockMarketData', secid], + queryFn: async () => { + const { data } = await apiClient.GET('/securities/shares/{secid}/marketdata', { + params: { path: { secid } }, + }); + return data?.data ?? null; + }, + staleTime: 900_000, + }); +} +``` + +- [ ] **Create useStockCandles.ts** + +```typescript +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '../api/client'; + +export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) { + return useQuery({ + queryKey: ['stockCandles', secid, interval, from, till], + queryFn: async () => { + const { data } = await apiClient.GET('/securities/shares/{secid}/candles', { + params: { + path: { secid }, + query: { interval, from, till }, + }, + }); + return data?.data ?? []; + }, + staleTime: 3600_000, + }); +} +``` + +- [ ] **Create useStockDividends.ts** + +```typescript +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '../api/client'; + +export function useStockDividends(secid: string) { + return useQuery({ + queryKey: ['stockDividends', secid], + queryFn: async () => { + const { data } = await apiClient.GET('/securities/shares/{secid}/dividends', { + params: { path: { secid } }, + }); + return data?.data ?? []; + }, + staleTime: 86400_000, + }); +} +``` + +- [ ] **Commit** + +```bash +git add apps/frontend/src/hooks/ +git commit -m "feat: add React Query hooks for stock, candles, dividends" +``` + +### Task 5.2: StockDetails component + +**Files:** +- Create: `apps/frontend/src/components/StockDetails.tsx` + +- [ ] **Create StockDetails.tsx** + +```typescript +import type { components } from '../api/types'; + +type Stock = components['schemas']['StockResponse']['data']; + +interface StockDetailsProps { + stock: NonNullable; +} + +const rowStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + padding: '8px 0', + borderBottom: '1px solid #eee', +}; + +export function StockDetails({ stock }: StockDetailsProps) { + const { marketData } = stock; + const isPositive = marketData.change >= 0; + + return ( +
+
+

+ {stock.shortName} ({stock.secid}) +

+
+ {stock.name} · {stock.isin} +
+
+ +
+ {marketData.price.toLocaleString('ru-RU', { minimumFractionDigits: 2 })}{' '} + + {isPositive ? '+' : ''}{marketData.change.toFixed(2)} ({marketData.changePercent.toFixed(2)}%) + +
+ +
+
+ Открытие + {marketData.open.toFixed(2)} +
+
+ Максимум + {marketData.high?.toFixed(2) ?? '—'} +
+
+ Минимум + {marketData.low?.toFixed(2) ?? '—'} +
+
+ Объём + {marketData.volume.toLocaleString('ru-RU')} +
+
+ Капитализация + + {marketData.issueCapitalization + ? (marketData.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽' + : '—'} + +
+
+ ISIN + {stock.isin} +
+
+ Уровень листинга + {stock.listLevel} +
+
+
+ ); +} +``` + +- [ ] **Commit** + +```bash +git add apps/frontend/src/components/StockDetails.tsx +git commit -m "feat: add StockDetails component" +``` + +### Task 5.3: Bond page hooks + +**Files:** +- Create: `apps/frontend/src/hooks/useBond.ts` +- Create: `apps/frontend/src/hooks/useBondCandles.ts` + +- [ ] **Create useBond.ts** + +```typescript +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '../api/client'; + +export function useBond(secid: string) { + return useQuery({ + queryKey: ['bond', secid], + queryFn: async () => { + const { data, error } = await apiClient.GET('/securities/bonds/{secid}', { + params: { path: { secid } }, + }); + if (error) throw new Error(error.message); + return data?.data ?? null; + }, + staleTime: 900_000, + }); +} +``` + +- [ ] **Create useBondCandles.ts** + +```typescript +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '../api/client'; + +export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) { + return useQuery({ + queryKey: ['bondCandles', secid, interval, from, till], + queryFn: async () => { + const { data } = await apiClient.GET('/securities/bonds/{secid}/candles', { + params: { + path: { secid }, + query: { interval, from, till }, + }, + }); + return data?.data ?? []; + }, + staleTime: 3600_000, + }); +} +``` + +- [ ] **Commit** + +```bash +git add apps/frontend/src/hooks/useBond.ts apps/frontend/src/hooks/useBondCandles.ts +git commit -m "feat: add React Query hooks for bond and bond candles" +``` + +### Task 5.4: BondDetails component + +**Files:** +- Create: `apps/frontend/src/components/BondDetails.tsx` + +- [ ] **Create BondDetails.tsx** + +```typescript +interface BondDetailsProps { + bond: any; // Тип генерируется openapi-typescript из схемы BondResponse +} + +const rowStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + padding: '8px 0', + borderBottom: '1px solid #eee', +}; + +export function BondDetails({ bond }: BondDetailsProps) { + const md = bond.marketData; + + return ( +
+
+

{bond.shortName}

+
+ {bond.isin} +
+
+ +
+ {md.price.toFixed(2)}% +
+ +
+
+ Номинал + {bond.faceValue.toLocaleString('ru-RU')} {bond.faceUnit} +
+
+ Дата погашения + {bond.matDate} +
+
+ Купон + {md.couponValue} ₽ {md.couponPercent != null ? `(${md.couponPercent}%)` : ''} +
+
+ Период купона + {bond.couponPeriod} дней +
+
+ Следующий купон + {md.nextCouponDate ?? '—'} +
+
+ НКД + {md.accruedInt.toFixed(2)} ₽ +
+
+ Доходность к погашению + {md.yieldToMaturity != null ? md.yieldToMaturity.toFixed(2) + '%' : '—'} +
+
+ Дюрация + {md.duration != null ? md.duration.toFixed(2) : '—'} +
+
+ Тип + {bond.bondType} +
+
+ ISIN + {bond.isin} +
+
+
+ ); +} +``` + +- [ ] **Commit** + +```bash +git add apps/frontend/src/components/BondDetails.tsx +git commit -m "feat: add BondDetails component" +``` + +### Task 5.5: PriceChart component + +**Files:** +- Create: `apps/frontend/src/components/PriceChart.tsx` + +- [ ] **Create PriceChart.tsx** + +```typescript +import { useEffect, useRef } from 'react'; +import { createChart, ColorType, IChartApi, CandlestickData, Time } from 'lightweight-charts'; + +interface PriceChartProps { + data: Array<{ + open: number; + high: number; + low: number; + close: number; + begin: string; + }>; + height?: number; +} + +export function PriceChart({ data, height = 400 }: PriceChartProps) { + const chartContainerRef = useRef(null); + const chartRef = useRef(null); + + useEffect(() => { + if (!chartContainerRef.current) return; + + const chart = createChart(chartContainerRef.current, { + layout: { + background: { type: ColorType.Solid, color: '#ffffff' }, + textColor: '#333', + }, + width: chartContainerRef.current.clientWidth, + height, + grid: { + vertLines: { color: '#f0f0f0' }, + horzLines: { color: '#f0f0f0' }, + }, + timeScale: { + timeVisible: false, + }, + }); + + const candleSeries = chart.addCandlestickSeries({ + upColor: '#2e7d32', + downColor: '#c62828', + borderDownColor: '#c62828', + borderUpColor: '#2e7d32', + wickDownColor: '#c62828', + wickUpColor: '#2e7d32', + }); + + const chartData: CandlestickData[] = data.map((candle) => ({ + time: (new Date(candle.begin).getTime() / 1000) as Time, + open: candle.open, + high: candle.high, + low: candle.low, + close: candle.close, + })); + + candleSeries.setData(chartData); + chart.timeScale().fitContent(); + chartRef.current = chart; + + const handleResize = () => { + if (chartContainerRef.current) { + chart.applyOptions({ width: chartContainerRef.current.clientWidth }); + } + }; + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + chart.remove(); + }; + }, [data, height]); + + return
; +} +``` + +- [ ] **Commit** + +```bash +git add apps/frontend/src/components/PriceChart.tsx +git commit -m "feat: add PriceChart component using lightweight-charts" +``` + +### Task 5.6: StockPage and BondPage + +**Files:** +- Modify: `apps/frontend/src/pages/StockPage.tsx` +- Modify: `apps/frontend/src/pages/BondPage.tsx` + +- [ ] **Create StockPage.tsx** + +```typescript +import { useParams } from 'react-router-dom'; +import { useStock } from '../hooks/useStock'; +import { useStockCandles } from '../hooks/useStockCandles'; +import { useStockDividends } from '../hooks/useStockDividends'; +import { StockDetails } from '../components/StockDetails'; +import { PriceChart } from '../components/PriceChart'; + +export function StockPage() { + const { secid } = useParams<{ secid: string }>(); + const { data: stock, isLoading, error } = useStock(secid!); + const till = new Date().toISOString().split('T')[0]; + const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + const { data: candles } = useStockCandles(secid!, '24h', from, till); + const { data: dividends } = useStockDividends(secid!); + + if (isLoading) return
Загрузка...
; + if (error || !stock) return
Инструмент не найден
; + + return ( +
+ + +
+

График цены

+ +
+ + {dividends && dividends.length > 0 && ( +
+

Дивиденды

+ + + + + + + + + {dividends.map((d, i) => ( + + + + + ))} + +
Дата закрытия реестраСумма
{d.registryCloseDate} + {d.value.toFixed(2)} {d.currency} +
+
+ )} +
+ ); +} +``` + +- [ ] **Create BondPage.tsx** + +```typescript +import { useParams } from 'react-router-dom'; +import { useBond } from '../hooks/useBond'; +import { useBondCandles } from '../hooks/useBondCandles'; +import { BondDetails } from '../components/BondDetails'; +import { PriceChart } from '../components/PriceChart'; + +export function BondPage() { + const { secid } = useParams<{ secid: string }>(); + const { data: bond, isLoading, error } = useBond(secid!); + const till = new Date().toISOString().split('T')[0]; + const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + const { data: candles } = useBondCandles(secid!, '24h', from, till); + + if (isLoading) return
Загрузка...
; + if (error || !bond) return
Инструмент не найден
; + + return ( +
+ + +
+

График цены

+ +
+
+ ); +} +``` + +- [ ] **Commit** + +```bash +git add apps/frontend/src/pages/ +git commit -m "feat: add StockPage and BondPage with charts and details" +``` + +--- + +## SPRINT 6: Documentation + Infrastructure + +### Task 6.1: OpenAPI spec finalization + +- [ ] **Verify OpenAPI spec** — start backend, check Swagger UI at `/api/docs`. Ensure all endpoints, schemas, and examples are present. + +- [ ] **Sync `docs/openapi/openapi.yaml`** with the generated spec if any changes were made during development. + +- [ ] **Commit** + +```bash +git add docs/openapi/openapi.yaml +git commit -m "docs: finalize OpenAPI specification" +``` + +### Task 6.2: ADR documentation + +- [ ] **Ensure all ADR files exist** in `docs/architecture/adr/` (created during design phase, adjust if needed). + +- [ ] **Add architecture overview diagram** (ASCII sequence diagram or Mermaid): + +```markdown +# Architecture Overview + +```mermaid +sequenceDiagram + participant User + participant Frontend as React SPA + participant Backend as NestJS API + participant Cache as In-Memory Cache + participant MOEX as MOEX ISS + + User->>Frontend: Search / View instrument + Frontend->>Backend: GET /api/v1/securities/search?q=SBER + Backend->>Cache: getOrFetch('search:sber') + alt Cache miss + Cache->>Backend: null + Backend->>MOEX: GET /iss/securities?q=SBER + MOEX-->>Backend: raw data + Backend->>Cache: set('search:sber', normalized, TTL=3600) + else Cache hit + Cache-->>Backend: cached data + end + Backend-->>Frontend: normalized response + Frontend-->>User: rendered UI +``` +``` + +- [ ] **Commit** + +```bash +git add docs/ +git commit -m "docs: add ADR documents and architecture diagrams" +``` + +### Task 6.3: Docker setup + +**Files:** +- Create: `docker/Dockerfile.backend` +- Create: `docker/Dockerfile.frontend` +- Create: `docker/nginx.conf` +- Create: `docker-compose.yml` + +- [ ] **Create Dockerfile.backend** + +```dockerfile +FROM node:20-alpine AS build +WORKDIR /app +COPY apps/backend/package.json ./ +RUN npm install +COPY apps/backend/ ./ +RUN npm run build + +FROM node:20-alpine AS production +WORKDIR /app +COPY --from=build /app/dist ./dist +COPY --from=build /app/node_modules ./node_modules +COPY apps/backend/package.json ./ +EXPOSE 3000 +CMD ["node", "dist/main.js"] +``` + +- [ ] **Create Dockerfile.frontend** + +```dockerfile +FROM node:20-alpine AS build +WORKDIR /app +COPY apps/frontend/package.json ./ +RUN npm install +COPY apps/frontend/ ./ +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] +``` + +- [ ] **Create nginx.conf** + +```nginx +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +- [ ] **Create docker-compose.yml** + +```yaml +services: + backend: + build: + context: ../../superpowers/plans + dockerfile: docker/Dockerfile.backend + ports: + - "3000:3000" + environment: + - PORT=3000 + - MOEX_BASE_URL=https://iss.moex.com/iss + - MOEX_RATE_LIMIT=10 + + frontend: + build: + context: ../../superpowers/plans + dockerfile: docker/Dockerfile.frontend + ports: + - "80:80" + depends_on: + - backend +``` + +- [ ] **Commit** + +```bash +git add docker/ docker-compose.yml +git commit -m "infra: add Docker setup with docker-compose" +``` + +### Task 6.4: README and final checks + +- [ ] **Create README.md** with: + - Project overview + - Tech stack + - Quick start (npm install, npm run dev:backend, npm run dev:frontend) + - Docker instructions + - Links to docs + +- [ ] **Run full test suite**: + +```bash +npm run test:backend +``` + +- [ ] **Verify type generation**: + +```bash +cd apps/frontend && npx openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts +``` + +- [ ] **Final commit** + +```bash +git add README.md +git commit -m "chore: add README with quick start instructions" +``` + +--- diff --git a/docs/superpowers/specs/2026-06-13-moex-vibe-design.md b/docs/features/moex-vibe/spec.md similarity index 100% rename from docs/superpowers/specs/2026-06-13-moex-vibe-design.md rename to docs/features/moex-vibe/spec.md diff --git a/docs/features/pagination-loading-overlay/plan.md b/docs/features/pagination-loading-overlay/plan.md new file mode 100644 index 0000000..5d71677 --- /dev/null +++ b/docs/features/pagination-loading-overlay/plan.md @@ -0,0 +1,514 @@ +# Pagination Loading Overlay — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add overlay + spinner to all broker paginated tables when switching pages + +**Architecture:** Use TanStack Query's `isFetching` (any fetch) vs `isLoading` (initial fetch) to show overlay when data exists and a new page is loading. Keep `keepPreviousData` so old data stays visible under the overlay. Add CSS spinner animation. + +**Tech Stack:** React, TanStack Query v5, CSS custom properties + +--- + +### Task 1: CSS — spinner animation and overlay styles + +**Files:** +- Modify: `apps/frontend/src/styles.css` + +- [ ] **Step 1: Add spinner keyframes and loading-spinner class** + +Add to `apps/frontend/src/styles.css` at the end: + +```css +@keyframes loading-spin { + to { transform: rotate(360deg); } +} + +.loading-spinner { + width: 20px; + height: 20px; + border: 2px solid var(--color-bg); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: loading-spin 0.8s linear infinite; +} + +.table-container { + position: relative; +} + +.table-loading-overlay { + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.65); + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 12px; + transition: opacity 0.2s ease; + z-index: 1; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend/src/styles.css +git commit -m "style: add loading-spinner and overlay CSS classes" +``` + +--- + +### Task 2: PositionGroupTable — overlay on pagination + spinner in buttons + +**Files:** +- Modify: `apps/frontend/src/pages/broker/BrokerPositionsSection.tsx` + +- [ ] **Step 1: Add `isFetching` to the query destructuring** + +Line 103 changes from: +```tsx +const { data: page, isLoading } = useBrokerPositions(accountId, query); +``` +to: +```tsx +const { data: page, isLoading, isFetching } = useBrokerPositions(accountId, query); +``` + +- [ ] **Step 2: Replace the loading rendering section** + +Current (lines 179-213): +```tsx +{isLoading && ( +
+ + + + + + + + + + + +
ТикерНазваниеКоличествоЦенаСтоимость
+
+)} + +{!isLoading && positions.length > 0 && ( +
+ + ... +
+
+)} +``` + +Replace with new rendering logic: + +```tsx +{isLoading && ( +
+ + + + + + + + + + + +
ТикерНазваниеКоличествоЦенаСтоимость
+
+)} + +{!isLoading && positions.length > 0 && ( +
+
+ + + + + + + + + + + + {positions.map((position) => ( + + + + + + + + ))} + +
ТикерНазваниеКоличествоЦенаСтоимость
+ + {position.name || '-'} + + {formatQuantity(position.quantity)}{formatMoney(position.currentPrice)}{formatMoney(position.currentValue)}
+
+ {isFetching && ( +
+
+ + Загрузка страницы {pageNumber}… + +
+ )} +
+)} +``` + +- [ ] **Step 3: Update pagination buttons to show spinner during fetch** + +Replace the button content in lines 167-174 (the "→" button): + +```tsx + +``` + +Also update the "←" button (lines 148-155): + +```tsx + +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/frontend/src/pages/broker/BrokerPositionsSection.tsx +git commit -m "feat: add loading overlay and spinner to PositionGroupTable" +``` + +--- + +### Task 3: BrokerOperationsTable — new `isFetching` prop + overlay + +**Files:** +- Modify: `apps/frontend/src/pages/broker/BrokerOperationsTable.tsx` + +- [ ] **Step 1: Add `isFetching` to props interface** + +Change the component props destructuring (line 96-113): + +```tsx +export function BrokerOperationsTable({ + isLoading, + isFetching, + page, + pageNumber, + canGoBack, + canGoForward, + onPrevious, + onNext, +}: { + isLoading: boolean; + isFetching: boolean; + page: BrokerOperationsPage | undefined; + pageNumber: number; + canGoBack: boolean; + canGoForward: boolean; + onPrevious: () => void; + onNext: () => void; +}) { +``` + +- [ ] **Step 2: Replace the loading/empty/data rendering** + +Current (lines 158-229): + +```tsx +{isLoading ? ( +
+ + + + + + + + + + +
ДатаТипИнструментСумма
+
+) : operations.length === 0 ? ( +

Операций за выбранный период нет

+) : ( +
+ + + + + + + + + + + {operations.map((operation) => { + const impact = getBrokerOperationImpact(operation); + return ( + + + + + + + ); + })} + +
ДатаТипИнструментСумма
{formatDate(operation.date)}{getBrokerOperationTypeLabel(operation)} + {formatMoney(operation.payment)} +
+
+)} +``` + +Replace with: + +```tsx +{isLoading ? ( +
+ + + + + + + + + + +
ДатаТипИнструментСумма
+
+) : operations.length === 0 && !isFetching ? ( +

Операций за выбранный период нет

+) : ( +
+
+ + + + + + + + + + + {operations.map((operation) => { + const impact = getBrokerOperationImpact(operation); + return ( + + + + + + + ); + })} + +
ДатаТипИнструментСумма
{formatDate(operation.date)}{getBrokerOperationTypeLabel(operation)} + {formatMoney(operation.payment)} +
+
+ {isFetching && ( +
+
+ + Загрузка страницы {pageNumber}… + +
+ )} +
+)} +``` + +Note: The empty state check changed from `operations.length === 0` to `operations.length === 0 && !isFetching` — this ensures the overlay shows on top of old data, not the empty message. + +- [ ] **Step 3: Update pagination buttons** + +Replace line 132-135 (← button): + +```tsx + +``` + +Replace lines 147-154 (→ button): + +```tsx + +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/frontend/src/pages/broker/BrokerOperationsTable.tsx +git commit -m "feat: add loading overlay and spinner to BrokerOperationsTable" +``` + +--- + +### Task 4: BrokerAccountDetailPage — pass `isFetching` to operations table + +**Files:** +- Modify: `apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx` + +- [ ] **Step 1: Add `isFetching` to the BrokerOperationsTable props** + +Change the `` call (line 123-131): + +```tsx + 0} + canGoForward={Boolean(operations.data?.hasNext && operations.data.nextCursor)} + onPrevious={handlePreviousOperationsPage} + onNext={handleNextOperationsPage} +/> +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/frontend/src/pages/broker/BrokerAccountDetailPage.tsx +git commit -m "feat: pass isFetching to BrokerOperationsTable" +``` + +--- + +### Task 5: Update tests + +**Files:** +- Modify: `apps/frontend/src/pages/broker/BrokerPages.test.tsx` + +- [ ] **Step 1: Add `isFetching: false` to all existing position mocks** + +In `mockUseBrokerPositions` (line 47-61), add `isFetching: false`: + +```tsx +return { + data: { + accountId: 'acc-1', + items: filtered, + nextCursor: null, + hasNext: false, + asOf: '2026-06-17T00:00:00.000Z', + }, + isLoading: false, + isFetching: false, + error: null, +} as any; +``` + +- [ ] **Step 2: Add `isFetching: false` to all operations mocks** + +Add `isFetching: false` alongside each `isLoading: false` in the operations mocks (lines 58, 85, 112, 147, 190, 201, 266, 324, 363, 433). + +For example, line 112 area becomes: +```tsx +data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-17T00:00:00.000Z' }, +isLoading: false, +isFetching: false, +``` + +- [ ] **Step 3: Verify tests pass** + +```bash +npx vitest run apps/frontend/src/pages/broker/BrokerPages.test.tsx -w apps/frontend +``` + +Expected: All tests PASS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/frontend/src/pages/broker/BrokerPages.test.tsx +git commit -m "test: add isFetching to mock return values" +``` + +--- + +### Task 6: Lint and final verification + +- [ ] **Step 1: Run lint** + +```bash +npm run lint +``` + +Expected: No errors (or only pre-existing ones). + +- [ ] **Step 2: Run full frontend test suite** + +```bash +npm run test:frontend +``` + +Expected: All tests pass. + +- [ ] **Step 3: Run typecheck** + +```bash +npx tsc -b apps/frontend +``` + +Expected: No type errors. diff --git a/docs/superpowers/specs/2026-06-18-pagination-loading-overlay-design.md b/docs/features/pagination-loading-overlay/spec.md similarity index 100% rename from docs/superpowers/specs/2026-06-18-pagination-loading-overlay-design.md rename to docs/features/pagination-loading-overlay/spec.md diff --git a/docs/features/portfolio-allocation-chart/plan.md b/docs/features/portfolio-allocation-chart/plan.md new file mode 100644 index 0000000..e1a318e --- /dev/null +++ b/docs/features/portfolio-allocation-chart/plan.md @@ -0,0 +1,261 @@ +# Диаграмма распределения портфеля — План реализации + +> **Для агентов:** Требуется навык `superpowers:subagent-driven-development` или `superpowers:executing-plans`. Шаги используют `- [ ]`. + +**Цель:** Добавить SVG-диаграмму donut в PortfolioSummary, показывающую распределение стоимости между акциями и облигациями. + +**Архитектура:** Всё на клиенте. Бэкенд уже возвращает `positions` с `currentValue` и `type`. Новый компонент `AllocationChart` агрегирует данные и рисует SVG. `PortfolioSummary` включает его. + +**Технологии:** React 18, SVG (без библиотек). + +--- + +### Задача 1: Создать AllocationChart + +**Файлы:** +- Создать: `apps/frontend/src/components/portfolios/AllocationChart.tsx` + +- [ ] **Шаг 1: Создать AllocationChart.tsx** + +```tsx +import type { PositionWithPrice } from '../../api/responses'; + +interface AllocationChartProps { + positions: PositionWithPrice[]; + totalValue: number; +} + +interface SectorData { + type: 'share' | 'bond'; + label: string; + value: number; + count: number; + color: string; +} + +const SECTOR_COLORS = { + share: 'var(--color-primary, #1976d2)', + bond: '#f57c00', +} as const; + +const SECTOR_LABELS = { + share: 'Акции', + bond: 'Облигации', +} as const; + +function computeSectors(positions: PositionWithPrice[]): SectorData[] { + const sectors: SectorData[] = [ + { type: 'share', label: SECTOR_LABELS.share, value: 0, count: 0, color: SECTOR_COLORS.share }, + { type: 'bond', label: SECTOR_LABELS.bond, value: 0, count: 0, color: SECTOR_COLORS.bond }, + ]; + + for (const p of positions) { + const sector = sectors.find((s) => s.type === p.type); + if (sector) { + sector.value += p.currentValue ?? 0; + sector.count += 1; + } + } + + return sectors; +} + +export function AllocationChart({ positions, totalValue }: AllocationChartProps) { + const sectors = computeSectors(positions); + const nonZero = sectors.filter((s) => s.value > 0); + const hasData = nonZero.length > 0; + + const cx = 60; + const cy = 60; + const r = 44; + const strokeWidth = 10; + const circumference = 2 * Math.PI * r; + const viewBoxSize = 120; + + function renderArcs() { + if (!hasData) { + return ( + + ); + } + + if (nonZero.length === 1) { + const sector = nonZero[0]; + return ( + + ); + } + + return sectors.map((sector, i) => { + const ratio = totalValue > 0 ? sector.value / totalValue : 0; + const dashLen = ratio * circumference; + const gapLen = circumference - dashLen; + let rotation = -90; + + for (let j = 0; j < i; j++) { + const prevRatio = totalValue > 0 ? sectors[j].value / totalValue : 0; + rotation += prevRatio * 360; + } + + return ( + + ); + }); + } + + return ( +
+ + {renderArcs()} + 0 ? 14 : 10, + fontWeight: 700, + fill: 'var(--color-text)', + }} + > + {positions.length === 0 + ? 'Нет позиций' + : totalValue.toLocaleString('ru-RU', { maximumFractionDigits: 0 })} + + +
+ {sectors.map((s) => { + const ratio = totalValue > 0 ? (s.value / totalValue) * 100 : 0; + return ( +
+ + + {s.label}: {s.count} / {ratio.toFixed(1)}% + +
+ ); + })} +
+
+ ); +} +``` + +- [ ] **Шаг 2: Проверить сборку** + +Run: `npm run build:frontend` +Expected: без ошибок + +--- + +### Задача 2: Интегрировать в PortfolioSummary + +**Файлы:** +- Изменить: `apps/frontend/src/components/portfolios/PortfolioSummary.tsx` + +- [ ] **Шаг 1: Обновить PortfolioSummary** + +```tsx +import { AllocationChart } from './AllocationChart'; +import type { PortfolioDetail } from '../../api/responses'; + +export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) { + return ( +
+ +
+
+ Общая стоимость +
+
+ {portfolio.totalValue.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + {portfolio.currency} + +
+
+
+
+ Позиций +
+
{portfolio.positions.length}
+
+
+ ); +} +``` + +- [ ] **Шаг 2: Проверить сборку** + +Run: `npm run build:frontend` +Expected: без ошибок + +- [ ] **Шаг 3: Проверить линтер** + +Run: `npm run lint` +Expected: без ошибок + +- [ ] **Шаг 4: Проверить форматирование** + +Run: `npm run format` +Expected: без изменений diff --git a/docs/superpowers/specs/2026-06-14-portfolio-allocation-chart-design.md b/docs/features/portfolio-allocation-chart/spec.md similarity index 100% rename from docs/superpowers/specs/2026-06-14-portfolio-allocation-chart-design.md rename to docs/features/portfolio-allocation-chart/spec.md diff --git a/docs/features/portfolio-analytics/plan.md b/docs/features/portfolio-analytics/plan.md new file mode 100644 index 0000000..dc7c336 --- /dev/null +++ b/docs/features/portfolio-analytics/plan.md @@ -0,0 +1,1037 @@ +# Portfolio Analytics — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add cost basis tracking (buyPrice/buyDate) to positions, calculate unrealized PnL at position and portfolio level, display PnL in UI. + +**Architecture:** Extend existing Prisma Position model with buyPrice/buyDate. PnL calculated on backend during enrichment. New PnL columns in position tables. New AnalyticsSummary component. + +**Tech Stack:** NestJS, Prisma + SQLite, TanStack Query v5, React 18 + +--- + +## File Structure + +### Backend (modified files) +- `apps/backend/prisma/schema.prisma` — add `buyPrice` (Float?), `buyDate` (DateTime?) to Position +- `apps/backend/src/modules/portfolio/dto/add-position.dto.ts` — add `buyPrice`, `buyDate` +- `apps/backend/src/modules/portfolio/dto/update-position.dto.ts` — add `buyPrice`, `buyDate` +- `apps/backend/src/modules/portfolio/portfolio.service.ts` — add PnL fields to EnrichedPosition + calculateAnalytics() + +### Backend (new files) +- `apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts` — PortfolioAnalyticsDto + +### Frontend (modified files) +- `apps/frontend/src/api/responses.ts` — add PnL fields to PositionWithPrice, add PortfolioAnalytics type +- `apps/frontend/src/api/portfolio.ts` — add buyPrice/buyDate to add/update position types +- `apps/frontend/src/hooks/usePositionMutations.ts` — pass buyPrice/buyDate +- `apps/frontend/src/components/portfolios/SharePositionRow.tsx` — add buyPrice edit + PnL columns +- `apps/frontend/src/components/portfolios/BondPositionRow.tsx` — add buyPrice edit + PnL columns +- `apps/frontend/src/components/portfolios/PortfolioSummary.tsx` — add analytics section +- `apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx` — add buyPrice to add position form + +### Frontend (new files) +- `apps/frontend/src/components/portfolios/AnalyticsSummary.tsx` — portfolio-level analytics card + +--- + +### Task 1: Prisma schema — add buyPrice and buyDate to Position + +**Files:** +- Modify: `apps/backend/prisma/schema.prisma` +- Run: `npx prisma migrate dev` + +- [ ] **Add buyPrice and buyDate fields to Position model** + +```prisma +model Position { + id Int @id @default(autoincrement()) + portfolioId Int + secid String + type String @default("share") + quantity Int + buyPrice Float? // NEW + buyDate DateTime? // NEW + notes String? + tags String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade) + + @@unique([portfolioId, secid]) +} +``` + +- [ ] **Run Prisma migration** + +```bash +npx prisma migrate dev --name add-buy-price-to-position -w apps/backend +``` + +- [ ] **Generate Prisma client** + +```bash +npx prisma generate -w apps/backend +``` + +--- + +### Task 2: Backend DTO updates — add-position and update-position + +**Files:** +- Modify: `apps/backend/src/modules/portfolio/dto/add-position.dto.ts` +- Modify: `apps/backend/src/modules/portfolio/dto/update-position.dto.ts` + +- [ ] **Add buyPrice and buyDate to AddPositionDto** + +```typescript +import { + IsString, IsOptional, IsInt, Min, IsArray, IsIn, + MaxLength, MinLength, IsNumber, +} 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: 250.5 }) + @IsNumber() + @Min(0) + @IsOptional() + buyPrice?: number; + + @ApiPropertyOptional({ example: '2026-06-01' }) + @IsString() + @IsOptional() + buyDate?: string; + + @ApiPropertyOptional({ example: 'Покупка на дип' }) + @IsString() + @IsOptional() + @MaxLength(500) + notes?: string; + + @ApiPropertyOptional({ example: ['DIVIDEND', 'GROWTH'], enum: TAGS }) + @IsArray() + @IsIn(TAGS, { each: true }) + @IsOptional() + tags?: string[]; +} +``` + +- [ ] **Add buyPrice and buyDate to UpdatePositionDto** + +```typescript +import { IsString, IsOptional, IsInt, Min, IsArray, IsIn, MaxLength, IsNumber } 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: 260.0 }) + @IsNumber() + @Min(0) + @IsOptional() + buyPrice?: number; + + @ApiPropertyOptional({ example: '2026-06-15' }) + @IsString() + @IsOptional() + buyDate?: string; + + @ApiPropertyOptional({ example: 'Докупка' }) + @IsString() + @IsOptional() + @MaxLength(500) + notes?: string; + + @ApiPropertyOptional({ example: ['DIVIDEND'], enum: TAGS }) + @IsArray() + @IsIn(TAGS, { each: true }) + @IsOptional() + tags?: string[]; +} +``` + +--- + +### Task 3: Backend PortfolioService — PnL enrichment + +**Files:** +- Modify: `apps/backend/src/modules/portfolio/portfolio.service.ts` + +- [ ] **Add PnL fields to EnrichedPosition interface and implement calculateAnalytics** + +Replace the `EnrichedPosition` interface and methods in `portfolio.service.ts`: + +```typescript +export interface EnrichedPosition { + id: number; + secid: string; + shortName: string | null; + type: string; + quantity: number; + notes: string | null; + tags: string[] | null; + buyPrice: number | null; // NEW + buyDate: string | null; // NEW + currentPrice: number | null; + currentValue: number | null; + totalCost: number | null; // NEW: buyPrice * quantity + unrealizedPnl: number | null; // NEW: currentValue - totalCost + unrealizedPnlPercent: number | null; // NEW: (currentPrice - buyPrice) / buyPrice * 100 + 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 PortfolioAnalytics { + totalCost: number | null; + totalValue: number; + totalPnl: number | null; + totalPnlPercent: number | null; + totalDividendIncome: number; + totalReturn: number | null; +} +``` + +- [ ] **Update enrichPositions to pass buyPrice/buyDate through enrichment** + +In the `enrichPositions` method, update the base object constructor: + +```typescript +const base = { + id: pos.id, + secid: pos.secid, + shortName: null as string | null, + type: pos.type, + quantity: pos.quantity, + notes: pos.notes, + tags: pos.tags ? JSON.parse(pos.tags) : null, + buyPrice: (pos as any).buyPrice ?? null, // NEW + buyDate: (pos as any).buyDate // NEW + ? ((pos as any).buyDate as Date).toISOString().split('T')[0] + : null as string | null, + weightPercent: 0, + currentPrice: null as number | null, + currentValue: null as number | null, + totalCost: null as number | null, // NEW + unrealizedPnl: null as number | null, // NEW + unrealizedPnlPercent: null as number | null, // NEW +}; +``` + +- [ ] **Update buildSharePosition to calculate PnL** + +```typescript +private buildSharePosition( + pos: { id: number; secid: string; quantity: number; buyPrice?: number | null }, + base: EnrichedPosition, + data: MoexShareMarketData | undefined, +): EnrichedPosition { + if (!data) return { ...base, currentPrice: null, currentValue: null, totalCost: null, unrealizedPnl: null, unrealizedPnlPercent: null }; + const currentPrice = data.last; + const currentValue = currentPrice !== null ? currentPrice * pos.quantity : null; + const totalCost = pos.buyPrice != null ? pos.buyPrice * pos.quantity : null; + const unrealizedPnl = totalCost != null && currentValue != null ? currentValue - totalCost : null; + const unrealizedPnlPercent = pos.buyPrice != null && currentPrice != null + ? ((currentPrice - pos.buyPrice) / pos.buyPrice) * 100 + : null; + return { + ...base, + shortName: data.shortName, + currentPrice, + change: data.lastChange, + changePercent: data.lastChangePrcnt, + currentValue, + totalCost, + unrealizedPnl, + unrealizedPnlPercent, + }; +} +``` + +- [ ] **Update buildBondPosition to calculate PnL** + +```typescript +private buildBondPosition( + pos: { id: number; secid: string; quantity: number; buyPrice?: number | null }, + base: EnrichedPosition, + data: MoexBondPositionData | undefined, +): EnrichedPosition { + if (!data) return { ...base, currentPrice: null, currentValue: null, totalCost: null, unrealizedPnl: null, unrealizedPnlPercent: null }; + const currentPrice = data.price; + const currentValue = data.price !== null ? (data.price / 100) * data.faceValue * pos.quantity : null; + const totalCost = pos.buyPrice != null ? pos.buyPrice * pos.quantity : null; + const unrealizedPnl = totalCost != null && currentValue != null ? currentValue - totalCost : null; + const unrealizedPnlPercent = pos.buyPrice != null && currentPrice != null + ? ((currentPrice - pos.buyPrice) / pos.buyPrice) * 100 + : null; + return { + ...base, + shortName: data.shortName, + currentPrice, + yieldToMaturity: data.yieldToMaturity, + duration: data.duration, + couponValue: data.couponValue, + couponPercent: data.couponPercent, + nextCouponDate: data.nextCouponDate, + matDate: data.matDate, + accruedInt: data.accruedInt, + bid: data.bid, + offer: data.offer, + couponPeriod: data.couponPeriod, + bondType: data.bondType, + offerDate: data.offerDate, + currentValue, + totalCost, + unrealizedPnl, + unrealizedPnlPercent, + }; +} +``` + +- [ ] **Update findOne to calculate and return analytics** + +Replace the final return block in `findOne`: + +```typescript +const positionsWithWeights: EnrichedPosition[] = positionsWithPrices.map((p) => { + const weightPercent = totalValue > 0 ? ((p.currentValue ?? 0) / totalValue) * 100 : 0; + return { + ...p, + weightPercent: Math.round(weightPercent * 2) / 2, + }; +}); + +const analytics = this.calculateAnalytics(positionsWithWeights); + +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, + analytics, +}; +``` + +- [ ] **Add calculateAnalytics private method** + +```typescript +private calculateAnalytics(positions: EnrichedPosition[]): PortfolioAnalytics { + const totalCost = positions.reduce( + (sum, p) => sum + (p.totalCost ?? 0), + 0, + ); + const totalValue = positions.reduce( + (sum, p) => sum + (p.currentValue ?? 0), + 0, + ); + const totalPnl = positions.reduce( + (sum, p) => sum + (p.unrealizedPnl ?? 0), + 0, + ); + const totalPnlPercent = totalCost > 0 ? (totalPnl / totalCost) * 100 : null; + + return { + totalCost: totalCost > 0 ? Math.round(totalCost * 100) / 100 : null, + totalValue: Math.round(totalValue * 100) / 100, + totalPnl: totalPnl !== 0 ? Math.round(totalPnl * 100) / 100 : null, + totalPnlPercent: totalPnlPercent != null ? Math.round(totalPnlPercent * 100) / 100 : null, + totalDividendIncome: 0, + totalReturn: totalPnlPercent, + }; +} +``` + +- [ ] **Update addPosition to accept buyPrice/buyDate** + +Replace the `data` block in the `create` call inside `addPosition`: + +```typescript +return this.prisma.position.create({ + data: { + portfolioId, + secid: dto.secid, + type, + quantity: dto.quantity, + buyPrice: dto.buyPrice ?? null, + buyDate: dto.buyDate ? new Date(dto.buyDate) : null, + notes: dto.notes ?? null, + tags: dto.tags ? JSON.stringify(dto.tags) : null, + }, +}); +``` + +- [ ] **Update updatePosition to accept buyPrice/buyDate** + +Replace the `data` block in the `update` call inside `updatePosition`: + +```typescript +return this.prisma.position.update({ + where: { id: positionId }, + data: { + ...(dto.quantity !== undefined && { quantity: dto.quantity }), + ...(dto.buyPrice !== undefined && { buyPrice: dto.buyPrice }), + ...(dto.buyDate !== undefined && { buyDate: new Date(dto.buyDate) }), + ...(dto.notes !== undefined && { notes: dto.notes }), + ...(dto.tags !== undefined && { tags: dto.tags ? JSON.stringify(dto.tags) : null }), + }, +}); +``` + +--- + +### Task 4: Backend AnalyticsResponseDto + +**Files:** +- Create: `apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts` + +- [ ] **Create AnalyticsResponseDto** + +```typescript +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class AnalyticsResponseDto { + @ApiPropertyOptional() + totalCost: number | null; + + @ApiProperty() + totalValue: number; + + @ApiPropertyOptional() + totalPnl: number | null; + + @ApiPropertyOptional() + totalPnlPercent: number | null; + + @ApiProperty() + totalDividendIncome: number; + + @ApiPropertyOptional() + totalReturn: number | null; +} +``` + +--- + +### Task 5: Backend tests — PnL calculation + +**Files:** +- Modify: `apps/backend/src/modules/portfolio/portfolio.service.spec.ts` + +- [ ] **Add test: PnL calculation for share position** + +Add inside `describe('findOne')` block: + +```typescript +it('should calculate PnL for share position with buyPrice', async () => { + const sharePosition = mockPosition({ + id: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + buyPrice: 200, + buyDate: new Date('2026-06-01'), + }); + + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue( + mockPortfolio({ positions: [sharePosition] }) as any, + ); + + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, + ] as any); + + vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([]); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + const result = await service.findOne(1, 1); + + expect(result.positions).toHaveLength(1); + expect(result.positions[0].buyPrice).toBe(200); + expect(result.positions[0].totalCost).toBe(2000); // 200 * 10 + expect(result.positions[0].unrealizedPnl).toBe(500); // 2500 - 2000 + expect(result.positions[0].unrealizedPnlPercent).toBe(25); // (250 - 200) / 200 * 100 + expect(result.analytics.totalCost).toBe(2000); + expect(result.analytics.totalPnl).toBe(500); + expect(result.analytics.totalPnlPercent).toBe(25); +}); + +it('should return null PnL when buyPrice is not set', async () => { + const sharePosition = mockPosition({ + id: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + }); + + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue( + mockPortfolio({ positions: [sharePosition] }) as any, + ); + + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, + ] as any); + + vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([]); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + const result = await service.findOne(1, 1); + + expect(result.positions[0].totalCost).toBeNull(); + expect(result.positions[0].unrealizedPnl).toBeNull(); + expect(result.positions[0].unrealizedPnlPercent).toBeNull(); +}); +``` + +- [ ] **Run tests to verify** + +```bash +npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend +``` + +Expected: all tests pass (including existing ones + 2 new ones) + +--- + +### Task 6: Frontend types — add PnL fields to responses.ts + +**Files:** +- Modify: `apps/frontend/src/api/responses.ts` + +- [ ] **Add PnL fields to PositionWithPrice and add PortfolioAnalytics type** + +Add new fields to `PositionWithPrice`: +```typescript +export interface PositionWithPrice { + // ... existing fields + buyPrice?: number | null; + buyDate?: string | null; + totalCost?: number | null; + unrealizedPnl?: number | null; + unrealizedPnlPercent?: number | null; +} +``` + +Add new types: +```typescript +export interface PortfolioAnalytics { + totalCost: number | null; + totalValue: number; + totalPnl: number | null; + totalPnlPercent: number | null; + totalDividendIncome: number; + totalReturn: number | null; +} +``` + +Update `PortfolioDetail` to include analytics: +```typescript +export interface PortfolioDetail extends Portfolio { + positions: PositionWithPrice[]; + totalValue: number; + analytics: PortfolioAnalytics; // NEW +} +``` + +--- + +### Task 7: Frontend API client + hooks — pass buyPrice/buyDate + +**Files:** +- Modify: `apps/frontend/src/api/portfolio.ts` +- Modify: `apps/frontend/src/hooks/usePositionMutations.ts` + +- [ ] **Update addPosition and updatePosition types in api/portfolio.ts** + +```typescript +export function addPosition( + portfolioId: number, + data: { secid: string; quantity: number; buyPrice?: number; buyDate?: string; 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; buyPrice?: number; buyDate?: string; 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, + }); +} +``` + +- [ ] **Update usePositionMutations to accept buyPrice/buyDate** + +Update the `add` mutation function type: +```typescript +const add = useMutation({ + mutationFn: (data: { + secid: string; + quantity: number; + buyPrice?: number; + buyDate?: string; + notes?: string; + tags?: string[]; + }) => addPosition(portfolioId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['portfolio', portfolioId] }); + }, +}); +``` + +Update the `update` mutation function type: +```typescript +const update = useMutation({ + mutationFn: ({ + positionId, + data, + }: { + positionId: number; + data: { quantity?: number; buyPrice?: number; buyDate?: string; notes?: string; tags?: string[] }; + }) => updatePosition(portfolioId, positionId, data), + // ... rest unchanged +}); +``` + +Update the optimistic update to handle buyPrice: +```typescript +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 } : {}), + ...(data.buyPrice !== undefined ? { buyPrice: data.buyPrice } : {}), + } + : p, + ), + }; +}); +``` + +--- + +### Task 8: Frontend SharePositionRow — add PnL columns + +**Files:** +- Modify: `apps/frontend/src/components/portfolios/SharePositionRow.tsx` + +- [ ] **Add buyPrice inline editing and PnL columns** + +Replace the `` content with additional cells between колонка «Стоимость» and «Доля»: + +```typescript +// After currentValue column (index 6), before weightPercent column: +{/* Цена покупки */} + + {position.buyPrice != null + ? position.buyPrice.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }) + : '—'} + + +{/* PnL */} + + {position.unrealizedPnl != null ? ( + = 0 ? '#43a047' : '#e53935' }}> + {position.unrealizedPnl >= 0 ? '+' : ''} + {position.unrealizedPnl.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + ) : '—'} + + +{/* PnL% */} + + {position.unrealizedPnlPercent != null ? ( + = 0 ? '#43a047' : '#e53935' }}> + {position.unrealizedPnlPercent >= 0 ? '+' : ''} + {position.unrealizedPnlPercent.toFixed(2)}% + + ) : '—'} + +``` + +Also update `onUpdate` props interface to accept `buyPrice`: +```typescript +interface Props { + position: PositionWithPrice; + onUpdate: (data: { quantity?: number; buyPrice?: number }) => void; + onDelete: () => void; +} +``` + +--- + +### Task 9: Frontend BondPositionRow — add PnL columns + +**Files:** +- Modify: `apps/frontend/src/components/portfolios/BondPositionRow.tsx` + +- [ ] **Add same PnL columns after НКД column (index 13), same logic as SharePositionRow** + +Insert after the totalAccrued cell: + +```typescript +{/* Цена покупки */} + + {position.buyPrice != null + ? position.buyPrice.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + : '—'} + + +{/* PnL */} + + {position.unrealizedPnl != null ? ( + = 0 ? '#43a047' : '#e53935' }}> + {position.unrealizedPnl >= 0 ? '+' : ''} + {position.unrealizedPnl.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + ) : '—'} + + +{/* PnL% */} + + {position.unrealizedPnlPercent != null ? ( + = 0 ? '#43a047' : '#e53935' }}> + {position.unrealizedPnlPercent >= 0 ? '+' : ''} + {position.unrealizedPnlPercent.toFixed(2)}% + + ) : '—'} + +``` + +Also update `onUpdate` props: +```typescript +interface Props { + position: PositionWithPrice; + onUpdate: (data: { quantity?: number; buyPrice?: number }) => void; + onDelete: () => void; +} +``` + +Update the SharePositionTable and BondPositionTable `` headers to include the new columns ("Цена покупки", "PnL", "PnL%"). + +--- + +### Task 10: Frontend AnalyticsSummary + PortfolioSummary update + +**Files:** +- Create: `apps/frontend/src/components/portfolios/AnalyticsSummary.tsx` +- Modify: `apps/frontend/src/components/portfolios/PortfolioSummary.tsx` + +- [ ] **Create AnalyticsSummary component** + +```typescript +import type { PortfolioAnalytics } from '../../api/responses'; + +interface Props { + analytics: PortfolioAnalytics; + currency: string; +} + +export function AnalyticsSummary({ analytics, currency }: Props) { + return ( +
+
+
+ Общая стоимость +
+
+ {analytics.totalValue.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + {currency} + +
+
+ + {analytics.totalCost != null && ( + <> +
+
+ Вложено +
+
+ {analytics.totalCost.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +
+
+ +
+
+ PnL +
+
= 0 ? '#43a047' : '#e53935', + }} + > + {analytics.totalPnl != null + ? `${analytics.totalPnl >= 0 ? '+' : ''}${analytics.totalPnl.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}` + : '—'} +
+
+ +
+
+ Доходность +
+
= 0 ? '#43a047' : '#e53935', + }} + > + {analytics.totalPnlPercent != null + ? `${analytics.totalPnlPercent >= 0 ? '+' : ''}${analytics.totalPnlPercent.toFixed(2)}%` + : '—'} +
+
+ + )} +
+ ); +} +``` + +- [ ] **Update PortfolioSummary to include AnalyticsSummary** + +```typescript +import { AllocationChart } from './AllocationChart'; +import { AnalyticsSummary } from './AnalyticsSummary'; +import type { PortfolioDetail } from '../../api/responses'; + +export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) { + return ( +
+
+ +
+
+ Позиций +
+
{portfolio.positions.length}
+
+
+ +
+ ); +} +``` + +--- + +### Task 11: Frontend PortfolioDetailPage — add buyPrice to add position form + +**Files:** +- Modify: `apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx` + +- [ ] **Add buyPrice input field to the add position form** + +Add state variable: +```typescript +const [newBuyPrice, setNewBuyPrice] = useState(''); +``` + +Add the input field after the quantity input in the add form: +```typescript +
+ + setNewBuyPrice(e.target.value)} + placeholder="250.50" + style={{ + padding: '8px 12px', + border: '1px solid #e0e0e0', + borderRadius: 'var(--border-radius)', + fontSize: 14, + width: 100, + }} + /> +
+``` + +Update `handleAddPosition`: +```typescript +function handleAddPosition() { + if (!newSecid.trim() || !parseInt(newQty, 10)) return; + addPosition.mutate( + { + secid: newSecid.trim().toUpperCase(), + quantity: parseInt(newQty, 10), + buyPrice: newBuyPrice ? parseFloat(newBuyPrice) : undefined, + }, + { + onSuccess: () => { + setShowAddForm(false); + setNewSecid(''); + setNewQty('1'); + setNewBuyPrice(''); + }, + }, + ); +} +``` + +- [ ] **Verify frontend builds** + +```bash +npm run build:frontend +``` + +Expected: no TypeScript errors + +--- + +### Task 12: Verify everything works + +- [ ] **Run all backend tests** + +```bash +npx vitest run -w apps/backend +``` + +Expected: all tests pass + +- [ ] **Run frontend tests** + +```bash +npx vitest run -w apps/frontend +``` + +Expected: all tests pass + +- [ ] **Run lint** + +```bash +npm run lint +``` + +Expected: no errors + +- [ ] **Commit** + +```bash +git add apps/backend/prisma/schema.prisma \ + apps/backend/src/modules/portfolio/dto/add-position.dto.ts \ + apps/backend/src/modules/portfolio/dto/update-position.dto.ts \ + apps/backend/src/modules/portfolio/dto/analytics-response.dto.ts \ + apps/backend/src/modules/portfolio/portfolio.service.ts \ + apps/backend/src/modules/portfolio/portfolio.service.spec.ts \ + apps/frontend/src/api/responses.ts \ + apps/frontend/src/api/portfolio.ts \ + apps/frontend/src/hooks/usePositionMutations.ts \ + apps/frontend/src/components/portfolios/SharePositionRow.tsx \ + apps/frontend/src/components/portfolios/BondPositionRow.tsx \ + apps/frontend/src/components/portfolios/PortfolioSummary.tsx \ + apps/frontend/src/components/portfolios/AnalyticsSummary.tsx \ + apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx \ + apps/backend/prisma/migrations +git commit -m "feat: add portfolio analytics with PnL and cost basis tracking" +``` diff --git a/docs/superpowers/specs/2026-06-14-portfolio-analytics-design.md b/docs/features/portfolio-analytics/spec.md similarity index 100% rename from docs/superpowers/specs/2026-06-14-portfolio-analytics-design.md rename to docs/features/portfolio-analytics/spec.md diff --git a/docs/features/portfolio-enricher-optimization/plan.md b/docs/features/portfolio-enricher-optimization/plan.md new file mode 100644 index 0000000..8944b8f --- /dev/null +++ b/docs/features/portfolio-enricher-optimization/plan.md @@ -0,0 +1,365 @@ +# Portfolio Enricher Optimization — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) for tracking. + +**Goal:** Reduce portfolio enrichment from 298 MOEX API calls (~30s) to 2 batch calls (~0.3s) by merging redundant bond data calls, eliminating extra security descriptions, and batching by market. + +**Architecture:** 3-phase: (1) type changes, (2) new batch methods on MoexClientService, (3) rewrite PortfolioService.enrichPositions to use batch + remove redundant calls. + +**Tech Stack:** NestJS, TypeScript, MOEX ISS API, PQueue + +--- + +### Task 1: Add types — `shortName` on share market data + `MoexBondPositionData` combined type + +**Files:** +- Modify: `apps/backend/src/modules/moex-client/moex-client.types.ts` + +- [ ] **Step 1: Extend `MoexShareMarketData` with `shortName`** + +Add `shortName: string;` field — it's already returned by MOEX in the `securities` table of the share endpoint, but was never extracted. + +- [ ] **Step 2: Add `MoexBondPositionData` combined type** + +```typescript +export interface MoexBondPositionData { + secid: string; + boardid: string; + shortName: string; + price: number | null; + yieldToMaturity: number | null; + duration: number | null; + couponValue: number | null; + couponPercent: number | null; + nextCouponDate: string | null; + matDate: string | null; + accruedInt: number | null; + faceValue: number; + bid: number | null; + offer: number | null; + couponPeriod: number | null; + bondType: string | null; + offerDate: string | null; +} +``` + +This replaces the need for both `MoexBondData` + `MoexBondMarketData` — combined from a single endpoint response. + +--- + +### Task 2: Add batch methods to MoexClientService + +**Files:** +- Modify: `apps/backend/src/modules/moex-client/moex-client.service.ts` + +- [ ] **Step 1: Add `getShareMarketDataBatch` method** + +```typescript +async getShareMarketDataBatch( + secids: string[], + boardId = 'TQBR', +): Promise { + if (secids.length === 0) return []; + const data = await this.request>( + `/engines/stock/markets/shares/securities`, + { securities: secids.join(','), boards: boardId }, + ); + const securities = this.extractTable(data, 'securities'); + const marketdata = this.extractTable(data, 'marketdata'); + + return secids.map((secid) => { + const sec = securities.find((r) => r.SECID === secid && r.BOARDID === boardId) + ?? securities.find((r) => r.SECID === secid); + const mkt = marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) + ?? marketdata.find((r) => r.SECID === secid); + + return { + secid, + boardid: boardId, + shortName: (sec?.SHORTNAME as string) || '', + bid: mkt ? parseFloat((mkt.BID as string) || '') : null, + offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null, + open: mkt ? parseFloat((mkt.OPEN as string) || '') : null, + low: mkt ? parseFloat((mkt.LOW as string) || '') : null, + high: mkt ? parseFloat((mkt.HIGH as string) || '') : null, + last: mkt + ? parseFloat((mkt.LAST as string) || '') + : parseFloat((sec?.PREVPRICE as string) || ''), + lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null, + lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null, + volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0, + value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0, + waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null, + numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0, + issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null, + tradingStatus: (mkt?.TRADINGSTATUS as string) || '', + updateTime: (mkt?.UPDATETIME as string) || '', + }; + }); +} +``` + +Key: uses existing `request()` method (rate-limited via PQueue). The `securities` param accepts comma-separated secids. + +- [ ] **Step 2: Add `getBondPositionDataBatch` method** + +```typescript +async getBondPositionDataBatch( + secids: string[], + boardId = 'TQCB', +): Promise { + if (secids.length === 0) return []; + const data = await this.request>( + `/engines/stock/markets/bonds/securities`, + { securities: secids.join(','), boards: boardId }, + ); + const securities = this.extractTable(data, 'securities'); + const marketdata = this.extractTable(data, 'marketdata'); + + return secids.map((secid) => { + const bond = + securities.find((r) => r.SECID === secid && r.BOARDID === boardId && r.PREVWAPRICE != null) || + securities.find((r) => r.SECID === secid && r.PREVWAPRICE != null) || + securities.find((r) => r.SECID === secid); + const mkt = + marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) || + marketdata.find((r) => r.LAST != null) || + marketdata.find((r) => r.SECID === secid); + + return { + secid, + boardid: boardId, + shortName: (bond?.SHORTNAME as string) || '', + price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null, + yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null, + duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null, + couponValue: bond?.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null, + couponPercent: bond?.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null, + nextCouponDate: (bond?.NEXTCOUPON as string) || null, + matDate: (bond?.MATDATE as string) || null, + accruedInt: bond?.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null, + faceValue: parseFloat((bond?.FACEVALUE as string) || '1000'), + bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null, + offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null, + couponPeriod: parseInt((bond?.COUPONPERIOD as string) || '0', 10), + bondType: (bond?.BONDTYPE as string) || null, + offerDate: (bond?.OFFERDATE as string) || null, + }; + }); +} +``` + +This replaces `getBondData` + `getBondMarketData` with a single batch call that parses both tables. + +- [ ] **Step 3: Update `getShareMarketData` to also extract `shortName`** + +In the single-security `getShareMarketData`, find the securities row and extract shortName: + +```typescript +const share = rows.find((r) => r.BOARDID === boardId); +return { + secid, + boardid: boardId, + shortName: (share?.SHORTNAME as string) || '', // NEW + bid: mkt ? parseFloat((mkt.BID as string) || '') : null, + // ... rest unchanged +}; +``` + +- [ ] **Step 4: Run existing tests** + +```bash +npx vitest run -w apps/backend +``` + +Expected: existing tests pass (no regressions). + +--- + +### Task 3: Rewrite `enrichPositions` in PortfolioService + +**Files:** +- Modify: `apps/backend/src/modules/portfolio/portfolio.service.ts` + +- [ ] **Step 1: Rewrite `enrichPositions` to use batch + eliminate redundant calls** + +Strategy: +1. Group positions by type (share/bond) +2. For shares: 1 `getShareMarketDataBatch` call → map by secid +3. For bonds: 1 `getBondPositionDataBatch` call → map by secid +4. Build enriched positions from maps (no more individual API calls) +5. shortName comes from market data response (no more `getSecurityDescription`) + +```typescript +private async enrichPositions( + positions: { + id: number; portfolioId: number; secid: string; + type: string; quantity: number; notes: string | null; tags: string | null; + }[], + portfolioId: number, +): Promise { + const sharePositions = positions.filter((p) => p.type === 'share'); + const bondPositions = positions.filter((p) => p.type === 'bond'); + const shareSecids = [...new Set(sharePositions.map((p) => p.secid))].sort(); + const bondSecids = [...new Set(bondPositions.map((p) => p.secid))].sort(); + + const [shareDataBySecid, bondDataBySecid] = await Promise.all([ + this.fetchShareBatch(shareSecids, portfolioId), + this.fetchBondBatch(bondSecids, portfolioId), + ]); + + const enriched: EnrichedPosition[] = []; + + for (const pos of positions) { + const base = { + id: pos.id, secid: pos.secid, + shortName: null as string | null, + 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') { + enriched.push(this.buildBondPosition(pos, base, bondDataBySecid.get(pos.secid))); + } else { + enriched.push(this.buildSharePosition(pos, base, shareDataBySecid.get(pos.secid))); + } + } + + return enriched; +} + +private async fetchShareBatch( + secids: string[], portfolioId: number, +): Promise> { + if (secids.length === 0) return new Map(); + const cacheKey = secids.join(','); + const { data } = await this.cache.getOrFetch( + 'batchdata', ['shares', cacheKey], + () => this.moexClient.getShareMarketDataBatch(secids), + 'marketDataTtl', + ); + return new Map(data.map((d) => [d.secid, d])); +} + +private async fetchBondBatch( + secids: string[], portfolioId: number, +): Promise> { + if (secids.length === 0) return new Map(); + const cacheKey = secids.join(','); + const { data } = await this.cache.getOrFetch( + 'batchdata', ['bonds', cacheKey], + () => this.moexClient.getBondPositionDataBatch(secids), + 'marketDataTtl', + ); + return new Map(data.map((d) => [d.secid, d])); +} +``` + +- [ ] **Step 2: Add `buildSharePosition` method** + +```typescript +private buildSharePosition( + pos: { id: number; secid: string; quantity: number }, + base: EnrichedPosition, + data: MoexShareMarketData | undefined, +): EnrichedPosition { + if (!data) return { ...base, currentPrice: null, currentValue: null }; + return { + ...base, + shortName: data.shortName, + currentPrice: data.last, + change: data.lastChange, + changePercent: data.lastChangePrcnt, + currentValue: data.last !== null ? data.last * pos.quantity : null, + }; +} +``` + +- [ ] **Step 3: Add `buildBondPosition` method** + +```typescript +private buildBondPosition( + pos: { id: number; secid: string; quantity: number }, + base: EnrichedPosition, + data: MoexBondPositionData | undefined, +): EnrichedPosition { + if (!data) return { ...base, currentPrice: null, currentValue: null }; + const currentValue = + data.price !== null ? (data.price / 100) * data.faceValue * pos.quantity : null; + return { + ...base, + shortName: data.shortName, + currentPrice: data.price, + yieldToMaturity: data.yieldToMaturity, + duration: data.duration, + couponValue: data.couponValue, + couponPercent: data.couponPercent, + nextCouponDate: data.nextCouponDate, + matDate: data.matDate, + accruedInt: data.accruedInt, + bid: data.bid, + offer: data.offer, + couponPeriod: data.couponPeriod, + bondType: data.bondType, + offerDate: data.offerDate, + currentValue, + }; +} +``` + +- [ ] **Step 4: Update `findOne` to pass `portfolio.id` to `enrichPositions`** + +```typescript +const positionsWithPrices = await this.enrichPositions(portfolio.positions, portfolio.id); +``` + +- [ ] **Step 5: Clean up removed methods** + +Remove old private methods: `enrichSharePosition`, `enrichBondPosition` (replaced by `buildSharePosition`, `buildBondPosition`). + +- [ ] **Step 6: Remove unused import `CacheService` if it becomes unused** + +Actually `CacheService` is still used via `fetchShareBatch`/`fetchBondBatch`. Keep it. + +- [ ] **Step 7: Run tests** + +```bash +npx vitest run -w apps/backend +``` + +Expected: all tests pass. + +--- + +### Task 4: Verify and lint + +- [ ] **Step 1: TypeScript check** + +```bash +npx tsc --noEmit -w apps/backend +``` + +- [ ] **Step 2: Lint** + +```bash +npm run lint 2>/dev/null || echo "Lint check complete" +``` + +- [ ] **Step 3: Format** + +```bash +npm run format +``` + +--- + +### Task 5: Document performance gain + +- [ ] **Step 1: Write ADR or performance note in docs** + +Add to `docs/superpowers/adr/2026-06-14-portfolio-enricher-optimization.md` documenting: +- Problem: 298 API calls → 29s +- Changes made: merged bond calls, removed redundant securityDescription, batch by market +- Result: 2 API calls → ~0.3s (97% reduction) diff --git a/docs/superpowers/specs/2026-06-14-portfolio-enricher-optimization.md b/docs/features/portfolio-enricher-optimization/spec.md similarity index 100% rename from docs/superpowers/specs/2026-06-14-portfolio-enricher-optimization.md rename to docs/features/portfolio-enricher-optimization/spec.md diff --git a/docs/features/portfolio-list-enrichment/plan.md b/docs/features/portfolio-list-enrichment/plan.md new file mode 100644 index 0000000..8786623 --- /dev/null +++ b/docs/features/portfolio-list-enrichment/plan.md @@ -0,0 +1,613 @@ +# Portfolio List Enrichment — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enrich `GET /api/v1/portfolios` with totalValue, positionCount, shareCount, bondCount from MOEX batch data and display on PortfolioCard. + +**Architecture:** Backend collects all positions across user's portfolios, does ONE batch MOEX call (cached), computes aggregates per portfolio. Frontend displays new fields on the existing card component. + +**Tech Stack:** NestJS, Prisma, MoexClientService (batch), React, TanStack Query + +--- + +### Task 1: Create PortfolioListResponseDto + +**Files:** +- Create: `apps/backend/src/modules/portfolio/dto/portfolio-list-response.dto.ts` + +- [ ] **Step 1: Create DTO file** + +```typescript +import { ApiProperty } from '@nestjs/swagger'; +import { PortfolioResponseDto } from './portfolio-response.dto'; + +export class PortfolioListResponseDto extends PortfolioResponseDto { + @ApiProperty({ description: 'Total market value of all positions' }) + totalValue!: number; + + @ApiProperty({ description: 'Total number of positions' }) + positionCount!: number; + + @ApiProperty({ description: 'Number of share positions' }) + shareCount!: number; + + @ApiProperty({ description: 'Number of bond positions' }) + bondCount!: number; +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit -w apps/backend` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add apps/backend/src/modules/portfolio/dto/portfolio-list-response.dto.ts +git commit -m "feat(backend): add PortfolioListResponseDto" +``` + +--- + +### Task 2: Write failing tests for PortfolioService.findAll enrichment + +**Files:** +- Create: `apps/backend/src/modules/portfolio/portfolio.service.spec.ts` + +- [ ] **Step 1: Create test file with failing tests** + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { PortfolioService } from './portfolio.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import configuration from '../../config/configuration'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; + +describe('PortfolioService', () => { + let service: PortfolioService; + let prisma: PrismaService; + let moexClient: MoexClientService; + let module: TestingModule; + + const mockPortfolio = (overrides: Record = {}) => ({ + id: 1, + userId: 1, + name: 'Test Portfolio', + description: 'A test portfolio', + currency: 'RUB', + targets: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-06-14'), + ...overrides, + }); + + const mockPosition = (overrides: Record = {}) => ({ + id: 1, + portfolioId: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + notes: null, + tags: null, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-06-14'), + ...overrides, + }); + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ load: [configuration] })], + providers: [ + PortfolioService, + { + provide: PrismaService, + useValue: { + portfolio: { + findMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + position: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }, + }, + { + provide: MoexClientService, + useValue: { + getShareMarketDataBatch: vi.fn(), + getBondPositionDataBatch: vi.fn(), + getSecurityDescription: vi.fn(), + }, + }, + { + provide: CacheService, + useValue: { + getOrFetch: vi.fn(), + }, + }, + ], + }).compile(); + + service = module.get(PortfolioService); + prisma = module.get(PrismaService); + moexClient = module.get(MoexClientService); + // CacheService is a useValue mock object + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('findAll', () => { + it('should return empty array when user has no portfolios', async () => { + vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]); + const result = await service.findAll(1); + expect(result).toEqual([]); + }); + + it('should return portfolios with zero aggregates when no positions exist', async () => { + vi.mocked(prisma.portfolio.findMany).mockResolvedValue([ + mockPortfolio({ positions: [] }) as any, + ]); + + const result = await service.findAll(1); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + name: 'Test Portfolio', + totalValue: 0, + positionCount: 0, + shareCount: 0, + bondCount: 0, + }); + }); + + it('should enrich portfolios with market data from batch MOEX call', async () => { + const sharePosition = mockPosition({ + id: 1, + secid: 'SBER', + type: 'share', + quantity: 10, + }); + const bondPosition = mockPosition({ + id: 2, + portfolioId: 1, + secid: 'SU26238RMFS5', + type: 'bond', + quantity: 5, + }); + + vi.mocked(prisma.portfolio.findMany).mockResolvedValue([ + mockPortfolio({ positions: [sharePosition, bondPosition] }) as any, + ]); + + vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, + ] as any); + + vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([ + { + secid: 'SU26238RMFS5', + shortName: 'OFZ 26238', + price: 98.5, + faceValue: 1000, + }, + ] as any); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockImplementation( + async (_prefix: string, _key: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + + const result = await service.findAll(1); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Test Portfolio'); + expect(result[0].positionCount).toBe(2); + expect(result[0].shareCount).toBe(1); + expect(result[0].bondCount).toBe(1); + // SBER: 250 * 10 = 2500, OFZ: (98.5 / 100) * 1000 * 5 = 4925 + expect(result[0].totalValue).toBe(7425); + }); + + it('should propagate MOEX errors to the caller', async () => { + vi.mocked(prisma.portfolio.findMany).mockResolvedValue([ + mockPortfolio({ positions: [mockPosition()] }) as any, + ]); + + const cacheMock = module.get(CacheService) as { getOrFetch: ReturnType }; + cacheMock.getOrFetch.mockRejectedValue(new Error('MOEX down')); + + await expect(service.findAll(1)).rejects.toThrow('MOEX down'); + }); + + it('should only return portfolios belonging to the requesting user', async () => { + vi.mocked(prisma.portfolio.findMany).mockResolvedValue([]); + + await service.findAll(2); + + expect(prisma.portfolio.findMany).toHaveBeenCalledWith({ + where: { userId: 2 }, + include: { positions: true }, + orderBy: { updatedAt: 'desc' }, + }); + }); + }); + + describe('findOne', () => { + it('should throw NotFoundException for non-existent portfolio', async () => { + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); + await expect(service.findOne(1, 999)).rejects.toThrow(NotFoundException); + }); + + it('should throw ForbiddenException for wrong user', async () => { + vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); + await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend` +Expected: FAIL — tests assert behavior that's not yet implemented + +--- + +### Task 3: Implement backend enrichment in PortfolioService.findAll + +**Files:** +- Modify: `apps/backend/src/modules/portfolio/portfolio.service.ts` — rewrite `findAll` method + +- [ ] **Step 1: Replace the findAll method** + +Current code (lines 62-67): +```typescript +async findAll(userId: number) { + return this.prisma.portfolio.findMany({ + where: { userId }, + orderBy: { updatedAt: 'desc' }, + }); +} +``` + +Replace with: +```typescript +async findAll(userId: number) { + const portfolios = await this.prisma.portfolio.findMany({ + where: { userId }, + include: { positions: true }, + orderBy: { updatedAt: 'desc' }, + }); + + const allPositions = portfolios.flatMap((p) => p.positions); + if (allPositions.length === 0) { + return portfolios.map((p) => ({ + id: p.id, + name: p.name, + description: p.description, + currency: p.currency, + createdAt: p.createdAt.toISOString(), + updatedAt: p.updatedAt.toISOString(), + totalValue: 0, + positionCount: 0, + shareCount: 0, + bondCount: 0, + })); + } + + const enrichedPositions = await this.enrichPositions(allPositions); + + const posByPortfolioId = new Map(); + for (let i = 0; i < enrichedPositions.length; i++) { + const pfId = allPositions[i].portfolioId; + if (!posByPortfolioId.has(pfId)) { + posByPortfolioId.set(pfId, []); + } + posByPortfolioId.get(pfId)!.push(enrichedPositions[i]); + } + + return portfolios.map((p) => { + const positions = posByPortfolioId.get(p.id) ?? []; + const totalValue = positions.reduce((sum, pos) => sum + (pos.currentValue ?? 0), 0); + return { + id: p.id, + name: p.name, + description: p.description, + currency: p.currency, + createdAt: p.createdAt.toISOString(), + updatedAt: p.updatedAt.toISOString(), + totalValue: Math.round(totalValue * 100) / 100, + positionCount: positions.length, + shareCount: positions.filter((pos) => pos.type === 'share').length, + bondCount: positions.filter((pos) => pos.type === 'bond').length, + }; + }); +} +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add apps/backend/src/modules/portfolio/portfolio.service.ts \ + apps/backend/src/modules/portfolio/portfolio.service.spec.ts +git commit -m "feat(backend): enrich portfolio list with MOEX batch data" +``` + +--- + +### Task 4: Update PortfolioController with new DTO + +**Files:** +- Modify: `apps/backend/src/modules/portfolio/portfolio.controller.ts` + +- [ ] **Step 1: Import PortfolioListResponseDto** + +Add import at top: +```typescript +import { PortfolioListResponseDto } from './dto/portfolio-list-response.dto'; +``` + +- [ ] **Step 2: Update findAll to use new DTO in Swagger** + +Replace method with ApiResponse decorator: +```typescript +@Get() +@ApiOperation({ summary: 'Get all portfolios for current user' }) +@ApiOkResponse({ type: PortfolioListResponseDto, isArray: true }) +async findAll(@CurrentUser() user: { sub: number }) { + const portfolios = await this.portfolioService.findAll(user.sub); + return { data: portfolios, meta: { cachedAt: null, fromCache: false } }; +} +``` + +Also add the import: +```typescript +import { ApiOkResponse } from '@nestjs/swagger'; +``` + +- [ ] **Step 3: Run existing tests to verify no regressions** + +Run: `npx vitest run apps/backend/src/modules/portfolio/portfolio.service.spec.ts -w apps/backend` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add apps/backend/src/modules/portfolio/portfolio.controller.ts +git commit -m "feat(backend): add Swagger decorators for enriched portfolio list" +``` + +--- + +### Task 5: Update frontend types + +**Files:** +- Modify: `apps/frontend/src/api/responses.ts` + +- [ ] **Step 1: Add new fields to Portfolio interface** + +Current (lines 138-145): +```typescript +export interface Portfolio { + id: number; + name: string; + description: string | null; + currency: string; + createdAt: string; + updatedAt: string; +} +``` + +Replace with: +```typescript +export interface Portfolio { + id: number; + name: string; + description: string | null; + currency: string; + createdAt: string; + updatedAt: string; + totalValue: number; + positionCount: number; + shareCount: number; + bondCount: number; +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc -b apps/frontend` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add apps/frontend/src/api/responses.ts +git commit -m "feat(frontend): add enrichment fields to Portfolio type" +``` + +--- + +### Task 6: Update PortfolioCard to show enriched data + +**Files:** +- Modify: `apps/frontend/src/components/portfolios/PortfolioCard.tsx` + +- [ ] **Step 1: Replace PortfolioCard implementation** + +Current (lines 1-39): +```typescript +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')} + + + ); +} +``` + +Replace with: +```typescript +import { Link } from 'react-router-dom'; +import type { Portfolio } from '../../api/responses'; + +export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) { + const chipStyle = (bg: string): React.CSSProperties => ({ + background: bg, + padding: '4px 10px', + borderRadius: 6, + fontSize: 12, + color: '#fff', + fontWeight: 500, + }); + + return ( + (e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.08)')} + onMouseLeave={(e) => (e.currentTarget.style.boxShadow = 'none')} + > +
+

{portfolio.name}

+
+
+ {portfolio.totalValue.toLocaleString('ru-RU', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +
+
+ {portfolio.currency} +
+
+
+ + {portfolio.description && ( +

+ {portfolio.description} +

+ )} + +
+ {portfolio.shareCount > 0 && ( + + {portfolio.shareCount} {pluralize(portfolio.shareCount, 'акция', 'акции', 'акций')} + + )} + {portfolio.bondCount > 0 && ( + + {portfolio.bondCount} {pluralize(portfolio.bondCount, 'облигация', 'облигации', 'облигаций')} + + )} + + {portfolio.positionCount} {pluralize(portfolio.positionCount, 'позиция', 'позиции', 'позиций')} + +
+ + + обновлён {new Date(portfolio.updatedAt).toLocaleDateString('ru-RU')} + + + ); +} + +function pluralize(n: number, one: string, few: string, many: string): string { + if (n % 10 === 1 && n % 100 !== 11) return one; + if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return few; + return many; +} +``` + +- [ ] **Step 2: Verify frontend builds** + +Run: `npm run build:frontend -w apps/frontend` (or `npx tsc -b apps/frontend`) +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add apps/frontend/src/components/portfolios/PortfolioCard.tsx +git commit -m "feat(frontend): display enriched data in PortfolioCard" +``` + +--- + +### Task 7: Run full test suite and verify + +- [ ] **Step 1: Run backend tests** + +Run: `npm run test:backend` +Expected: All tests pass (including new portfolio service tests) + +- [ ] **Step 2: Run frontend build** + +Run: `npm run build:frontend` +Expected: Build succeeds + +- [ ] **Step 3: Run linter** + +Run: `npm run lint` +Expected: No lint errors diff --git a/docs/superpowers/specs/2026-06-14-portfolio-list-enrichment.md b/docs/features/portfolio-list-enrichment/spec.md similarity index 100% rename from docs/superpowers/specs/2026-06-14-portfolio-list-enrichment.md rename to docs/features/portfolio-list-enrichment/spec.md diff --git a/docs/features/portfolio/plan.md b/docs/features/portfolio/plan.md new file mode 100644 index 0000000..d97d2e6 --- /dev/null +++ b/docs/features/portfolio/plan.md @@ -0,0 +1,1615 @@ +# Portfolio Phase 1 Implementation Plan + +> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement Portfolio CRUD + Position CRUD with MOEX price integration. + +**Architecture:** New `PortfolioModule` on backend (NestJS) following existing feature module patterns. New `portfolios/` pages on frontend (React + TanStack Query). Prisma models `Portfolio` and `Position`. + +**Tech Stack:** NestJS, Prisma + SQLite, TanStack Query v5, React 18, react-router-dom v6 + +--- + +## File Structure + +### Backend (new files) +- `apps/backend/src/modules/portfolio/portfolio.module.ts` +- `apps/backend/src/modules/portfolio/portfolio.controller.ts` +- `apps/backend/src/modules/portfolio/portfolio.service.ts` +- `apps/backend/src/modules/portfolio/dto/create-portfolio.dto.ts` +- `apps/backend/src/modules/portfolio/dto/update-portfolio.dto.ts` +- `apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts` +- `apps/backend/src/modules/portfolio/dto/add-position.dto.ts` +- `apps/backend/src/modules/portfolio/dto/update-position.dto.ts` +- `apps/backend/src/modules/portfolio/dto/position-response.dto.ts` + +### Backend (modified files) +- `apps/backend/prisma/schema.prisma` — add Portfolio + Position models +- `apps/backend/src/app.module.ts` — add PortfolioModule import + +### Frontend (new files) +- `apps/frontend/src/api/portfolio.ts` +- `apps/frontend/src/hooks/usePortfolios.ts` +- `apps/frontend/src/hooks/usePortfolio.ts` +- `apps/frontend/src/hooks/usePortfolioMutations.ts` +- `apps/frontend/src/hooks/usePositionMutations.ts` +- `apps/frontend/src/pages/portfolios/PortfoliosListPage.tsx` +- `apps/frontend/src/pages/portfolios/PortfolioDetailPage.tsx` +- `apps/frontend/src/components/portfolios/PortfolioCard.tsx` +- `apps/frontend/src/components/portfolios/PortfolioForm.tsx` +- `apps/frontend/src/components/portfolios/PositionTable.tsx` +- `apps/frontend/src/components/portfolios/PositionRow.tsx` +- `apps/frontend/src/components/portfolios/PortfolioSummary.tsx` +- `apps/frontend/src/components/portfolios/TargetAllocationEditor.tsx` +- `apps/frontend/src/components/portfolios/TagBadge.tsx` + +### Frontend (modified files) +- `apps/frontend/src/routes.tsx` — add portfolio routes +- `apps/frontend/src/components/Layout.tsx` — add portfolio nav link +- `apps/frontend/src/api/responses.ts` — add Portfolio/Position types + +--- + +### Task 1: Prisma schema — Portfolio and Position models + +**Files:** +- Modify: `apps/backend/prisma/schema.prisma` +- Run: `npx prisma migrate dev` + +- [ ] **Add Portfolio and Position models to schema.prisma** + +```prisma +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 + 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]) +} +``` + +- [ ] **Run Prisma migration** + +```bash +npx prisma migrate dev --name add-portfolio-position -w apps/backend +``` + +--- + +### Task 2: Backend — PortfolioModule scaffold + +**Files:** +- Create: `apps/backend/src/modules/portfolio/portfolio.module.ts` +- Create: `apps/backend/src/modules/portfolio/portfolio.controller.ts` +- Create: `apps/backend/src/modules/portfolio/portfolio.service.ts` +- Create: `apps/backend/src/modules/portfolio/dto/create-portfolio.dto.ts` +- Create: `apps/backend/src/modules/portfolio/dto/update-portfolio.dto.ts` +- Create: `apps/backend/src/modules/portfolio/dto/portfolio-response.dto.ts` +- Create: `apps/backend/src/modules/portfolio/dto/add-position.dto.ts` +- Create: `apps/backend/src/modules/portfolio/dto/update-position.dto.ts` +- Create: `apps/backend/src/modules/portfolio/dto/position-response.dto.ts` +- Modify: `apps/backend/src/app.module.ts` + +- [ ] **Create DTO: create-portfolio.dto.ts** + +```typescript +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; +} +``` + +- [ ] **Create DTO: update-portfolio.dto.ts** + +```typescript +import { IsString, IsOptional, IsIn, MaxLength, MinLength, IsArray, ValidateNested, IsNumber, Min, Max } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +const CURRENCIES = ['RUB', 'USD', 'EUR', 'CNY', 'KZT', 'BYN'] as const; + +export class TargetAllocationDto { + @ApiProperty({ example: 'SBER' }) + @IsString() + secid!: string; + + @ApiProperty({ example: 30, description: 'Target percent (0-100)' }) + @IsNumber() + @Min(0) + @Max(100) + targetPercent!: number; +} + +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; + + @ApiPropertyOptional({ type: [TargetAllocationDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TargetAllocationDto) + @IsOptional() + targets?: TargetAllocationDto[]; +} +``` + +- [ ] **Create DTO: add-position.dto.ts** + +```typescript +import { IsString, IsOptional, IsInt, Min, IsArray, IsIn, MaxLength } 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[]; +} +``` + +- [ ] **Create DTO: update-position.dto.ts** + +```typescript +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[]; +} +``` + +- [ ] **Create DTO: portfolio-response.dto.ts** + +```typescript +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class TargetAllocationDto { + @ApiProperty({ example: 'SBER' }) + secid!: string; + + @ApiProperty({ example: 30 }) + targetPercent!: number; +} + +export class PortfolioResponseDto { + @ApiProperty() id!: number; + @ApiProperty() name!: string; + @ApiPropertyOptional() description!: string | null; + @ApiProperty({ default: 'RUB' }) currency!: string; + @ApiPropertyOptional({ type: [TargetAllocationDto] }) targets!: TargetAllocationDto[] | null; + @ApiProperty() createdAt!: string; + @ApiProperty() updatedAt!: string; +} + +export class PositionWithPriceDto { + @ApiProperty() id!: number; + @ApiProperty({ example: 'SBER' }) secid!: 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() targetPercent!: number | null; + @ApiPropertyOptional() deviation!: number | null; +} + +export class PortfolioDetailResponseDto extends PortfolioResponseDto { + @ApiProperty({ type: [PositionWithPriceDto] }) + positions!: PositionWithPriceDto[]; + + @ApiProperty() totalValue!: number; +} +``` + +- [ ] **Create DTO: position-response.dto.ts** + +```typescript +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; +} +``` + +- [ ] **Create: portfolio.service.ts** + +```typescript +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'; + +@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 targets = this.parseTargets(portfolio.targets); + const positionsWithPrices = await this.enrichPositions(portfolio.positions, targets); + + const totalValue = positionsWithPrices.reduce((sum, p) => sum + (p.currentValue ?? 0), 0); + + const positionsWithWeights = positionsWithPrices.map((p) => { + const weightPercent = totalValue > 0 ? ((p.currentValue ?? 0) / totalValue) * 100 : 0; + const targetPercent = p.targetPercent ?? null; + return { + ...p, + weightPercent: Math.round(weightPercent * 2) / 2, + deviation: targetPercent !== null ? Math.round((weightPercent - targetPercent) * 2) / 2 : null, + }; + }); + + return { + id: portfolio.id, + name: portfolio.name, + description: portfolio.description, + currency: portfolio.currency, + targets: targets.length > 0 ? targets : null, + 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'); + + if (dto.targets) { + const total = dto.targets.reduce((sum, t) => sum + t.targetPercent, 0); + if (Math.round(total) !== 100) { + throw new BadRequestException('Sum of target allocations must be 100'); + } + } + + 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 }), + ...(dto.targets !== undefined && { targets: JSON.stringify(dto.targets) }), + }, + }); + } + + 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`); + + return this.prisma.position.create({ + data: { + portfolioId, + secid: dto.secid, + 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 parseTargets(targetsJson: string | null): { secid: string; targetPercent: number }[] { + if (!targetsJson) return []; + try { + return JSON.parse(targetsJson); + } catch { + return []; + } + } + + private async enrichPositions( + positions: { id: number; portfolioId: number; secid: string; quantity: number; notes: string | null; tags: string | null }[], + targets: { secid: string; targetPercent: number }[], + ) { + return Promise.all( + positions.map(async (pos) => { + let currentPrice: number | null = null; + + try { + const marketData = await this.cache.getOrFetch( + 'marketdata', + ['portfolio', pos.secid], + async () => { + const shareData = await this.moexClient.getShareMarketData(pos.secid); + if (shareData?.last) return { price: shareData.last }; + const bondData = await this.moexClient.getBondMarketData(pos.secid); + if (bondData?.last) return { price: bondData.last }; + return { price: null }; + }, + 'marketDataTtl', + ); + currentPrice = marketData.data.price; + } catch { + currentPrice = null; + } + + const target = targets.find((t) => t.secid === pos.secid); + + return { + id: pos.id, + secid: pos.secid, + quantity: pos.quantity, + notes: pos.notes, + tags: pos.tags ? JSON.parse(pos.tags) : null, + currentPrice, + currentValue: currentPrice !== null ? currentPrice * pos.quantity : null, + targetPercent: target?.targetPercent ?? null, + weightPercent: 0, + deviation: null, + }; + }), + ); + } +} +``` + +- [ ] **Create: portfolio.controller.ts** + +```typescript +import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe, UseGuards } 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'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + +@ApiTags('Portfolios') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@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 } }; + } +} +``` + +- [ ] **Create: portfolio.module.ts** + +```typescript +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 {} +``` + +- [ ] **Register PortfolioModule in app.module.ts** + +```typescript +// Add import: +import { PortfolioModule } from './modules/portfolio/portfolio.module'; + +// Add to imports array: +PortfolioModule, +``` + +--- + +### Task 3: Frontend — API types and client + +**Files:** +- Modify: `apps/frontend/src/api/responses.ts` +- Create: `apps/frontend/src/api/portfolio.ts` + +- [ ] **Add Portfolio/Position types to responses.ts** + +```typescript +export interface Portfolio { + id: number; + name: string; + description: string | null; + currency: string; + targets: TargetAllocation[] | null; + createdAt: string; + updatedAt: string; +} + +export interface TargetAllocation { + secid: string; + targetPercent: number; +} + +export interface PositionWithPrice { + id: number; + secid: string; + quantity: number; + notes: string | null; + tags: string[] | null; + currentPrice: number | null; + currentValue: number | null; + weightPercent: number; + targetPercent: number | null; + deviation: number | 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; +} +``` + +- [ ] **Create: api/portfolio.ts** + +```typescript +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; targets?: { secid: string; targetPercent: number }[] }): 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', + }); +} +``` + +--- + +### Task 4: Frontend — TanStack Query hooks + +**Files:** +- Create: `apps/frontend/src/hooks/usePortfolios.ts` +- Create: `apps/frontend/src/hooks/usePortfolio.ts` +- Create: `apps/frontend/src/hooks/usePortfolioMutations.ts` +- Create: `apps/frontend/src/hooks/usePositionMutations.ts` + +- [ ] **Create: hooks/usePortfolios.ts** + +```typescript +import { useQuery } from '@tanstack/react-query'; +import { getPortfolios } from '../api/portfolio'; + +export function usePortfolios() { + return useQuery({ + queryKey: ['portfolios'], + queryFn: () => getPortfolios(), + staleTime: 900_000, + retry: 2, + refetchOnWindowFocus: false, + }); +} +``` + +- [ ] **Create: hooks/usePortfolio.ts** + +```typescript +import { useQuery } from '@tanstack/react-query'; +import { getPortfolio } from '../api/portfolio'; + +export function usePortfolio(id: number) { + return useQuery({ + queryKey: ['portfolio', id], + queryFn: () => getPortfolio(id), + staleTime: 900_000, + retry: 2, + refetchOnWindowFocus: false, + enabled: !!id, + }); +} +``` + +- [ ] **Create: hooks/usePortfolioMutations.ts** + +```typescript +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; targets?: { secid: string; targetPercent: number }[] } }) => + 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 }; +} +``` + +- [ ] **Create: hooks/usePositionMutations.ts** + +```typescript +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { addPosition, updatePosition, removePosition } from '../api/portfolio'; + +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(['portfolio', portfolioId]); + queryClient.setQueryData(['portfolio', portfolioId], (old: any) => { + if (!old) return old; + return { + ...old, + data: { + ...old.data, + positions: old.data.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 }; +} +``` + +--- + +### Task 5: Frontend — Portfolio components + +**Files:** +- Create: `apps/frontend/src/components/portfolios/TagBadge.tsx` +- Create: `apps/frontend/src/components/portfolios/PortfolioCard.tsx` +- Create: `apps/frontend/src/components/portfolios/PortfolioForm.tsx` +- Create: `apps/frontend/src/components/portfolios/PortfolioSummary.tsx` +- Create: `apps/frontend/src/components/portfolios/PositionRow.tsx` +- Create: `apps/frontend/src/components/portfolios/PositionTable.tsx` +- Create: `apps/frontend/src/components/portfolios/TargetAllocationEditor.tsx` + +- [ ] **Create: components/portfolios/TagBadge.tsx** + +```tsx +const TAG_COLORS: Record = { + DIVIDEND: '#2e7d32', + GROWTH: '#1565c0', + DEFENSIVE: '#6a1b9a', + SPECULATIVE: '#e65100', + BOND: '#00838f', + ETF: '#4a148c', + GOVERNMENT: '#37474f', + CASH: '#546e7a', +}; + +export function TagBadge({ tag }: { tag: string }) { + return ( + + {tag} + + ); +} +``` + +- [ ] **Create: components/portfolios/PortfolioCard.tsx** + +```tsx +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')} + + + ); +} +``` + +- [ ] **Create: components/portfolios/PortfolioForm.tsx** + +```tsx +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 }} + /> +
+
+ +