From 739405a597cfb14ee8ad2e6d9ce7601acde8a7c6 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 15 Jun 2026 21:04:24 +0300 Subject: [PATCH] docs: consolidate SDD documentation --- AGENTS.md | 25 +- README.md | 4 +- apps/backend/src/openapi-artifacts.spec.ts | 97 +- apps/docs/docs/adr/index.md | 5 +- apps/docs/docs/architecture.md | 4 +- apps/docs/docs/backend/modules.md | 36 + apps/docs/docs/development/codegen.md | 7 +- apps/docs/docs/intro.md | 6 +- apps/docs/docusaurus.config.ts | 6 + apps/docs/package.json | 1 + .../ADR-001-backend-single-point-of-access.md | 25 - .../adr/ADR-002-in-memory-cache.md | 33 - .../adr/ADR-003-rate-limiting-strategy.md | 21 - .../adr/ADR-004-feature-modules.md | 29 - .../adr/ADR-005-openapi-codegen-frontend.md | 40 - docs/architecture/adr/ADR-006-no-cci.md | 20 - .../adr/ADR-007-two-level-caching.md | 27 - docs/architecture/overview.md | 24 - docs/openapi/openapi.yaml | 1401 ------- docs/requirements.md | 193 - ...6-06-14-portfolio-enricher-optimization.md | 65 - .../plans/2026-06-13-auth-system.md | 1186 ------ .../plans/2026-06-13-cicd-implementation.md | 110 - .../2026-06-13-frontend-test-coverage.md | 1920 --------- .../2026-06-13-moex-vibe-implementation.md | 3456 ----------------- .../2026-06-14-portfolio-allocation-chart.md | 261 -- .../plans/2026-06-14-portfolio-analytics.md | 1037 ----- ...6-06-14-portfolio-enricher-optimization.md | 365 -- .../2026-06-14-portfolio-list-enrichment.md | 613 --- .../plans/2026-06-14-portfolio-phase1.md | 1615 -------- .../plans/2026-06-14-pre-commit-checks.md | 187 - .../2026-06-14-quality-gate-contract-docs.md | 1401 ------- .../plans/2026-06-14-security-screener.md | 1614 -------- package-lock.json | 1102 +++++- 34 files changed, 1202 insertions(+), 15734 deletions(-) delete mode 100644 docs/architecture/adr/ADR-001-backend-single-point-of-access.md delete mode 100644 docs/architecture/adr/ADR-002-in-memory-cache.md delete mode 100644 docs/architecture/adr/ADR-003-rate-limiting-strategy.md delete mode 100644 docs/architecture/adr/ADR-004-feature-modules.md delete mode 100644 docs/architecture/adr/ADR-005-openapi-codegen-frontend.md delete mode 100644 docs/architecture/adr/ADR-006-no-cci.md delete mode 100644 docs/architecture/adr/ADR-007-two-level-caching.md delete mode 100644 docs/architecture/overview.md delete mode 100644 docs/openapi/openapi.yaml delete mode 100644 docs/requirements.md delete mode 100644 docs/superpowers/adr/2026-06-14-portfolio-enricher-optimization.md delete mode 100644 docs/superpowers/plans/2026-06-13-auth-system.md delete mode 100644 docs/superpowers/plans/2026-06-13-cicd-implementation.md delete mode 100644 docs/superpowers/plans/2026-06-13-frontend-test-coverage.md delete mode 100644 docs/superpowers/plans/2026-06-13-moex-vibe-implementation.md delete mode 100644 docs/superpowers/plans/2026-06-14-portfolio-allocation-chart.md delete mode 100644 docs/superpowers/plans/2026-06-14-portfolio-analytics.md delete mode 100644 docs/superpowers/plans/2026-06-14-portfolio-enricher-optimization.md delete mode 100644 docs/superpowers/plans/2026-06-14-portfolio-list-enrichment.md delete mode 100644 docs/superpowers/plans/2026-06-14-portfolio-phase1.md delete mode 100644 docs/superpowers/plans/2026-06-14-pre-commit-checks.md delete mode 100644 docs/superpowers/plans/2026-06-14-quality-gate-contract-docs.md delete mode 100644 docs/superpowers/plans/2026-06-14-security-screener.md diff --git a/AGENTS.md b/AGENTS.md index f63193d..eb821eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,9 +6,23 @@ npm workspaces монорепозиторий: `apps/backend` (NestJS), `apps/fr ## Обязательный подход к разработке -- **SDD (Specification-Driven Development)**: перед написанием кода сначала сформировать спецификацию — PRD, доменную модель, ADR, OpenAPI-контракт, архитектуру фронтенда и бэкенда, план реализации по этапам. -- **Superpowers**: обязательно использовать скиллы (Skills) при старте любой задачи — brainstorming, frontend-design, test-driven-development, writing-plans, executing-plans, requesting-code-review. -- **MCP-инструменты**: использовать MCP для анализа и генерации дизайна, работы с API, генерации кода. +- **SDD (Specification-Driven Development)**: перед значимыми изменениями сначала зафиксировать спецификацию нужного масштаба — PRD/цели, доменную модель, ADR, API-контракт, frontend/backend architecture и этапы реализации. Для небольших maintenance-правок достаточно короткого обоснования и acceptance criteria. +- **Superpowers**: использовать релевантные Skills при старте задачи. Обычно: brainstorming для уточнения дизайна, systematic-debugging для багов, test-driven-development для feature/bugfix, writing-plans/executing-plans для крупных многошаговых работ, frontend-design для UI, requesting-code-review перед завершением крупных изменений. +- **MCP-инструменты**: использовать MCP для анализа, дизайна, работы с API, генерации кода и проверки локального UI, когда это полезно задаче. + +## Git workflow + +- Для каждой самостоятельной фичи создавать отдельную feature branch и вести разработку внутри неё. +- Имя ветки по умолчанию начинать с `codex/`, если пользователь не попросил другой префикс. +- Не смешивать независимые фичи в одной ветке. Небольшие связанные docs/chore/test-правки можно держать в той же ветке, если они относятся к текущей задаче. + +## Документация и SDD-артефакты + +- `apps/docs` — единственная опубликованная человекочитаемая документация проекта (Docusaurus). +- Root `docs` хранит только согласованные SDD-спецификации в `docs/superpowers/specs/`. +- 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. ## Команды @@ -37,11 +51,14 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`. | `PORT` | 3000 | Порт бэкенда | | `MOEX_BASE_URL` | `https://iss.moex.com/iss` | Endpoint MOEX ISS | | `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX | +| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | 5 | Количество ошибок до открытия circuit breaker | +| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | 30 | Время до попытки закрыть circuit breaker | | `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) | | `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) | | `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) | | `CACHE_SECURITY_TTL` | 86400 | TTL спецификации (с) | | `CACHE_SEARCH_TTL` | 3600 | TTL результатов поиска (с) | +| `CACHE_DIVIDENDS_TTL` | 86400 | TTL дивидендных данных (с) | | `DATABASE_URL` | `file:./dev.db` | URL SQLite для Prisma | | `JWT_SECRET` | `dev-jwt-secret-...` | Secret для access token | | `JWT_REFRESH_SECRET` | `dev-refresh-secret-...` | Secret для refresh token | @@ -55,7 +72,7 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`. - `MoexClientService` использует p-queue (rate limiter) + circuit breaker (5 ошибок → 30s открыт). - In-memory кеш через `@nestjs/cache-manager`. Путь миграции на Redis описан (см. ADR-002). - Аутентификация: JWT access token (15m, в памяти) + refresh token (7d, httpOnly cookie, bcrypt hash в БД). Глобальный `JwtAuthGuard` (`@Public()` для открытых эндпоинтов). -- БД: SQLite через Prisma ORM. Prisma client генерируется в `src/generated/prisma/`. +- БД: SQLite через Prisma ORM. Prisma client используется из `@prisma/client`; схема и миграции находятся в `apps/backend/prisma/`. - Глобальный префикс NestJS: `/api/v1`. Swagger: `/api/docs`. - Глобальный ValidationPipe (`transform: true, whitelist: true`), `HttpExceptionFilter`, `TransformInterceptor`, middleware логирования запросов. - Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`. diff --git a/README.md b/README.md index c4618bf..25d485d 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,5 @@ apps/ frontend/ — React SPA with Vite docs/ — Docusaurus documentation site docs/ - architecture/ — ADR documents and diagrams - openapi/ — OpenAPI specification - superpowers/ — Design specs and implementation plans + superpowers/specs/ — accepted SDD specifications ``` diff --git a/apps/backend/src/openapi-artifacts.spec.ts b/apps/backend/src/openapi-artifacts.spec.ts index 9a7a962..1d1ca3a 100644 --- a/apps/backend/src/openapi-artifacts.spec.ts +++ b/apps/backend/src/openapi-artifacts.spec.ts @@ -1,14 +1,9 @@ import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { load } = require('js-yaml'); - -describe('checked-in OpenAPI artifacts', () => { +describe('checked-in OpenAPI frontend types', () => { const rootDir = resolve(process.cwd(), '../..'); const frontendTypes = readFileSync(join(rootDir, 'apps/frontend/src/api/types.ts'), 'utf8'); - const openapiYaml = readFileSync(join(rootDir, 'docs/openapi/openapi.yaml'), 'utf8'); - const openapi = load(openapiYaml) as any; const requiredPaths = [ '/api/v1/auth/register', @@ -30,79 +25,33 @@ describe('checked-in OpenAPI artifacts', () => { } }); - it('static OpenAPI YAML snapshot includes current protected domains', () => { - for (const path of requiredPaths) { - expect(openapiYaml).toContain(`${path}:`); - } - }); - - it('checked-in artifacts do not leak the local alternate codegen port', () => { + it('frontend generated types do not leak the local alternate codegen port', () => { expect(frontendTypes).not.toContain('localhost:3001'); expect(frontendTypes).not.toContain('3001'); - expect(openapiYaml).not.toContain('localhost:3001'); - expect(openapiYaml).not.toContain('3001'); }); - it('nullable primitive schemas are typed explicitly', () => { - const screenerItem = openapi.components.schemas.ScreenerItemDto; + it('frontend generated types include typed operations for current protected domains', () => { + const requiredOperations = [ + 'AuthController_register', + 'AuthController_login', + 'AuthController_refresh', + 'AuthController_logout', + 'AuthController_getProfile', + 'AuthController_updateProfile', + 'SecuritiesController_screener', + 'PortfolioController_findAll', + 'PortfolioController_create', + 'PortfolioController_findOne', + 'PortfolioController_update', + 'PortfolioController_remove', + 'PortfolioController_addPosition', + 'PortfolioController_updatePosition', + 'PortfolioController_removePosition', + 'PortfolioController_getAnalytics', + ]; - expect(screenerItem.properties.price).toMatchObject({ - type: 'number', - nullable: true, - }); - expect(screenerItem.properties.matDate).toMatchObject({ - type: 'string', - nullable: true, - }); - }); - - it('position tags request schemas are arrays of known tags', () => { - for (const schemaName of ['AddPositionDto', 'UpdatePositionDto']) { - const tags = openapi.components.schemas[schemaName].properties.tags; - - expect(tags).toMatchObject({ - type: 'array', - }); - expect(tags.items.enum).toContain('DIVIDEND'); - } - }); - - it('current auth, screener and portfolio operations have typed JSON responses', () => { - const requiredJsonResponses = [ - ['post', '/api/v1/auth/register', 201], - ['post', '/api/v1/auth/login', 201], - ['post', '/api/v1/auth/refresh', 200], - ['post', '/api/v1/auth/logout', 200], - ['get', '/api/v1/auth/me', 200], - ['patch', '/api/v1/auth/me', 200], - ['get', '/api/v1/securities/screener', 200], - ['get', '/api/v1/portfolios', 200], - ['post', '/api/v1/portfolios', 201], - ['get', '/api/v1/portfolios/{id}', 200], - ['patch', '/api/v1/portfolios/{id}', 200], - ['delete', '/api/v1/portfolios/{id}', 200], - ['post', '/api/v1/portfolios/{id}/positions', 201], - ['patch', '/api/v1/portfolios/{id}/positions/{positionId}', 200], - ['delete', '/api/v1/portfolios/{id}/positions/{positionId}', 200], - ['get', '/api/v1/portfolios/{id}/analytics', 200], - ] as const; - - for (const [method, path, status] of requiredJsonResponses) { - expect( - openapi.paths[path][method].responses[status].content?.['application/json'], - ).toBeDefined(); - } - }); - - it('portfolio delete operations document a null data envelope', () => { - for (const [method, path, status] of [ - ['delete', '/api/v1/portfolios/{id}', 200], - ['delete', '/api/v1/portfolios/{id}/positions/{positionId}', 200], - ] as const) { - expect( - openapi.paths[path][method].responses[status].content['application/json'].schema.properties - .data, - ).toMatchObject({ type: 'null' }); + for (const operation of requiredOperations) { + expect(frontendTypes).toContain(`operations['${operation}']`); } }); }); diff --git a/apps/docs/docs/adr/index.md b/apps/docs/docs/adr/index.md index 3c016cc..1e994b0 100644 --- a/apps/docs/docs/adr/index.md +++ b/apps/docs/docs/adr/index.md @@ -9,5 +9,8 @@ | [ADR-005](ADR-005-openapi-codegen-frontend) | Accepted | OpenAPI Codegen for Frontend | | [ADR-006](ADR-006-no-cci) | Deprecated | No Custom Components Infrastructure | | [ADR-007](ADR-007-two-level-caching) | Draft | Two-Level Caching (In-Memory + Redis) | +| [ADR-008](ADR-008-auth-system) | Accepted | Authentication & Authorization | +| [ADR-009](ADR-009-portfolio-domain) | Accepted | Portfolio Domain Model | +| [ADR-010](ADR-010-backend-price-computation) | Accepted | Backend Price Computation | -Все ADR находятся в `docs/architecture/adr/`. +Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе. diff --git a/apps/docs/docs/architecture.md b/apps/docs/docs/architecture.md index 881a04f..6e9fc06 100644 --- a/apps/docs/docs/architecture.md +++ b/apps/docs/docs/architecture.md @@ -73,7 +73,7 @@ sequenceDiagram ## Architecture Decisions -All architectural decisions are documented as ADR in `docs/architecture/adr/`: +All architectural decisions are documented as ADR pages in this documentation app: | ADR | Summary | |---|---| @@ -85,6 +85,8 @@ All architectural decisions are documented as ADR in `docs/architecture/adr/`: | [ADR-006](adr/ADR-006-no-cci) | No CCI (Custom Components Infrastructure) | | [ADR-007](adr/ADR-007-two-level-caching) | Two-level caching strategy | | [ADR-008](adr/ADR-008-auth-system) | Authentication & Authorization | +| [ADR-009](adr/ADR-009-portfolio-domain) | Portfolio domain model | +| [ADR-010](adr/ADR-010-backend-price-computation) | Backend price computation | ## Response Format diff --git a/apps/docs/docs/backend/modules.md b/apps/docs/docs/backend/modules.md index 8947cfc..0b65711 100644 --- a/apps/docs/docs/backend/modules.md +++ b/apps/docs/docs/backend/modules.md @@ -7,17 +7,24 @@ graph TD AppModule --> ConfigModule AppModule --> CacheModule AppModule --> MoexClientModule + AppModule --> PrismaModule AppModule --> HealthModule + AppModule --> AuthModule AppModule --> SecuritiesModule AppModule --> SharesModule AppModule --> BondsModule AppModule --> CandlesModule + AppModule --> PortfolioModule subgraph Global_Modules["Global Modules"] + PrismaModule CacheModule MoexClientModule end + AuthModule --> PrismaService["PrismaService"] + PortfolioModule --> PrismaService + PortfolioModule --> MoexClientService SharesModule --> CacheService["CacheService"] BondsModule --> CacheService SecuritiesModule --> CacheService @@ -33,13 +40,24 @@ graph TD | Module | Global | Path | Description | |---|---|---|---| +| `PrismaModule` | Yes | `modules/prisma/` | Prisma client for SQLite | | `CacheModule` | Yes | `modules/cache/` | In-memory cache (cache-manager) | | `MoexClientModule` | Yes | `modules/moex-client/` | HTTP-клиент MOEX ISS | | `HealthModule` | No | `modules/health/` | Health check endpoint | +| `AuthModule` | No | `modules/auth/` | JWT auth, refresh cookie, guards | | `SecuritiesModule` | No | `modules/securities/` | Поиск инструментов | | `SharesModule` | No | `modules/shares/` | Акции | | `BondsModule` | No | `modules/bonds/` | Облигации | | `CandlesModule` | No | `modules/candles/` | Свечи OHLCV | +| `PortfolioModule` | No | `modules/portfolio/` | Пользовательские портфели и аналитика | + +### PrismaModule + +Глобальный модуль доступа к SQLite через Prisma. + +- `PrismaService` подключается к базе из `DATABASE_URL` +- Схема и миграции находятся в `apps/backend/prisma/` +- Используется `@prisma/client`; отдельный generated client в `src/generated/prisma/` не создаётся ### CacheModule @@ -63,6 +81,15 @@ graph TD - `GET /api/v1/health` → `{ status: 'ok', timestamp, uptime }` +### AuthModule + +Аутентификация и авторизация. + +- JWT access token передаётся в `Authorization: Bearer ` +- Refresh token хранится в httpOnly cookie и в БД как bcrypt hash +- `JwtAuthGuard` и `RolesGuard` зарегистрированы глобально через `APP_GUARD` +- Открытые endpoints помечаются `@Public()` + ### SecuritiesModule Поиск по инструментам (акции и облигации). @@ -92,3 +119,12 @@ graph TD - 2 эндпоинта: для акций и облигаций - Интервалы: `1h` (60 min) и `24h` (daily) - Маппинг: `1h` → MOEX interval 60, `24h` → MOEX interval 24 + +### PortfolioModule + +Портфели пользователя и аналитика позиций. + +- CRUD портфелей и позиций +- Обогащение позиций текущими ценами из MOEX +- Расчёт summary, PnL и долей портфеля +- Все endpoints защищены JWT diff --git a/apps/docs/docs/development/codegen.md b/apps/docs/docs/development/codegen.md index c04c015..5bd116c 100644 --- a/apps/docs/docs/development/codegen.md +++ b/apps/docs/docs/development/codegen.md @@ -24,7 +24,7 @@ openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts ### Output - `apps/frontend/src/api/types.ts` — сгенерированные типы `paths` и `operations` -- `docs/openapi/openapi.yaml` — статический snapshot Swagger JSON для ревью и документации +- Live Swagger JSON на `http://localhost:3000/api/docs-json` остаётся источником OpenAPI-контракта ### Verify Artifacts @@ -34,9 +34,8 @@ openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts npm run test -w apps/backend -- src/openapi-artifacts.spec.ts ``` -Тест проверяет, что checked-in frontend types и YAML содержат актуальные auth, screener и portfolio -paths, не содержат локальный alternate port и сохраняют важные schema metadata для nullable полей, -array enum tags и typed response envelopes. +Тест проверяет, что checked-in frontend types содержат актуальные auth, screener и portfolio paths +и не содержат локальный alternate port. ### Manual Types diff --git a/apps/docs/docs/intro.md b/apps/docs/docs/intro.md index 895392d..40e3ccc 100644 --- a/apps/docs/docs/intro.md +++ b/apps/docs/docs/intro.md @@ -22,9 +22,8 @@ moex-vibe/ │ ├── frontend/ # React SPA │ └── docs/ # Docusaurus documentation site ├── docs/ -│ ├── architecture/ # ADR и диаграммы -│ ├── openapi/ # OpenAPI-спецификация -│ └── superpowers/ # Дизайн-спеки и планы +│ └── superpowers/ +│ └── specs/ # Согласованные SDD-спецификации ├── docker/ │ ├── Dockerfile.backend │ ├── Dockerfile.frontend @@ -39,3 +38,4 @@ moex-vibe/ - npm workspaces монорепозиторий: `apps/backend`, `apps/frontend` и `apps/docs`. - Глобальный префикс API: `/api/v1`. Swagger: `/api/docs`. - Ответы API обёрнуты в `{ data: T, meta: { fromCache, cachedAt } }`. +- `apps/docs` — опубликованная документация; root `docs` хранит только SDD specs. diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index adee17c..7d2308c 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -18,6 +18,10 @@ const config: Config = { locales: ['ru'], }, + markdown: { + mermaid: true, + }, + presets: [ [ 'classic', @@ -34,6 +38,8 @@ const config: Config = { ], ], + themes: ['@docusaurus/theme-mermaid'], + themeConfig: { navbar: { title: 'MoexVibe', diff --git a/apps/docs/package.json b/apps/docs/package.json index 09ddd1f..c40e7fd 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -10,6 +10,7 @@ "dependencies": { "@docusaurus/core": "3.7.0", "@docusaurus/preset-classic": "3.7.0", + "@docusaurus/theme-mermaid": "3.7.0", "@mdx-js/react": "^3.0.0", "react": "^18.3.0", "react-dom": "^18.3.0" diff --git a/docs/architecture/adr/ADR-001-backend-single-point-of-access.md b/docs/architecture/adr/ADR-001-backend-single-point-of-access.md deleted file mode 100644 index 6170454..0000000 --- a/docs/architecture/adr/ADR-001-backend-single-point-of-access.md +++ /dev/null @@ -1,25 +0,0 @@ -# ADR-001: Backend — Single Point of Access to MOEX - -**Status:** Accepted -**Date:** 2026-06-13 -**Deciders:** Architect, Tech Lead - -## Context -Frontend должен отображать данные Московской биржи. MOEX ISS API отдаёт сырые данные со сложной структурой (вложенные таблицы, различные форматы). Прямые запросы с фронта приведут к дублированию логики нормализации, усложнят обработку ошибок и сделают систему зависимой от внешнего API. - -## Decision -Backend (NestJS) является единственной точкой доступа к MOEX. Frontend никогда не обращается к MOEX напрямую. - -Backend: -- Проксирует запросы к MOEX ISS -- Нормализует данные в доменные модели -- Кеширует ответы -- Обрабатывает ошибки MOEX (пустые данные, rate limit, таймауты) -- Предоставляет собственный OpenAPI-контракт для фронта - -## Consequences -- Единый источник правды для трансформации данных -- Изоляция изменений MOEX API — меняется только MoexClient -- Централизованное кеширование сокращает количество запросов к MOEX -- Фронтенд остаётся тонким клиентом -- Дополнительная задержка (один hop), но нивелируется кешированием diff --git a/docs/architecture/adr/ADR-002-in-memory-cache.md b/docs/architecture/adr/ADR-002-in-memory-cache.md deleted file mode 100644 index 62175ff..0000000 --- a/docs/architecture/adr/ADR-002-in-memory-cache.md +++ /dev/null @@ -1,33 +0,0 @@ -# ADR-002: In-Memory Cache with Migration Path to Redis - -**Status:** Accepted -**Date:** 2026-06-13 -**Deciders:** Architect, Tech Lead - -## Context -Для MVP требуется кеширование MOEX-данных, чтобы снизить нагрузку на внешнее API и обеспечить приемлемое время ответа. На начальном этапе нет требований к горизонтальному масштабированию, и хочется избежать внешних зависимостей. - -## Decision -Использовать `@nestjs/cache-manager` с MemoryStore. TTL настраивается per-endpoint через конфигурацию. - -Архитектура позволяет переключиться на Redis заменой импорта провайдера: - -```typescript -// Текущая реализация -CacheModule.register({ store: 'memory', ttl: 900 }) - -// Миграция на Redis (меняется только registration) -CacheModule.registerAsync({ - useFactory: () => ({ - store: redisStore, - host: process.env.REDIS_HOST, - port: process.env.REDIS_PORT, - }), -}) -``` - -## Consequences -- Нет внешних зависимостей для MVP -- Кеш сбрасывается при рестарте сервера (приемлемо для read-only приложения) -- Чистый путь миграции на Redis -- Единый API для cache (cache-manager abstraction) diff --git a/docs/architecture/adr/ADR-003-rate-limiting-strategy.md b/docs/architecture/adr/ADR-003-rate-limiting-strategy.md deleted file mode 100644 index 483a88f..0000000 --- a/docs/architecture/adr/ADR-003-rate-limiting-strategy.md +++ /dev/null @@ -1,21 +0,0 @@ -# ADR-003: Rate Limiting Strategy for MOEX Client - -**Status:** Accepted -**Date:** 2026-06-13 -**Deciders:** Architect, Tech Lead - -## Context -MOEX ISS не документирует жёсткие лимиты на количество запросов, но массовые запросы могут привести к блокировке или ухудшению качества обслуживания. Backend является единственным клиентом MOEX и должен контролировать исходящий трафик. - -## Decision -Внедрить два механизма в MoexClient: - -1. **Request Queue (p-queue)**: конфигурируемый лимит запросов в секунду (default: 10 req/s). Запросы сверх лимита ставятся в очередь и выполняются по расписанию. - -2. **Circuit Breaker (`@nestjs/axios` + interceptor)**: при 5+ последовательных ошибках (5xx, timeout, network error) клиент перестаёт отправлять запросы к MOEX на 30 секунд. После таймаута — пробный запрос для восстановления. - -## Consequences -- Плавная нагрузка на MOEX, без пиков -- Автоматическое восстановление после сбоев MOEX -- Graceful degradation: при отключённом circuit breaker возвращаются кешированные данные -- Параметр конфигурации `MOEX_RATE_LIMIT` (int, req/s) diff --git a/docs/architecture/adr/ADR-004-feature-modules.md b/docs/architecture/adr/ADR-004-feature-modules.md deleted file mode 100644 index ab31eec..0000000 --- a/docs/architecture/adr/ADR-004-feature-modules.md +++ /dev/null @@ -1,29 +0,0 @@ -# ADR-004: Feature Modules by Domain - -**Status:** Accepted -**Date:** 2026-06-13 -**Deciders:** Architect, Tech Lead - -## Context -NestJS рекомендует модульную архитектуру. Требования указывают на архитектуру по feature modules. Модули должны иметь чёткие границы и быть тестируемыми изолированно. - -## Decision -Каждый бизнес-домен — отдельный NestJS feature module: - -| Module | Responsibility | -|--------|---------------| -| `MoexClientModule` | HTTP-клиент к MOEX ISS, rate limiting, circuit breaker | -| `CacheModule` | Абстракция кеширования | -| `SecuritiesModule` | Поиск по инструментам | -| `SharesModule` | Спецификация, marketdata, дивиденды | -| `BondsModule` | Спецификация, marketdata | -| `CandlesModule` | OHLCV свечи (общий для shares+bonds) | -| `HealthModule` | Healthcheck endpoint | - -Каждый module exports свой сервис, control imports через `@Module({ imports: [...] })`. - -## Consequences -- Чёткие границы, изолированное тестирование -- Возможность вынести модуль в отдельный микросервис -- Понятная навигация по коду -- Нет циклических зависимостей (MoexClient — единственный downstream) diff --git a/docs/architecture/adr/ADR-005-openapi-codegen-frontend.md b/docs/architecture/adr/ADR-005-openapi-codegen-frontend.md deleted file mode 100644 index 244b0ad..0000000 --- a/docs/architecture/adr/ADR-005-openapi-codegen-frontend.md +++ /dev/null @@ -1,40 +0,0 @@ -# ADR-005: OpenAPI Codegen with openapi-typescript - -**Status:** Accepted -**Date:** 2026-06-13 -**Deciders:** Architect, Tech Lead - -## Context -Frontend должен потреблять API бэкенда. Ручное написание клиентов и DTO приводит к рассинхронизации с бэкендом и ошибкам типизации. - -## Decision -Использовать `openapi-typescript` + `openapi-fetch` для генерации: - -- TypeScript типов (DTO, request/response schemas) -- Fetcher клиента (типобезопасные вызовы) - -Процесс: -1. Backend генерирует OpenAPI spec через `@nestjs/swagger` -2. `openapi-typescript` на фронте генерирует типы -3. `openapi-fetch` создаёт типобезопасный HTTP-клиент -4. Разработчик пишет TanStack Query hooks вручную поверх сгенерированного клиента - -```typescript -// Пример: типобезопасный хук -import { getSharesSecid } from '@/api/client'; -import type { components } from '@/api/types'; - -export function useStock(secid: string) { - return useQuery({ - queryKey: ['stock', secid], - queryFn: () => getSharesSecid(secid), - staleTime: 900_000, // 15 min - }); -} -``` - -## Consequences -- Полная типобезопасность на стыке frontend/backend -- Автоматическая синхронизация с API-контрактом -- TanStack Query hooks пишутся вручную — полный контроль staleTime/caching -- Добавляется шаг в CI: codegen при изменении OpenAPI spec diff --git a/docs/architecture/adr/ADR-006-no-cci.md b/docs/architecture/adr/ADR-006-no-cci.md deleted file mode 100644 index ab2f9f5..0000000 --- a/docs/architecture/adr/ADR-006-no-cci.md +++ /dev/null @@ -1,20 +0,0 @@ -# ADR-006: CCI (Financial Reporting) Moved Out of MVP - -**Status:** Accepted -**Date:** 2026-06-13 -**Deciders:** Architect, Product - -## Context -MOEX предоставляет корпоративную информацию (CCI) — финансовую отчётность по МСФО/РСБУ. Данные включают отчёты о прибылях/убытках, балансовые отчёты, мультипликаторы. Однако: - -- CCI API имеет собственную сложную структуру (виды отчётности, периоды, индикаторы) -- Данные требуют дополнительной нормализации и расчёта метрик -- Для MVP пользователи хотят базовую информацию (цена, купон, график) - -## Decision -Не включать CCI в MVP. Roadmap на post-MVP. - -## Consequences -- Меньший объём работы в MVP -- API не привязывается к CCI-схемам (будет отдельный модуль) -- Пользователи не увидят мультипликаторы (P/E, EV/EBITDA) в первой версии diff --git a/docs/architecture/adr/ADR-007-two-level-caching.md b/docs/architecture/adr/ADR-007-two-level-caching.md deleted file mode 100644 index 6826570..0000000 --- a/docs/architecture/adr/ADR-007-two-level-caching.md +++ /dev/null @@ -1,27 +0,0 @@ -# ADR-007: Two-Level Caching (Backend + Frontend) - -**Status:** Accepted -**Date:** 2026-06-13 -**Deciders:** Architect - -## Context -Данные MOEX имеют задержку 15 минут. Кеширование на одном уровне (только бэкенд или только фронтенд) неоптимально: -- Только бэкенд: каждый пользователь создаёт запрос к серверу -- Только фронтенд: нет централизованного кеша, не защищает MOEX от повторных запросов - -## Decision -Внедрить два уровня кеширования: - -1. **Backend (in-memory cache-manager)**: централизованное кеширование ответов от MOEX. Предотвращает повторные запросы к MOEX от разных пользователей. - -2. **Frontend (TanStack Query staleTime)**: предотвращает повторные запросы к бэкенду при навигации или монтировании компонентов. - -TTL согласованы (см. Caching Strategy). - -Cache-Control заголовки в HTTP-ответах для промежуточных proxy/CDN (опционально). - -## Consequences -- Избыточность intentional: resilience при отказе одного уровня -- TanStack Query staleTime = backend TTL (нет лишних запросов) -- При рестарте бэкенда фронт всё ещё имеет данные в memory cache -- Небольшое увеличение memory на фронте (приемлемо для SPA) diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md deleted file mode 100644 index 4a60e68..0000000 --- a/docs/architecture/overview.md +++ /dev/null @@ -1,24 +0,0 @@ -# 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 -``` diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml deleted file mode 100644 index 767391b..0000000 --- a/docs/openapi/openapi.yaml +++ /dev/null @@ -1,1401 +0,0 @@ -openapi: 3.0.0 -paths: - /api/v1/health: - get: - operationId: HealthController_check - summary: Проверка состояния сервиса - parameters: [] - responses: - '200': - description: '' - tags: - - Health - /api/v1/auth/register: - post: - operationId: AuthController_register - summary: Register new user - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RegisterDto' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/AuthTokenResponseDto' - tags: - - Auth - /api/v1/auth/login: - post: - operationId: AuthController_login - summary: Login with email and password - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/LoginDto' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/AuthTokenResponseDto' - tags: - - Auth - /api/v1/auth/refresh: - post: - operationId: AuthController_refresh - summary: Refresh access token - parameters: [] - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/AuthTokenResponseDto' - tags: - - Auth - /api/v1/auth/logout: - post: - operationId: AuthController_logout - summary: Logout user - parameters: [] - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/AuthLogoutResponseDto' - tags: - - Auth - security: - - bearer: [] - /api/v1/auth/me: - get: - operationId: AuthController_getProfile - summary: Get current user profile - parameters: [] - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/AuthProfileResponseDto' - tags: - - Auth - security: - - bearer: [] - patch: - operationId: AuthController_updateProfile - summary: Update current user profile - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateProfileDto' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/AuthProfileResponseDto' - tags: - - Auth - security: - - bearer: [] - /api/v1/securities/search: - get: - operationId: SecuritiesController_search - summary: Поиск по инструментам - parameters: - - name: q - required: true - in: query - description: Поисковый запрос (тикер, название, ISIN) - schema: - type: string - - name: type - required: false - in: query - schema: - default: all - enum: - - all - - share - - bond - type: string - - name: limit - required: false - in: query - schema: - default: 20 - type: number - responses: - '200': - description: '' - tags: - - Securities - /api/v1/securities/screener: - get: - operationId: SecuritiesController_screener - summary: Фильтр ценных бумаг по параметрам - parameters: - - name: type - required: true - in: query - schema: - enum: - - share - - bond - type: string - - name: priceMin - required: false - in: query - schema: - type: number - - name: priceMax - required: false - in: query - schema: - type: number - - name: volumeMin - required: false - in: query - schema: - type: number - - name: listLevel - required: false - in: query - schema: - type: number - - name: changePercentMin - required: false - in: query - schema: - type: number - - name: changePercentMax - required: false - in: query - schema: - type: number - - name: capitalizationMin - required: false - in: query - schema: - type: number - - name: yieldMin - required: false - in: query - schema: - type: number - - name: yieldMax - required: false - in: query - schema: - type: number - - name: durationMin - required: false - in: query - schema: - type: number - - name: durationMax - required: false - in: query - schema: - type: number - - name: couponMin - required: false - in: query - schema: - type: number - - name: couponMax - required: false - in: query - schema: - type: number - - name: couponPercentMin - required: false - in: query - schema: - type: number - - name: couponPercentMax - required: false - in: query - schema: - type: number - - name: maturityBefore - required: false - in: query - schema: - type: string - - name: maturityAfter - required: false - in: query - schema: - type: string - - name: bondType - required: false - in: query - schema: - type: string - - name: sortBy - required: false - in: query - schema: - default: price - type: string - - name: sortOrder - required: false - in: query - schema: - default: asc - type: string - - name: page - required: false - in: query - schema: - default: 1 - type: number - - name: pageSize - required: false - in: query - schema: - default: 20 - type: number - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/ScreenerResponseDto' - tags: - - Securities - /api/v1/securities/shares/{secid}: - get: - operationId: SharesController_getShare - summary: Получить спецификацию акции - parameters: - - name: secid - required: true - in: path - schema: - type: string - responses: - '200': - description: '' - tags: - - Shares - /api/v1/securities/shares/{secid}/marketdata: - get: - operationId: SharesController_getMarketData - summary: Получить рыночные данные акции - parameters: - - name: secid - required: true - in: path - schema: - type: string - responses: - '200': - description: '' - tags: - - Shares - /api/v1/securities/shares/{secid}/dividends: - get: - operationId: SharesController_getDividends - summary: Получить дивиденды - parameters: - - name: secid - required: true - in: path - schema: - type: string - responses: - '200': - description: '' - tags: - - Shares - /api/v1/securities/shares/{secid}/history: - get: - operationId: SharesController_getHistory - summary: Получить дневную историю торгов акции - parameters: - - name: secid - required: true - in: path - schema: - type: string - - name: from - required: true - in: query - schema: - type: string - - name: till - required: true - in: query - schema: - type: string - responses: - '200': - description: '' - tags: - - Shares - /api/v1/securities/bonds/{secid}: - get: - operationId: BondsController_getBond - summary: Получить спецификацию облигации - parameters: - - name: secid - required: true - in: path - schema: - type: string - responses: - '200': - description: '' - tags: - - Bonds - /api/v1/securities/bonds/{secid}/marketdata: - get: - operationId: BondsController_getMarketData - summary: Получить рыночные данные облигации - parameters: - - name: secid - required: true - in: path - schema: - type: string - responses: - '200': - description: '' - tags: - - Bonds - /api/v1/securities/bonds/{secid}/history: - get: - operationId: BondsController_getHistory - summary: Получить дневную историю торгов облигации - parameters: - - name: secid - required: true - in: path - schema: - type: string - - name: from - required: true - in: query - schema: - type: string - - name: till - required: true - in: query - schema: - type: string - responses: - '200': - description: '' - tags: - - Bonds - /api/v1/securities/shares/{secid}/candles: - get: - operationId: CandlesController_getShareCandles - summary: Получить свечи акции - parameters: - - name: secid - required: true - in: path - schema: - type: string - - name: interval - required: true - in: query - schema: - enum: - - 1h - - 24h - type: string - - name: from - required: true - in: query - schema: - format: date - example: '2025-06-13' - type: string - - name: till - required: true - in: query - schema: - format: date - example: '2026-06-13' - type: string - responses: - '200': - description: '' - tags: - - Candles - /api/v1/securities/bonds/{secid}/candles: - get: - operationId: CandlesController_getBondCandles - summary: Получить свечи облигации - parameters: - - name: secid - required: true - in: path - schema: - type: string - - name: interval - required: true - in: query - schema: - enum: - - 1h - - 24h - type: string - - name: from - required: true - in: query - schema: - format: date - example: '2025-06-13' - type: string - - name: till - required: true - in: query - schema: - format: date - example: '2026-06-13' - type: string - responses: - '200': - description: '' - tags: - - Candles - /api/v1/portfolios: - get: - operationId: PortfolioController_findAll - summary: Get all portfolios for current user - parameters: [] - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/PortfolioListEnvelopeDto' - tags: - - Portfolios - security: - - bearer: [] - post: - operationId: PortfolioController_create - summary: Create a new portfolio - parameters: [] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreatePortfolioDto' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/PortfolioEnvelopeDto' - tags: - - Portfolios - security: - - bearer: [] - /api/v1/portfolios/{id}: - get: - operationId: PortfolioController_findOne - summary: Get portfolio details with positions and prices - parameters: - - name: id - required: true - in: path - schema: - type: number - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/PortfolioDetailEnvelopeDto' - tags: - - Portfolios - security: - - bearer: [] - patch: - operationId: PortfolioController_update - summary: Update portfolio - parameters: - - name: id - required: true - in: path - schema: - type: number - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdatePortfolioDto' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/PortfolioEnvelopeDto' - tags: - - Portfolios - security: - - bearer: [] - delete: - operationId: PortfolioController_remove - summary: Delete portfolio - parameters: - - name: id - required: true - in: path - schema: - type: number - responses: - '200': - description: '' - content: - application/json: - schema: - type: object - properties: - data: - type: 'null' - meta: - $ref: '#/components/schemas/PortfolioResponseMetaDto' - required: - - data - - meta - tags: - - Portfolios - security: - - bearer: [] - /api/v1/portfolios/{id}/positions: - post: - operationId: PortfolioController_addPosition - summary: Add position to portfolio - parameters: - - name: id - required: true - in: path - schema: - type: number - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AddPositionDto' - responses: - '201': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/PositionEnvelopeDto' - tags: - - Portfolios - security: - - bearer: [] - /api/v1/portfolios/{id}/positions/{positionId}: - patch: - operationId: PortfolioController_updatePosition - summary: Update position - parameters: - - name: id - required: true - in: path - schema: - type: number - - name: positionId - required: true - in: path - schema: - type: number - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdatePositionDto' - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/PositionEnvelopeDto' - tags: - - Portfolios - security: - - bearer: [] - delete: - operationId: PortfolioController_removePosition - summary: Remove position from portfolio - parameters: - - name: id - required: true - in: path - schema: - type: number - - name: positionId - required: true - in: path - schema: - type: number - responses: - '200': - description: '' - content: - application/json: - schema: - type: object - properties: - data: - type: 'null' - meta: - $ref: '#/components/schemas/PortfolioResponseMetaDto' - required: - - data - - meta - tags: - - Portfolios - security: - - bearer: [] - /api/v1/portfolios/{id}/analytics: - get: - operationId: PortfolioController_getAnalytics - summary: Get portfolio analytics with PnL - parameters: - - name: id - required: true - in: path - schema: - type: number - responses: - '200': - description: '' - content: - application/json: - schema: - $ref: '#/components/schemas/AnalyticsEnvelopeDto' - tags: - - Portfolios - security: - - bearer: [] -info: - title: MoexVibe API - description: '' - version: 1.0.0 - contact: {} -tags: [] -servers: [] -components: - securitySchemes: - bearer: - scheme: bearer - bearerFormat: JWT - type: http - schemas: - RegisterDto: - type: object - properties: - email: - type: string - example: user@example.com - password: - type: string - example: securePass123 - name: - type: string - example: John - required: - - email - - password - AuthUserDto: - type: object - properties: - id: - type: number - email: - type: string - name: - type: string - nullable: true - role: - type: string - required: - - id - - email - - name - - role - AuthTokenDataDto: - type: object - properties: - user: - $ref: '#/components/schemas/AuthUserDto' - accessToken: - type: string - required: - - user - - accessToken - AuthResponseMetaDto: - type: object - properties: - cachedAt: - type: string - nullable: true - fromCache: - type: boolean - required: - - cachedAt - - fromCache - AuthTokenResponseDto: - type: object - properties: - data: - $ref: '#/components/schemas/AuthTokenDataDto' - meta: - $ref: '#/components/schemas/AuthResponseMetaDto' - required: - - data - - meta - LoginDto: - type: object - properties: - email: - type: string - example: user@example.com - password: - type: string - example: securePass123 - required: - - email - - password - LogoutDataDto: - type: object - properties: - message: - type: string - required: - - message - AuthLogoutResponseDto: - type: object - properties: - data: - $ref: '#/components/schemas/LogoutDataDto' - meta: - $ref: '#/components/schemas/AuthResponseMetaDto' - required: - - data - - meta - AuthProfileResponseDto: - type: object - properties: - data: - $ref: '#/components/schemas/AuthUserDto' - meta: - $ref: '#/components/schemas/AuthResponseMetaDto' - required: - - data - - meta - UpdateProfileDto: - type: object - properties: - name: - type: string - example: John Doe - ScreenerItemDto: - type: object - properties: - secid: - type: string - example: SBER - shortName: - type: string - example: Сбербанк - isin: - type: string - example: RU0009029540 - type: - type: string - enum: - - share - - bond - price: - type: number - nullable: true - example: 322.35 - change: - type: number - nullable: true - example: 1.15 - changePercent: - type: number - nullable: true - example: 0.36 - volume: - type: number - example: 1925163 - listLevel: - type: number - example: 1 - capitalization: - type: number - nullable: true - example: 6958336818320 - yieldToMaturity: - type: number - nullable: true - example: 12.71 - duration: - type: number - nullable: true - example: 4.5 - couponValue: - type: number - nullable: true - example: 40.64 - couponPercent: - type: number - nullable: true - example: 8.15 - accruedInt: - type: number - nullable: true - example: 29.48 - matDate: - type: string - nullable: true - example: '2027-02-03' - bondType: - type: string - nullable: true - example: ОФЗ-ПД - required: - - secid - - shortName - - isin - - type - - volume - - listLevel - ScreenerResultDto: - type: object - properties: - items: - type: array - items: - $ref: '#/components/schemas/ScreenerItemDto' - total: - type: number - page: - type: number - pageSize: - type: number - totalPages: - type: number - required: - - items - - total - - page - - pageSize - - totalPages - ScreenerResponseMetaDto: - type: object - properties: - cachedAt: - type: string - nullable: true - fromCache: - type: boolean - required: - - cachedAt - - fromCache - ScreenerResponseDto: - type: object - properties: - data: - $ref: '#/components/schemas/ScreenerResultDto' - meta: - $ref: '#/components/schemas/ScreenerResponseMetaDto' - required: - - data - - meta - PortfolioResponseMetaDto: - type: object - properties: - cachedAt: - type: string - nullable: true - fromCache: - type: boolean - required: - - cachedAt - - fromCache - PortfolioListResponseDto: - type: object - properties: - id: - type: number - name: - type: string - description: - type: string - nullable: true - currency: - type: string - default: RUB - createdAt: - type: string - updatedAt: - type: string - totalValue: - type: number - description: Total market value of all positions - positionCount: - type: number - description: Total number of positions - shareCount: - type: number - description: Number of share positions - bondCount: - type: number - description: Number of bond positions - required: - - id - - name - - currency - - createdAt - - updatedAt - - totalValue - - positionCount - - shareCount - - bondCount - PortfolioListEnvelopeDto: - type: object - properties: - data: - type: array - items: - $ref: '#/components/schemas/PortfolioListResponseDto' - meta: - $ref: '#/components/schemas/PortfolioResponseMetaDto' - required: - - data - - meta - CreatePortfolioDto: - type: object - properties: - name: - type: string - example: Мой портфель - description: - type: string - example: Описание портфеля - currency: - type: string - default: RUB - enum: - - RUB - - USD - - EUR - - CNY - - KZT - - BYN - required: - - name - PortfolioResponseDto: - type: object - properties: - id: - type: number - name: - type: string - description: - type: string - nullable: true - currency: - type: string - default: RUB - createdAt: - type: string - updatedAt: - type: string - required: - - id - - name - - currency - - createdAt - - updatedAt - PortfolioEnvelopeDto: - type: object - properties: - data: - $ref: '#/components/schemas/PortfolioResponseDto' - meta: - $ref: '#/components/schemas/PortfolioResponseMetaDto' - required: - - data - - meta - PositionWithPriceDto: - type: object - properties: - id: - type: number - secid: - type: string - example: SBER - shortName: - type: string - nullable: true - type: - type: string - example: share - enum: - - share - - bond - quantity: - type: number - example: 10 - buyPrice: - type: number - nullable: true - buyDate: - type: string - nullable: true - notes: - type: string - nullable: true - tags: - nullable: true - type: array - items: - type: string - currentPrice: - type: number - nullable: true - totalCost: - type: number - nullable: true - currentValue: - type: number - nullable: true - weightPercent: - type: number - pnl: - type: number - nullable: true - pnlPercent: - type: number - nullable: true - dividendIncome: - type: number - nullable: true - totalReturn: - type: number - nullable: true - totalReturnPercent: - type: number - nullable: true - change: - type: number - nullable: true - changePercent: - type: number - nullable: true - yieldToMaturity: - type: number - nullable: true - duration: - type: number - nullable: true - couponValue: - type: number - nullable: true - couponPercent: - type: number - nullable: true - nextCouponDate: - type: string - nullable: true - matDate: - type: string - nullable: true - accruedInt: - type: number - nullable: true - bid: - type: number - nullable: true - offer: - type: number - nullable: true - couponPeriod: - type: number - nullable: true - bondType: - type: string - nullable: true - offerDate: - type: string - nullable: true - required: - - id - - secid - - type - - quantity - - weightPercent - PortfolioSummaryDto: - type: object - properties: - totalInvested: - type: number - totalValue: - type: number - totalPnl: - type: number - totalPnlPercent: - type: number - nullable: true - totalDividends: - type: number - totalReturn: - type: number - totalReturnPercent: - type: number - nullable: true - positionCount: - type: number - weightedYield: - type: number - nullable: true - required: - - totalInvested - - totalValue - - totalPnl - - totalPnlPercent - - totalDividends - - totalReturn - - totalReturnPercent - - positionCount - - weightedYield - PortfolioDetailResponseDto: - type: object - properties: - id: - type: number - name: - type: string - description: - type: string - nullable: true - currency: - type: string - default: RUB - createdAt: - type: string - updatedAt: - type: string - positions: - type: array - items: - $ref: '#/components/schemas/PositionWithPriceDto' - totalValue: - type: number - analytics: - $ref: '#/components/schemas/PortfolioSummaryDto' - required: - - id - - name - - currency - - createdAt - - updatedAt - - positions - - totalValue - - analytics - PortfolioDetailEnvelopeDto: - type: object - properties: - data: - $ref: '#/components/schemas/PortfolioDetailResponseDto' - meta: - $ref: '#/components/schemas/PortfolioResponseMetaDto' - required: - - data - - meta - UpdatePortfolioDto: - type: object - properties: - name: - type: string - example: Мой портфель - description: - type: string - example: Обновлённое описание - currency: - type: string - default: RUB - enum: - - RUB - - USD - - EUR - - CNY - - KZT - - BYN - AddPositionDto: - type: object - properties: - secid: - type: string - example: SBER - quantity: - type: number - example: 10 - buyPrice: - type: number - example: 250.5 - buyDate: - type: string - example: '2026-06-01' - notes: - type: string - example: Покупка на дип - tags: - type: array - example: - - DIVIDEND - - GROWTH - items: - type: string - enum: - - DIVIDEND - - GROWTH - - DEFENSIVE - - SPECULATIVE - - BOND - - ETF - - GOVERNMENT - - CASH - required: - - secid - - quantity - PositionResponseDto: - type: object - properties: - id: - type: number - secid: - type: string - example: SBER - quantity: - type: number - example: 10 - notes: - type: string - nullable: true - tags: - nullable: true - type: array - items: - type: string - portfolioId: - type: number - createdAt: - type: string - updatedAt: - type: string - required: - - id - - secid - - quantity - - portfolioId - - createdAt - - updatedAt - PositionEnvelopeDto: - type: object - properties: - data: - $ref: '#/components/schemas/PositionResponseDto' - meta: - $ref: '#/components/schemas/PortfolioResponseMetaDto' - required: - - data - - meta - UpdatePositionDto: - type: object - properties: - quantity: - type: number - example: 15 - buyPrice: - type: number - example: 260 - buyDate: - type: string - example: '2026-06-15' - notes: - type: string - example: Докупка - tags: - type: array - example: - - DIVIDEND - items: - type: string - enum: - - DIVIDEND - - GROWTH - - DEFENSIVE - - SPECULATIVE - - BOND - - ETF - - GOVERNMENT - - CASH - AnalyticsResponseDto: - type: object - properties: - positions: - type: array - items: - $ref: '#/components/schemas/PositionWithPriceDto' - summary: - $ref: '#/components/schemas/PortfolioSummaryDto' - required: - - positions - - summary - AnalyticsEnvelopeDto: - type: object - properties: - data: - $ref: '#/components/schemas/AnalyticsResponseDto' - meta: - $ref: '#/components/schemas/PortfolioResponseMetaDto' - required: - - data - - meta diff --git a/docs/requirements.md b/docs/requirements.md deleted file mode 100644 index 4f9de32..0000000 --- a/docs/requirements.md +++ /dev/null @@ -1,193 +0,0 @@ -Ты выступаешь как Senior Solution Architect, Tech Lead и Product Analyst. - -Нужно спроектировать MVP приложения для анализа инвестиций на Московской бирже (MOEX). - -Перед составлением спецификации и плана разработки ты ОБЯЗАН выявить все недостающие требования и задать уточняющие вопросы. Не переходи к проектированию, пока все критические вопросы не будут закрыты. - -## Источники данных - -Использовать только официальные API и документацию MOEX: - -* https://www.moex.com/a2193 -* https://www.moex.com/a7939 -* https://iss.moex.com/iss/reference/ - -Перед проектированием изучи доступные методы API и предложи оптимальную модель интеграции. - ---- - -# Цель MVP - -Разработать веб-приложение для анализа ценных бумаг Московской биржи. - -## MVP должен включать - -### Главная страница - -* глобальный поиск по инструментам -* поиск акций -* поиск облигаций -* отображение результатов поиска -* переход на карточку инструмента - -### Страница акции - -Отображение: - -* тикера -* названия компании -* текущей цены -* капитализации -* дивидендной информации -* доходности -* основных финансовых показателей (если доступны через MOEX) -* исторических данных -* графика цены - -### Страница облигации - -Отображение: - -* ISIN -* тикера -* эмитента -* номинала -* купона -* даты погашения -* текущей цены -* доходности к погашению -* накопленного купонного дохода -* графика цены -* прочих доступных параметров - ---- - -# Технологический стек - -## Frontend - -* React -* TypeScript -* Vite -* TanStack Query -* React Router -* OpenAPI Code Generation -* максимальная типизация -* SSR не требуется - -## Backend - -* NestJS -* TypeScript -* OpenAPI (Swagger) -* архитектура по feature modules -* DTO validation -* централизованная обработка ошибок -* structured logging -* request/response logging middleware -* healthcheck endpoint -* configuration module - -## Документация - -Использовать Docusaurus. - -Документация должна включать: - -* архитектурные решения (ADR) -* sequence diagrams -* component diagrams -* deployment diagrams -* API documentation -* OpenAPI схемы -* описание бизнес-процессов -* onboarding разработчиков - ---- - -# Подход к разработке - -Использовать: - -* Superpowers -* OpenSpec - -Разработка должна начинаться со спецификации. - -Сначала сформировать: - -1. Product Requirements Document (PRD) -2. Domain Model -3. Architecture Decision Records (ADR) -4. OpenAPI Contract -5. Frontend Architecture -6. Backend Architecture -7. План реализации по этапам - ---- - -# Требования к API - -Backend является единственной точкой доступа к MOEX. - -Frontend не должен обращаться к MOEX напрямую. - -Backend должен: - -* агрегировать данные MOEX -* кешировать ответы -* нормализовать модели данных -* предоставлять собственный OpenAPI контракт - -Необходимо предложить стратегию: - -* кеширования -* rate limiting -* обработки ошибок MOEX -* обновления данных - ---- - -# Требования к Frontend - -Использовать OpenAPI codegen для генерации: - -* API clients -* DTO -* React Query hooks (если возможно) - -Не писать API-клиенты вручную без необходимости. - -Предложить оптимальную структуру проекта. - ---- - -# UX/UI - -Использовать современные практики frontend разработки. - -При проектировании интерфейсов: - -* использовать MCP инструменты для анализа и генерации дизайна -* использовать frontend design skills -* подготовить описание экранов -* подготовить user flow -* подготовить wireframes в текстовом виде - ---- - -# Ожидаемый результат - -После уточнения требований сформируй: - -1. список вопросов -2. PRD -3. OpenSpec спецификацию -4. архитектуру системы -5. структуру репозитория -6. OpenAPI проект -7. план реализации по спринтам -8. список рисков -9. roadmap развития после MVP - -Не сокращай ответы. Действуй как архитектор уровня Staff+/Principal Engineer. diff --git a/docs/superpowers/adr/2026-06-14-portfolio-enricher-optimization.md b/docs/superpowers/adr/2026-06-14-portfolio-enricher-optimization.md deleted file mode 100644 index df8b140..0000000 --- a/docs/superpowers/adr/2026-06-14-portfolio-enricher-optimization.md +++ /dev/null @@ -1,65 +0,0 @@ -# ADR: Portfolio Enricher Optimization - -**Date:** 2026-06-14 -**Status:** Implemented -**Deciders:** AI Agent + Human - -## Context - -`GET /api/v1/portfolios/1` выполнялся ~29 секунд для портфеля с 104 позициями. -Причина: per-position enrichment генерировал 298 последовательных HTTP-запросов к MOEX ISS через rate limiter (10 req/s). - -## Decision - -Три оптимизации, реализованные одновременно: - -### 1. Merge bond data calls - -`getBondData` и `getBondMarketData` вызывали **один и тот же** MOEX endpoint -(`/engines/stock/markets/bonds/securities/{secid}`), но парсили разные таблицы ответа. - -Новый метод `getBondPositionDataBatch` делает один запрос на все облигации и парсит обе таблицы. - -**Profit:** 180 → 90 запросов для bonds - -### 2. Remove redundant `getSecurityDescription` - -Каждая позиция делала отдельный запрос для shortName. Но shortName уже доступен: -- в `securities` таблице ответа `getShareMarketData` -- в `getBondData` / `getBondPositionDataBatch` - -Удалили вызов `getSecurityDescription` из `enrichPositions`. - -**Profit:** 104 → 0 запросов - -### 3. Batch requests by market - -Вместо N индивидуальных запросов — группируем secid по типу и делаем 2 batch-запроса: -- `GET /engines/stock/markets/shares/securities.json?securities=SBER,VTBR,...` -- `GET /engines/stock/markets/bonds/securities.json?securities=RU000...,SU262...` - -Новые методы: `getShareMarketDataBatch`, `getBondPositionDataBatch`. - -**Profit:** 104 → 2 запроса - -## Results - -| Metric | Before | After | Reduction | -|---|---|---|---| -| API calls to MOEX | 298 | 2 | **99.3%** | -| Estimated latency (cache cold) | ~29.8s | ~0.3s | **99%** | -| Code in PortfolioService | ~150 lines | ~90 lines | **40%** | - -## Consequences - -- **Cache key format changed**: from `marketdata:portfolio:{secid}` / `bonddata:portfolio:{secid}` / `security:portfolio-name:{secid}` to `batchdata:shares:{sortedSecids}` / `batchdata:bonds:{sortedSecids}`. Old cache entries will naturally expire via TTL. -- **Cache granularity**: batch results are cached as a unit. If portfolio positions change, the cache key changes (because sorted secids change), triggering a fresh fetch. -- **Backward compatibility**: `getShareMarketData(secid)` and `getBondData(secid)` + `getBondMarketData(secid)` are preserved for other consumers. - -## Files Changed - -| File | Change | -|---|---| -| `moex-client.types.ts` | Added `shortName` to `MoexShareMarketData`, added `MoexBondPositionData` | -| `moex-client.service.ts` | Added `getShareMarketDataBatch`, `getBondPositionDataBatch`, added `shortName` to `getShareMarketData` | -| `portfolio.service.ts` | Rewrote `enrichPositions` to batch, removed redundant `getSecurityDescription` calls, removed old per-position enrichment methods | diff --git a/docs/superpowers/plans/2026-06-13-auth-system.md b/docs/superpowers/plans/2026-06-13-auth-system.md deleted file mode 100644 index f6c7a9e..0000000 --- a/docs/superpowers/plans/2026-06-13-auth-system.md +++ /dev/null @@ -1,1186 +0,0 @@ -# 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"}] \ No newline at end of file diff --git a/docs/superpowers/plans/2026-06-13-cicd-implementation.md b/docs/superpowers/plans/2026-06-13-cicd-implementation.md deleted file mode 100644 index c94e210..0000000 --- a/docs/superpowers/plans/2026-06-13-cicd-implementation.md +++ /dev/null @@ -1,110 +0,0 @@ -# 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/plans/2026-06-13-frontend-test-coverage.md b/docs/superpowers/plans/2026-06-13-frontend-test-coverage.md deleted file mode 100644 index e7106bb..0000000 --- a/docs/superpowers/plans/2026-06-13-frontend-test-coverage.md +++ /dev/null @@ -1,1920 +0,0 @@ -# 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/plans/2026-06-13-moex-vibe-implementation.md b/docs/superpowers/plans/2026-06-13-moex-vibe-implementation.md deleted file mode 100644 index 330bf4e..0000000 --- a/docs/superpowers/plans/2026-06-13-moex-vibe-implementation.md +++ /dev/null @@ -1,3456 +0,0 @@ -# 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: . - 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: . - 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/plans/2026-06-14-portfolio-allocation-chart.md b/docs/superpowers/plans/2026-06-14-portfolio-allocation-chart.md deleted file mode 100644 index e1a318e..0000000 --- a/docs/superpowers/plans/2026-06-14-portfolio-allocation-chart.md +++ /dev/null @@ -1,261 +0,0 @@ -# Диаграмма распределения портфеля — План реализации - -> **Для агентов:** Требуется навык `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/plans/2026-06-14-portfolio-analytics.md b/docs/superpowers/plans/2026-06-14-portfolio-analytics.md deleted file mode 100644 index dc7c336..0000000 --- a/docs/superpowers/plans/2026-06-14-portfolio-analytics.md +++ /dev/null @@ -1,1037 +0,0 @@ -# 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/plans/2026-06-14-portfolio-enricher-optimization.md b/docs/superpowers/plans/2026-06-14-portfolio-enricher-optimization.md deleted file mode 100644 index 8944b8f..0000000 --- a/docs/superpowers/plans/2026-06-14-portfolio-enricher-optimization.md +++ /dev/null @@ -1,365 +0,0 @@ -# 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/plans/2026-06-14-portfolio-list-enrichment.md b/docs/superpowers/plans/2026-06-14-portfolio-list-enrichment.md deleted file mode 100644 index 8786623..0000000 --- a/docs/superpowers/plans/2026-06-14-portfolio-list-enrichment.md +++ /dev/null @@ -1,613 +0,0 @@ -# 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/plans/2026-06-14-portfolio-phase1.md b/docs/superpowers/plans/2026-06-14-portfolio-phase1.md deleted file mode 100644 index d97d2e6..0000000 --- a/docs/superpowers/plans/2026-06-14-portfolio-phase1.md +++ /dev/null @@ -1,1615 +0,0 @@ -# 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 }} - /> -
-
- -