commit e4ec1eea0aad55b84df09c0ed5a2f236620180f0 Author: Sergey Krylov Date: Sat Jun 13 18:42:46 2026 +0300 create specification and plan for MVP 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 new file mode 100644 index 0000000..6170454 --- /dev/null +++ b/docs/architecture/adr/ADR-001-backend-single-point-of-access.md @@ -0,0 +1,25 @@ +# 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 new file mode 100644 index 0000000..62175ff --- /dev/null +++ b/docs/architecture/adr/ADR-002-in-memory-cache.md @@ -0,0 +1,33 @@ +# 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 new file mode 100644 index 0000000..483a88f --- /dev/null +++ b/docs/architecture/adr/ADR-003-rate-limiting-strategy.md @@ -0,0 +1,21 @@ +# 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 new file mode 100644 index 0000000..ab31eec --- /dev/null +++ b/docs/architecture/adr/ADR-004-feature-modules.md @@ -0,0 +1,29 @@ +# 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 new file mode 100644 index 0000000..244b0ad --- /dev/null +++ b/docs/architecture/adr/ADR-005-openapi-codegen-frontend.md @@ -0,0 +1,40 @@ +# 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 new file mode 100644 index 0000000..ab2f9f5 --- /dev/null +++ b/docs/architecture/adr/ADR-006-no-cci.md @@ -0,0 +1,20 @@ +# 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 new file mode 100644 index 0000000..6826570 --- /dev/null +++ b/docs/architecture/adr/ADR-007-two-level-caching.md @@ -0,0 +1,27 @@ +# 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/openapi/openapi.yaml b/docs/openapi/openapi.yaml new file mode 100644 index 0000000..aa3c486 --- /dev/null +++ b/docs/openapi/openapi.yaml @@ -0,0 +1,776 @@ +openapi: "3.0.3" +info: + title: MoexVibe API + description: | + API для анализа ценных бумаг Московской биржи. + Backend является единственной точкой доступа к MOEX ISS. + version: "1.0.0" + contact: + name: MoexVibe Team + +servers: + - url: http://localhost:3000/api/v1 + description: Local development + - url: https://api.moexvibe.example.com/api/v1 + description: Production + +paths: + /health: + get: + operationId: healthCheck + tags: [Health] + summary: Проверка состояния сервиса + responses: + "200": + description: Сервис работает + content: + application/json: + schema: + $ref: "#/components/schemas/HealthResponse" + + /securities/search: + get: + operationId: searchSecurities + tags: [Securities] + summary: Поиск по инструментам + parameters: + - name: q + in: query + required: true + schema: + type: string + minLength: 1 + maxLength: 100 + description: Поисковый запрос (тикер, название, ISIN) + - name: type + in: query + required: false + schema: + type: string + enum: [all, share, bond] + default: all + description: Фильтр по типу инструмента + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + description: Максимальное количество результатов + responses: + "200": + description: Результаты поиска + content: + application/json: + schema: + $ref: "#/components/schemas/SearchResponse" + "400": + $ref: "#/components/responses/BadRequest" + + /securities/shares/{secid}: + get: + operationId: getShare + tags: [Shares] + summary: Получить спецификацию акции + parameters: + - name: secid + in: path + required: true + schema: + type: string + description: SECID инструмента (e.g. SBER) + responses: + "200": + description: Спецификация акции + content: + application/json: + schema: + $ref: "#/components/schemas/StockResponse" + "404": + $ref: "#/components/responses/NotFound" + + /securities/shares/{secid}/marketdata: + get: + operationId: getShareMarketData + tags: [Shares] + summary: Получить рыночные данные акции + parameters: + - name: secid + in: path + required: true + schema: + type: string + responses: + "200": + description: Рыночные данные + content: + application/json: + schema: + $ref: "#/components/schemas/StockMarketDataResponse" + "404": + $ref: "#/components/responses/NotFound" + + /securities/shares/{secid}/candles: + get: + operationId: getShareCandles + tags: [Shares] + summary: Получить свечи для графика цены акции + parameters: + - name: secid + in: path + required: true + schema: + type: string + - name: interval + in: query + required: true + schema: + type: string + enum: ["1h", "24h"] + description: Таймфрейм свечей + - name: from + in: query + required: true + schema: + type: string + format: date + description: Начальная дата (ISO 8601) + - name: till + in: query + required: true + schema: + type: string + format: date + description: Конечная дата (ISO 8601) + responses: + "200": + description: Массив свечей + content: + application/json: + schema: + $ref: "#/components/schemas/CandlesResponse" + "400": + $ref: "#/components/responses/BadRequest" + + /securities/shares/{secid}/history: + get: + operationId: getShareHistory + tags: [Shares] + summary: Получить дневную историю торгов акции + parameters: + - name: secid + in: path + required: true + schema: + type: string + - name: from + in: query + required: true + schema: + type: string + format: date + - name: till + in: query + required: true + schema: + type: string + format: date + responses: + "200": + description: Дневная история + content: + application/json: + schema: + $ref: "#/components/schemas/HistoryResponse" + + /securities/shares/{secid}/dividends: + get: + operationId: getShareDividends + tags: [Shares] + summary: Получить историю дивидендных выплат + parameters: + - name: secid + in: path + required: true + schema: + type: string + responses: + "200": + description: Дивиденды + content: + application/json: + schema: + $ref: "#/components/schemas/DividendsResponse" + "404": + $ref: "#/components/responses/NotFound" + + /securities/bonds/{secid}: + get: + operationId: getBond + tags: [Bonds] + summary: Получить спецификацию облигации + parameters: + - name: secid + in: path + required: true + schema: + type: string + responses: + "200": + description: Спецификация облигации + content: + application/json: + schema: + $ref: "#/components/schemas/BondResponse" + "404": + $ref: "#/components/responses/NotFound" + + /securities/bonds/{secid}/marketdata: + get: + operationId: getBondMarketData + tags: [Bonds] + summary: Получить рыночные данные облигации + parameters: + - name: secid + in: path + required: true + schema: + type: string + responses: + "200": + description: Рыночные данные облигации + content: + application/json: + schema: + $ref: "#/components/schemas/BondMarketDataResponse" + "404": + $ref: "#/components/responses/NotFound" + + /securities/bonds/{secid}/candles: + get: + operationId: getBondCandles + tags: [Bonds] + summary: Получить свечи для графика цены облигации + parameters: + - name: secid + in: path + required: true + schema: + type: string + - name: interval + in: query + required: true + schema: + type: string + enum: ["1h", "24h"] + - name: from + in: query + required: true + schema: + type: string + format: date + - name: till + in: query + required: true + schema: + type: string + format: date + responses: + "200": + description: Массив свечей + content: + application/json: + schema: + $ref: "#/components/schemas/CandlesResponse" + + /securities/bonds/{secid}/history: + get: + operationId: getBondHistory + tags: [Bonds] + summary: Получить дневную историю торгов облигации + parameters: + - name: secid + in: path + required: true + schema: + type: string + - name: from + in: query + required: true + schema: + type: string + format: date + - name: till + in: query + required: true + schema: + type: string + format: date + responses: + "200": + description: Дневная история + content: + application/json: + schema: + $ref: "#/components/schemas/BondHistoryResponse" + +components: + schemas: + # ── Health ── + HealthResponse: + type: object + properties: + status: + type: string + example: "ok" + timestamp: + type: string + format: date-time + uptime: + type: number + required: [status, timestamp, uptime] + + # ── Api Response Wrapper ── + ApiResponse: + type: object + properties: + data: {} + meta: + type: object + properties: + cachedAt: + type: string + format: date-time + nullable: true + fromCache: + type: boolean + required: [data] + + # ── Search ── + SearchResult: + type: object + properties: + secid: + type: string + example: "SBER" + isin: + type: string + example: "RU0009029540" + shortName: + type: string + example: "Сбербанк" + type: + type: string + enum: [share, bond] + listLevel: + type: integer + example: 1 + currency: + type: string + nullable: true + example: "RUB" + price: + type: number + nullable: true + example: 322.35 + required: [secid, isin, shortName, type, listLevel] + + SearchResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/SearchResult" + meta: + $ref: "#/components/schemas/ApiResponse/properties/meta" + + # ── Stock ── + StockMarketData: + type: object + properties: + price: + type: number + example: 322.35 + change: + type: number + example: 1.15 + changePercent: + type: number + example: 0.36 + open: + type: number + example: 321.30 + high: + type: number + example: 322.66 + low: + type: number + nullable: true + example: 321.20 + volume: + type: integer + example: 1925163 + value: + type: number + example: 620184479 + issueCapitalization: + type: number + example: 6958336818320 + updatedAt: + type: string + format: date-time + example: "2026-06-13T18:03:11Z" + required: [price, change, changePercent, open, volume, value, updatedAt] + + Stock: + type: object + properties: + secid: + type: string + example: "SBER" + isin: + type: string + example: "RU0009029540" + name: + type: string + example: "Сбербанк России ПАО ао" + shortName: + type: string + example: "Сбербанк" + latName: + type: string + nullable: true + example: "Sberbank" + listLevel: + type: integer + example: 1 + issueSize: + type: integer + example: 21586948000 + faceValue: + type: number + example: 3 + faceUnit: + type: string + example: "RUB" + type: + type: string + example: "common_share" + marketData: + $ref: "#/components/schemas/StockMarketData" + required: [secid, isin, name, shortName, listLevel, type, marketData] + + StockResponse: + type: object + properties: + data: + $ref: "#/components/schemas/Stock" + meta: + $ref: "#/components/schemas/ApiResponse/properties/meta" + + StockMarketDataResponse: + type: object + properties: + data: + $ref: "#/components/schemas/StockMarketData" + meta: + $ref: "#/components/schemas/ApiResponse/properties/meta" + + # ── Bond ── + BondMarketData: + type: object + properties: + price: + type: number + example: 100.45 + description: Цена в % от номинала + yieldToMaturity: + type: number + nullable: true + example: 12.71 + yieldAtWaprice: + type: number + nullable: true + duration: + type: number + nullable: true + accruedInt: + type: number + example: 29.48 + couponValue: + type: number + example: 40.64 + couponPercent: + type: number + nullable: true + example: 8.15 + nextCouponDate: + type: string + format: date + nullable: true + example: "2026-08-05" + open: + type: number + high: + type: number + nullable: true + low: + type: number + nullable: true + volume: + type: integer + updatedAt: + type: string + format: date-time + required: [price, accruedInt, couponValue, volume, updatedAt] + + Bond: + type: object + properties: + secid: + type: string + isin: + type: string + name: + type: string + shortName: + type: string + latName: + type: string + nullable: true + listLevel: + type: integer + issueSize: + type: integer + faceValue: + type: number + faceUnit: + type: string + matDate: + type: string + format: date + example: "2027-02-03" + couponValue: + type: number + example: 40.64 + couponPercent: + type: number + nullable: true + example: 8.15 + couponPeriod: + type: integer + example: 182 + nextCoupon: + type: string + format: date + example: "2026-08-05" + accruedInt: + type: number + example: 29.48 + bondType: + type: string + example: "Фикс с известным купоном" + bondSubType: + type: string + example: "До погашения" + offerDate: + type: string + format: date + nullable: true + buybackDate: + type: string + format: date + nullable: true + marketData: + $ref: "#/components/schemas/BondMarketData" + required: [secid, isin, name, shortName, listLevel, matDate, couponValue, + couponPeriod, accruedInt, bondType, marketData] + + BondResponse: + type: object + properties: + data: + $ref: "#/components/schemas/Bond" + meta: + $ref: "#/components/schemas/ApiResponse/properties/meta" + + BondMarketDataResponse: + type: object + properties: + data: + $ref: "#/components/schemas/BondMarketData" + meta: + $ref: "#/components/schemas/ApiResponse/properties/meta" + + # ── Candle ── + Candle: + type: object + properties: + open: + type: number + example: 280.00 + high: + type: number + example: 280.41 + low: + type: number + example: 271.80 + close: + type: number + example: 272.25 + volume: + type: integer + example: 43086870 + value: + type: number + example: 11853565984.9 + begin: + type: string + format: date-time + example: "2025-01-03T00:00:00Z" + end: + type: string + format: date-time + example: "2025-01-03T23:59:59Z" + required: [open, high, low, close, volume, value, begin, end] + + CandlesResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Candle" + meta: + $ref: "#/components/schemas/ApiResponse/properties/meta" + + # ── History ── + HistoryEntry: + type: object + properties: + date: + type: string + format: date + open: + type: number + high: + type: number + low: + type: number + close: + type: number + volume: + type: integer + value: + type: number + required: [date, open, high, low, close, volume, value] + + HistoryResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/HistoryEntry" + meta: + $ref: "#/components/schemas/ApiResponse/properties/meta" + + BondHistoryEntry: + type: object + properties: + date: + type: string + format: date + closePrice: + type: number + yieldClose: + type: number + nullable: true + duration: + type: number + nullable: true + required: [date, closePrice] + + BondHistoryResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/BondHistoryEntry" + meta: + $ref: "#/components/schemas/ApiResponse/properties/meta" + + # ── Dividend ── + Dividend: + type: object + properties: + registryCloseDate: + type: string + format: date + example: "2025-07-18" + value: + type: number + example: 34.84 + currency: + type: string + example: "RUB" + required: [registryCloseDate, value, currency] + + DividendsResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Dividend" + meta: + $ref: "#/components/schemas/ApiResponse/properties/meta" + + # ── Error ── + ErrorResponse: + type: object + properties: + statusCode: + type: integer + example: 404 + message: + type: string + example: "Instrument SBER_NOT_FOUND not found" + error: + type: string + example: "Not Found" + timestamp: + type: string + format: date-time + path: + type: string + example: "/api/v1/securities/shares/SBER_NOT_FOUND" + required: [statusCode, message, error, timestamp, path] + + responses: + BadRequest: + description: Неверный запрос + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + NotFound: + description: Инструмент не найден + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + +tags: + - name: Health + description: Мониторинг состояния сервиса + - name: Securities + description: Поиск инструментов + - name: Shares + description: Акции + - name: Bonds + description: Облигации diff --git a/docs/requirements.md b/docs/requirements.md new file mode 100644 index 0000000..4f9de32 --- /dev/null +++ b/docs/requirements.md @@ -0,0 +1,193 @@ +Ты выступаешь как 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/plans/2026-06-13-moex-vibe-implementation.md b/docs/superpowers/plans/2026-06-13-moex-vibe-implementation.md new file mode 100644 index 0000000..330bf4e --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-moex-vibe-implementation.md @@ -0,0 +1,3456 @@ +# MoexVibe MVP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a working MVP of MoexVibe — web application for analyzing MOEX stocks and bonds. + +**Architecture:** NestJS monolith serving normalized REST API (OpenAPI 3.0), in-memory cache, rate-limited MOEX ISS client. React SPA with TanStack Query and openapi-typescript codegen. Dev-only single server, production via Docker. + +**Tech Stack:** NestJS, React, Vite, TypeScript, TanStack Query, React Router, @nestjs/cache-manager, node-fetch/axios, lightweight-charts, vitest, Docusaurus + +--- + +## File Structure + +``` +moex-vibe/ +├── package.json # Root npm workspaces +├── tsconfig.base.json +├── .gitignore +├── .prettierrc +├── .eslintrc.cjs +├── apps/ +│ ├── backend/ +│ │ ├── package.json +│ │ ├── tsconfig.json +│ │ ├── nest-cli.json +│ │ └── src/ +│ │ ├── main.ts +│ │ ├── app.module.ts +│ │ ├── config/ +│ │ │ └── configuration.ts +│ │ ├── common/ +│ │ │ ├── dto/ +│ │ │ │ ├── api-response.dto.ts +│ │ │ │ └── pagination.dto.ts +│ │ │ ├── filters/ +│ │ │ │ └── http-exception.filter.ts +│ │ │ ├── interceptors/ +│ │ │ │ ├── logging.interceptor.ts +│ │ │ │ └── transform.interceptor.ts +│ │ │ └── middleware/ +│ │ │ └── request-logging.middleware.ts +│ │ └── modules/ +│ │ ├── moex-client/ +│ │ │ ├── moex-client.module.ts +│ │ │ ├── moex-client.service.ts +│ │ │ ├── moex-client.service.spec.ts +│ │ │ └── moex-client.types.ts +│ │ ├── cache/ +│ │ │ ├── cache.module.ts +│ │ │ └── cache.service.ts +│ │ ├── securities/ +│ │ │ ├── securities.module.ts +│ │ │ ├── securities.controller.ts +│ │ │ ├── securities.controller.spec.ts +│ │ │ ├── securities.service.ts +│ │ │ ├── securities.service.spec.ts +│ │ │ └── dto/ +│ │ │ └── search-query.dto.ts +│ │ ├── shares/ +│ │ │ ├── shares.module.ts +│ │ │ ├── shares.controller.ts +│ │ │ ├── shares.controller.spec.ts +│ │ │ ├── shares.service.ts +│ │ │ ├── shares.service.spec.ts +│ │ │ └── dto/ +│ │ │ ├── share-response.dto.ts +│ │ │ ├── share-marketdata-response.dto.ts +│ │ │ ├── dividends-response.dto.ts +│ │ │ └── history-query.dto.ts +│ │ ├── bonds/ +│ │ │ ├── bonds.module.ts +│ │ │ ├── bonds.controller.ts +│ │ │ ├── bonds.controller.spec.ts +│ │ │ ├── bonds.service.ts +│ │ │ ├── bonds.service.spec.ts +│ │ │ └── dto/ +│ │ │ ├── bond-response.dto.ts +│ │ │ ├── bond-marketdata-response.dto.ts +│ │ │ └── bond-history.dto.ts +│ │ ├── candles/ +│ │ │ ├── candles.module.ts +│ │ │ ├── candles.controller.ts +│ │ │ ├── candles.controller.spec.ts +│ │ │ ├── candles.service.ts +│ │ │ ├── candles.service.spec.ts +│ │ │ └── dto/ +│ │ │ └── candles-query.dto.ts +│ │ └── health/ +│ │ └── health.controller.ts +│ └── frontend/ +│ ├── package.json +│ ├── tsconfig.json +│ ├── tsconfig.node.json +│ ├── vite.config.ts +│ ├── index.html +│ └── src/ +│ ├── main.tsx +│ ├── App.tsx +│ ├── routes.tsx +│ ├── styles.css +│ ├── api/ +│ │ └── (generated by openapi-typescript) +│ ├── hooks/ +│ │ ├── useSearch.ts +│ │ ├── useStock.ts +│ │ ├── useStockCandles.ts +│ │ ├── useStockDividends.ts +│ │ ├── useBond.ts +│ │ └── useBondCandles.ts +│ ├── pages/ +│ │ ├── HomePage.tsx +│ │ ├── StockPage.tsx +│ │ └── BondPage.tsx +│ ├── components/ +│ │ ├── Layout.tsx +│ │ ├── SearchBar.tsx +│ │ ├── SecurityCard.tsx +│ │ ├── PriceChart.tsx +│ │ ├── StockDetails.tsx +│ │ └── BondDetails.tsx +│ ├── types/ +│ │ └── (generated, re-exported) +│ └── vite-env.d.ts +├── docker/ +│ ├── Dockerfile.backend +│ ├── Dockerfile.frontend +│ └── nginx.conf +├── docker-compose.yml +└── docs/ + ├── architecture/adr/ + ├── openapi/openapi.yaml + └── superpowers/specs/2026-06-13-moex-vibe-design.md +``` + +--- + +## SPRINT 1: Backend Foundation + +### Task 1.1: Initialize project root with npm workspaces + +**Files:** +- Create: `package.json` +- Create: `tsconfig.base.json` +- Create: `.gitignore` +- Create: `.prettierrc` + +- [ ] **Create root package.json with workspaces** + +```json +{ + "name": "moex-vibe", + "private": true, + "workspaces": [ + "apps/backend", + "apps/frontend" + ], + "scripts": { + "dev:backend": "npm run start:dev -w apps/backend", + "dev:frontend": "npm run dev -w apps/frontend", + "build:backend": "npm run build -w apps/backend", + "build:frontend": "npm run build -w apps/frontend", + "test:backend": "npm run test -w apps/backend", + "lint": "npm run lint -w apps/backend", + "format": "prettier --write \"**/*.ts\"" + }, + "devDependencies": { + "prettier": "^3.0.0" + } +} +``` + +- [ ] **Create tsconfig.base.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + } +} +``` + +- [ ] **Create .gitignore** + +``` +node_modules/ +dist/ +.env +*.log +.DS_Store +``` + +- [ ] **Create .prettierrc** + +```json +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "semi": true +} +``` + +- [ ] **Run `npm install` at root** to create lockfile and workspace links. + +- [ ] **Commit** + +```bash +git add package.json tsconfig.base.json .gitignore .prettierrc +git commit -m "chore: initialize monorepo with npm workspaces" +``` + +### Task 1.2: Scaffold NestJS backend + +**Files:** +- Create: `apps/backend/package.json` +- Create: `apps/backend/tsconfig.json` +- Create: `apps/backend/nest-cli.json` +- Create: `apps/backend/src/main.ts` +- Create: `apps/backend/src/app.module.ts` + +- [ ] **Create apps/backend/package.json** + +```json +{ + "name": "@moex-vibe/backend", + "version": "0.0.1", + "private": true, + "scripts": { + "build": "nest build", + "start:dev": "nest start --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,test}/**/*.ts\"", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/config": "^3.0.0", + "@nestjs/swagger": "^7.0.0", + "@nestjs/axios": "^3.0.0", + "@nestjs/cache-manager": "^2.0.0", + "cache-manager": "^5.0.0", + "axios": "^1.6.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.0", + "class-validator": "^0.14.0", + "class-transformer": "^0.5.0", + "p-queue": "^7.3.0", + "swagger-ui-express": "^5.0.0" + }, + "devDependencies": { + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@types/express": "^4.17.0", + "@types/node": "^20.0.0", + "typescript": "^5.3.0", + "vitest": "^1.0.0", + "eslint": "^8.0.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@typescript-eslint/parser": "^7.0.0" + } +} +``` + +- [ ] **Create apps/backend/tsconfig.json** + +```json +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "outDir": "./dist", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "baseUrl": "./", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"] +} +``` + +- [ ] **Create apps/backend/nest-cli.json** + +```json +{ + "collection": "@nestjs/schematics", + "sourceRoot": "src" +} +``` + +- [ ] **Create apps/backend/src/main.ts** + +```typescript +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import { TransformInterceptor } from './common/interceptors/transform.interceptor'; +import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware'; +import { ValidationPipe } from '@nestjs/common'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + app.setGlobalPrefix('api/v1'); + + app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalInterceptors(new TransformInterceptor()); + app.use(new RequestLoggingMiddleware().use); + + app.enableCors(); + + const config = new DocumentBuilder() + .setTitle('MoexVibe API') + .setVersion('1.0.0') + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); + + const port = process.env.PORT || 3000; + await app.listen(port); + console.log(`MoexVibe API running on http://localhost:${port}/api/v1`); + console.log(`Swagger docs: http://localhost:${port}/api/docs`); +} +bootstrap(); +``` + +- [ ] **Create apps/backend/src/app.module.ts** + +```typescript +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { CacheModule } from './modules/cache/cache.module'; +import { MoexClientModule } from './modules/moex-client/moex-client.module'; +import { HealthModule } from './modules/health/health.module'; +import { SecuritiesModule } from './modules/securities/securities.module'; +import { SharesModule } from './modules/shares/shares.module'; +import { BondsModule } from './modules/bonds/bonds.module'; +import { CandlesModule } from './modules/candles/candles.module'; +import configuration from './config/configuration'; + +@Module({ + imports: [ + ConfigModule.forRoot({ load: [configuration], isGlobal: true }), + CacheModule, + MoexClientModule, + HealthModule, + SecuritiesModule, + SharesModule, + BondsModule, + CandlesModule, + ], +}) +export class AppModule {} +``` + +- [ ] **Commit** + +```bash +git add apps/backend/ +git commit -m "feat: scaffold NestJS backend with Swagger, validation, CORS" +``` + +### Task 1.3: Configuration module + +**Files:** +- Create: `apps/backend/src/config/configuration.ts` + +- [ ] **Create configuration.ts** + +```typescript +import { registerAs } from '@nestjs/config'; + +export default registerAs('app', () => ({ + port: parseInt(process.env.PORT || '3000', 10), + moex: { + baseUrl: process.env.MOEX_BASE_URL || 'https://iss.moex.com/iss', + rateLimit: parseInt(process.env.MOEX_RATE_LIMIT || '10', 10), + circuitBreakerThreshold: parseInt( + process.env.MOEX_CIRCUIT_BREAKER_THRESHOLD || '5', + 10, + ), + circuitBreakerResetSeconds: parseInt( + process.env.MOEX_CIRCUIT_BREAKER_RESET_SECONDS || '30', + 10, + ), + }, + cache: { + marketDataTtl: parseInt(process.env.CACHE_MARKET_DATA_TTL || '900', 10), + historyTtl: parseInt(process.env.CACHE_HISTORY_TTL || '3600', 10), + candlesTtl: parseInt(process.env.CACHE_CANDLES_TTL || '3600', 10), + securityTtl: parseInt(process.env.CACHE_SECURITY_TTL || '86400', 10), + searchTtl: parseInt(process.env.CACHE_SEARCH_TTL || '3600', 10), + dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10), + }, +})); +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/config/ +git commit -m "feat: add configuration module with env vars" +``` + +### Task 1.4: Common DTOs, filters, interceptors, middleware + +**Files:** +- Create: `apps/backend/src/common/dto/api-response.dto.ts` +- Create: `apps/backend/src/common/dto/pagination.dto.ts` +- Create: `apps/backend/src/common/filters/http-exception.filter.ts` +- Create: `apps/backend/src/common/interceptors/transform.interceptor.ts` +- Create: `apps/backend/src/common/middleware/request-logging.middleware.ts` + +- [ ] **Create common/dto/api-response.dto.ts** + +```typescript +import { ApiProperty } from '@nestjs/swagger'; + +export class ApiResponseMeta { + @ApiProperty({ nullable: true }) + cachedAt: string | null; + + @ApiProperty() + fromCache: boolean; +} + +export class ApiResponse { + data: T; + meta: ApiResponseMeta; + + constructor(data: T, fromCache = false, cachedAt: string | null = null) { + this.data = data; + this.meta = { cachedAt, fromCache }; + } +} +``` + +- [ ] **Create common/dto/pagination.dto.ts** + +```typescript +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsOptional, IsInt, Min, Max } from 'class-validator'; + +export class PaginationDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} +``` + +- [ ] **Create common/filters/http-exception.filter.ts** + +```typescript +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { Response } from 'express'; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let status = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Internal server error'; + let error = 'Internal Server Error'; + + if (exception instanceof HttpException) { + status = exception.getStatus(); + const res = exception.getResponse(); + if (typeof res === 'string') { + message = res; + error = exception.name; + } else if (typeof res === 'object') { + const r = res as Record; + message = (r.message as string) || message; + error = (r.error as string) || exception.name; + } + } else if (exception instanceof Error) { + message = exception.message; + } + + response.status(status).json({ + statusCode: status, + message, + error, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} +``` + +- [ ] **Create common/interceptors/transform.interceptor.ts** + +```typescript +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ApiResponse } from '../dto/api-response.dto'; + +@Injectable() +export class TransformInterceptor + implements NestInterceptor> +{ + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { + return next.handle().pipe( + map((data) => { + if (data instanceof ApiResponse) return data; + return new ApiResponse(data, false, null); + }), + ); + } +} +``` + +- [ ] **Create common/middleware/request-logging.middleware.ts** + +```typescript +import { Injectable, NestMiddleware, Logger } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; + +@Injectable() +export class RequestLoggingMiddleware implements NestMiddleware { + private logger = new Logger('HTTP'); + + use(req: Request, res: Response, next: NextFunction): void { + const { method, originalUrl } = req; + const start = Date.now(); + + res.on('finish', () => { + const { statusCode } = res; + const duration = Date.now() - start; + this.logger.log(`${method} ${originalUrl} ${statusCode} ${duration}ms`); + }); + + next(); + } +} +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/common/ +git commit -m "feat: add common DTOs, exception filter, transform interceptor, logging middleware" +``` + +### Task 1.5: Health check endpoint + +**Files:** +- Create: `apps/backend/src/modules/health/health.controller.ts` + +- [ ] **Create health.controller.ts** + +```typescript +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; + +@ApiTags('Health') +@Controller('health') +export class HealthController { + @Get() + @ApiOperation({ summary: 'Проверка состояния сервиса' }) + check() { + return { + status: 'ok', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + }; + } +} +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/modules/health/ +git commit -m "feat: add health check endpoint" +``` + +### Task 1.6: MoexClient module with rate limiting + +**Files:** +- Create: `apps/backend/src/modules/moex-client/moex-client.module.ts` +- Create: `apps/backend/src/modules/moex-client/moex-client.service.ts` +- Create: `apps/backend/src/modules/moex-client/moex-client.types.ts` +- Create: `apps/backend/src/modules/moex-client/moex-client.service.spec.ts` + +- [ ] **Create moex-client.types.ts** + +```typescript +export interface MoexSecurityDescription { + secid: string; + isin: string; + name: string; + shortName: string; + latName: string | null; + listLevel: number; + issueSize: number; + faceValue: number; + faceUnit: string; + issueDate: string; + typeName: string; + group: string; + type: string; + isQualifiedInvestors: boolean; + morningSession: boolean; + eveningSession: boolean; +} + +export interface MoexShareMarketData { + secid: string; + boardid: string; + bid: number | null; + offer: number | null; + open: number | null; + low: number | null; + high: number | null; + last: number | null; + lastChange: number | null; + lastChangePrcnt: number | null; + volume: number; + value: number; + waprice: number | null; + numtrades: number; + issueCapitalization: number | null; + tradingStatus: string; + updateTime: string; +} + +export interface MoexBondData { + secid: string; + boardid: string; + shortName: string; + prevWaprice: number | null; + yieldAtPrevWaprice: number | null; + couponValue: number | null; + nextCoupon: string | null; + accruedInt: number | null; + prevPrice: number | null; + lotSize: number; + faceValue: number; + matDate: string; + couponPeriod: number; + issueSize: number; + isin: string; + couponPercent: number | null; + offerDate: string | null; + buybackDate: string | null; + bondType: string; + bondSubType: string; + listLevel: number; +} + +export interface MoexBondMarketData { + secid: string; + bid: number | null; + offer: number | null; + open: number | null; + low: number | null; + high: number | null; + last: number | null; + yield: number | null; + waprice: number | null; + yieldAtWaprice: number | null; + duration: number | null; + volume: number; + value: number; + numtrades: number; + tradingStatus: string; + updateTime: string; +} + +export interface MoexDividend { + secid: string; + isin: string; + registryCloseDate: string; + value: number; + currencyId: string; +} + +export interface MoexCandle { + open: number; + close: number; + high: number; + low: number; + value: number; + volume: number; + begin: string; + end: string; +} + +export interface MoexHistoryEntry { + tradeDate: string; + open: number | null; + low: number | null; + high: number | null; + close: number | null; + waprice: number | null; + volume: number; + value: number; + numtrades: number; +} + +export interface MoexBondHistoryEntry { + tradeDate: string; + close: number | null; + legalClosePrice: number | null; + waprice: number | null; + yieldClose: number | null; + duration: number | null; + accruedInt: number | null; +} + +export interface MoexBoard { + secid: string; + boardid: string; + title: string; + isPrimary: boolean; + isTraded: boolean; + currencyid: string; +} +``` + +- [ ] **Create moex-client.service.ts** + +```typescript +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios, { AxiosInstance } from 'axios'; +import PQueue from 'p-queue'; +import { + MoexSecurityDescription, + MoexShareMarketData, + MoexBondData, + MoexBondMarketData, + MoexDividend, + MoexCandle, + MoexHistoryEntry, + MoexBondHistoryEntry, +} from './moex-client.types'; + +@Injectable() +export class MoexClientService { + private readonly logger = new Logger(MoexClientService.name); + private readonly client: AxiosInstance; + private readonly queue: PQueue; + private circuitOpen = false; + private circuitErrorCount = 0; + private readonly threshold: number; + private readonly resetMs: number; + + constructor(private configService: ConfigService) { + const baseUrl = this.configService.get('app.moex.baseUrl')!; + this.threshold = this.configService.get( + 'app.moex.circuitBreakerThreshold', + 5, + ); + this.resetMs = + this.configService.get( + 'app.moex.circuitBreakerResetSeconds', + 30, + ) * 1000; + const rateLimit = this.configService.get( + 'app.moex.rateLimit', + 10, + ); + + this.client = axios.create({ + baseURL: baseUrl, + timeout: 10000, + paramsSerializer: { indexes: null }, + }); + + this.queue = new PQueue({ + interval: 1000, + intervalCap: rateLimit, + }); + } + + private async request(path: string, params?: Record): Promise { + if (this.circuitOpen) { + throw new Error('Circuit breaker is open — MOEX requests paused'); + } + + return this.queue.add(async () => { + try { + const response = await this.client.get(path, { + params: { ...params, 'iss.meta': 'off' }, + }); + this.circuitErrorCount = 0; + return response.data as T; + } catch (error) { + this.circuitErrorCount++; + if (this.circuitErrorCount >= this.threshold) { + this.circuitOpen = true; + this.logger.warn(`Circuit breaker opened after ${this.threshold} errors`); + setTimeout(() => { + this.circuitOpen = false; + this.circuitErrorCount = 0; + this.logger.log('Circuit breaker reset'); + }, this.resetMs); + } + throw error; + } + }) as Promise; + } + + private extractTable(data: Record, name: string): Record[] { + const table = data[name] as Record | undefined; + if (!table || !table.columns || !table.data) return []; + const columns = table.columns as string[]; + const rows = table.data as unknown[][]; + return rows.map((row) => { + const obj: Record = {}; + columns.forEach((col, i) => { + obj[col] = row[i]; + }); + return obj; + }); + } + + async searchSecurities(query: string): Promise { + const data = await this.request>('/securities', { + q: query, + }); + return this.extractTable(data, 'securities').map((s) => ({ + secid: s.secid as string, + isin: s.isin as string, + name: s.name as string, + shortName: s.shortName as string, + latName: (s.latName as string) || null, + listLevel: parseInt(s.listLevel as string, 10) || 0, + issueSize: parseInt(s.issuesize as string, 10) || 0, + faceValue: parseFloat(s.facevalue as string) || 0, + faceUnit: (s.faceunit as string) || '', + issueDate: (s.issuedate as string) || '', + typeName: (s.typename as string) || '', + group: (s.group as string) || '', + type: (s.type as string) || '', + isQualifiedInvestors: (s.isqualifiedinvestors as string) === '1', + morningSession: (s.morningsession as string) === '1', + eveningSession: (s.eveningsession as string) === '1', + })); + } + + async getSecurityDescription(secid: string): Promise { + const data = await this.request>(`/securities/${secid}`); + const rows = this.extractTable(data, 'description'); + if (rows.length === 0) return null; + const map = new Map(rows.map((r) => [r.name, r.value])); + return { + secid, + isin: (map.get('ISIN') as string) || '', + name: (map.get('NAME') as string) || '', + shortName: (map.get('SHORTNAME') as string) || '', + latName: (map.get('LATNAME') as string) || null, + listLevel: parseInt((map.get('LISTLEVEL') as string) || '0', 10), + issueSize: parseInt((map.get('ISSUESIZE') as string) || '0', 10), + faceValue: parseFloat((map.get('FACEVALUE') as string) || '0'), + faceUnit: (map.get('FACEUNIT') as string) || '', + issueDate: (map.get('ISSUEDATE') as string) || '', + typeName: (map.get('TYPENAME') as string) || '', + group: (map.get('GROUP') as string) || '', + type: (map.get('TYPE') as string) || '', + isQualifiedInvestors: (map.get('ISQUALIFIEDINVESTORS') as string) === '1', + morningSession: (map.get('MORNINGSESSION') as string) === '1', + eveningSession: (map.get('EVENINGSESSION') as string) === '1', + }; + } + + async getShareMarketData(secid: string, boardId = 'TQBR'): Promise { + const data = await this.request>( + `/engines/stock/markets/shares/securities/${secid}`, + { boards: boardId }, + ); + const rows = this.extractTable(data, 'securities'); + const share = rows.find((r) => r.BOARDID === boardId); + if (!share) return null; + + const mktRows = this.extractTable(data, 'marketdata'); + const mkt = mktRows.find((r) => r.BOARDID === boardId); + + return { + secid, + boardid: boardId, + bid: mkt ? parseFloat((mkt.BID as string) || '') : null, + offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null, + open: mkt ? parseFloat((mkt.OPEN as string) || '') : null, + low: mkt ? parseFloat((mkt.LOW as string) || '') : null, + high: mkt ? parseFloat((mkt.HIGH as string) || '') : null, + last: mkt ? parseFloat((mkt.LAST as string) || '') : parseFloat((share.PREVPRICE as string) || ''), + lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null, + lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null, + volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0, + value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0, + waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null, + numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0, + issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null, + tradingStatus: (mkt?.TRADINGSTATUS as string) || '', + updateTime: (mkt?.UPDATETIME as string) || '', + }; + } + + async getBondData(secid: string, boardId = 'TQCB'): Promise { + const data = await this.request>( + `/engines/stock/markets/bonds/securities/${secid}`, + { boards: boardId }, + ); + const rows = this.extractTable(data, 'securities'); + const bond = rows.find((r) => r.BOARDID === boardId); + if (!bond) return null; + + return { + secid, + boardid: boardId, + shortName: (bond.SHORTNAME as string) || '', + prevWaprice: parseFloat((bond.PREVWAPRICE as string) || '') || null, + yieldAtPrevWaprice: parseFloat((bond.YIELDATPREVWAPRICE as string) || '') || null, + couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null, + nextCoupon: (bond.NEXTCOUPON as string) || null, + accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null, + prevPrice: parseFloat((bond.PREVPRICE as string) || '') || null, + lotSize: parseInt((bond.LOTSIZE as string) || '1', 10), + faceValue: parseFloat((bond.FACEVALUE as string) || '1000'), + matDate: (bond.MATDATE as string) || '', + couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10), + issueSize: parseInt((bond.ISSUESIZE as string) || '0', 10), + isin: (bond.ISIN as string) || '', + couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null, + offerDate: (bond.OFFERDATE as string) || null, + buybackDate: (bond.BUYBACKDATE as string) || null, + bondType: (bond.BONDTYPE as string) || '', + bondSubType: (bond.BONDSUBTYPE as string) || '', + listLevel: parseInt((bond.LISTLEVEL as string) || '0', 10), + }; + } + + async getBondMarketData(secid: string, boardId = 'TQCB'): Promise { + const data = await this.request>( + `/engines/stock/markets/bonds/securities/${secid}`, + { boards: boardId }, + ); + const mktRows = this.extractTable(data, 'marketdata'); + const mkt = mktRows.find((r) => r.SECID === secid); + if (!mkt) return null; + + return { + secid, + bid: mkt.BID != null ? parseFloat(mkt.BID as string) : null, + offer: mkt.OFFER != null ? parseFloat(mkt.OFFER as string) : null, + open: mkt.OPEN != null ? parseFloat(mkt.OPEN as string) : null, + low: mkt.LOW != null ? parseFloat(mkt.LOW as string) : null, + high: mkt.HIGH != null ? parseFloat(mkt.HIGH as string) : null, + last: mkt.LAST != null ? parseFloat(mkt.LAST as string) : null, + yield: mkt.YIELD != null ? parseFloat(mkt.YIELD as string) : null, + waprice: mkt.WAPRICE != null ? parseFloat(mkt.WAPRICE as string) : null, + yieldAtWaprice: mkt.YIELDATWAPRICE != null ? parseFloat(mkt.YIELDATWAPRICE as string) : null, + duration: mkt.DURATION != null ? parseFloat(mkt.DURATION as string) : null, + volume: parseInt((mkt.VOLTODAY as string) || '0', 10), + value: parseFloat((mkt.VALTODAY as string) || '0'), + numtrades: parseInt((mkt.NUMTRADES as string) || '0', 10), + tradingStatus: (mkt.TRADINGSTATUS as string) || '', + updateTime: (mkt.UPDATETIME as string) || '', + }; + } + + async getDividends(secid: string): Promise { + const data = await this.request>(`/securities/${secid}/dividends`); + return this.extractTable(data, 'dividends').map((d) => ({ + secid: d.secid as string, + isin: d.isin as string, + registryCloseDate: d.registryclosedate as string, + value: parseFloat(d.value as string), + currencyId: (d.currencyid as string) || 'RUB', + })); + } + + async getCandles( + engine: 'stock', + market: 'shares' | 'bonds', + secid: string, + interval: 1 | 10 | 60 | 24, + from: string, + till: string, + ): Promise { + const intervalMap: Record = { + 1: '1min', + 10: '10min', + 60: '1hour', + 24: '24hours', + }; + const data = await this.request>( + `/engines/${engine}/markets/${market}/securities/${secid}/candles`, + { + interval: String(interval), + from, + till, + }, + ); + return this.extractTable(data, 'candles').map((c) => ({ + open: parseFloat(c.open as string), + close: parseFloat(c.close as string), + high: parseFloat(c.high as string), + low: parseFloat(c.low as string), + value: parseFloat(c.value as string), + volume: parseInt(c.volume as string, 10), + begin: c.begin as string, + end: c.end as string, + })); + } + + async getHistory( + secid: string, + from: string, + till: string, + ): Promise { + const data = await this.request>( + `/engines/stock/markets/shares/securities/${secid}`, + { from, till }, + ); + const tableName = Object.keys(data).find( + (k) => k.startsWith('history') && !k.includes('cursor'), + ); + if (!tableName) return []; + return this.extractTable(data, tableName).map((h) => ({ + tradeDate: h.TRADEDATE as string, + open: h.OPEN != null ? parseFloat(h.OPEN as string) : null, + low: h.LOW != null ? parseFloat(h.LOW as string) : null, + high: h.HIGH != null ? parseFloat(h.HIGH as string) : null, + close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null, + waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null, + volume: parseInt((h.VOLUME as string) || '0', 10), + value: parseFloat((h.VALUE as string) || '0'), + numtrades: parseInt((h.NUMTRADES as string) || '0', 10), + })); + } + + async getBondHistory( + secid: string, + from: string, + till: string, + ): Promise { + const data = await this.request>( + `/engines/stock/markets/bonds/securities/${secid}`, + { from, till }, + ); + const tableName = Object.keys(data).find( + (k) => k.startsWith('history') && !k.includes('cursor'), + ); + if (!tableName) return []; + return this.extractTable(data, tableName).map((h) => ({ + tradeDate: h.TRADEDATE as string, + close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null, + legalClosePrice: + h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null, + waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null, + yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null, + duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null, + accruedInt: h.ACCINT != null ? parseFloat(h.ACCINT as string) : null, + })); + } +} +``` + +- [ ] **Create moex-client.module.ts** + +```typescript +import { Global, Module } from '@nestjs/common'; +import { MoexClientService } from './moex-client.service'; + +@Global() +@Module({ + providers: [MoexClientService], + exports: [MoexClientService], +}) +export class MoexClientModule {} +``` + +- [ ] **Create moex-client.service.spec.ts** + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { MoexClientService } from './moex-client.service'; +import configuration from '../../config/configuration'; + +describe('MoexClientService', () => { + let service: MoexClientService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ load: [configuration] }), + ], + providers: [MoexClientService], + }).compile(); + + service = module.get(MoexClientService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('searchSecurities', () => { + it('should return results for SBER query', async () => { + const results = await service.searchSecurities('SBER'); + expect(Array.isArray(results)).toBe(true); + if (results.length > 0) { + expect(results[0].secid).toBeDefined(); + } + }, 15000); + }); + + describe('getShareMarketData', () => { + it('should return market data for SBER', async () => { + const data = await service.getShareMarketData('SBER'); + expect(data).toBeDefined(); + expect(data!.secid).toBe('SBER'); + }, 15000); + }); +}); +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/modules/moex-client/ +git commit -m "feat: add MoexClient with rate-limited HTTP client, circuit breaker, and MOEX ISS data methods" +``` + +### Task 1.7: Cache module + +**Files:** +- Create: `apps/backend/src/modules/cache/cache.module.ts` +- Create: `apps/backend/src/modules/cache/cache.service.ts` + +- [ ] **Create cache.module.ts** + +```typescript +import { Module, CacheModule as NestCacheModule } from '@nestjs/cache-manager'; +import { CacheService } from './cache.service'; + +@Module({ + imports: [ + NestCacheModule.register({ + ttl: 900, + max: 1000, + isGlobal: true, + }), + ], + providers: [CacheService], + exports: [CacheService], +}) +export class CacheModule {} +``` + +- [ ] **Create cache.service.ts** + +```typescript +import { Injectable, Inject } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Cache } from 'cache-manager'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class CacheService { + constructor( + @Inject(CACHE_MANAGER) private cacheManager: Cache, + private configService: ConfigService, + ) {} + + async get(key: string): Promise { + return this.cacheManager.get(key); + } + + async set(key: string, value: unknown, ttl?: number): Promise { + await this.cacheManager.set(key, value, ttl); + } + + private buildKey(...parts: string[]): string { + return parts.join(':'); + } + + async getOrFetch( + keyPrefix: string, + keyParts: string[], + fetchFn: () => Promise, + ttlConfigKey: string, + ): Promise<{ data: T; fromCache: boolean; cachedAt: string | null }> { + const key = this.buildKey(keyPrefix, ...keyParts); + const ttl = this.configService.get(`app.cache.${ttlConfigKey}`, 900); + + const cached = await this.get(key); + if (cached !== undefined) { + return { data: cached, fromCache: true, cachedAt: null }; + } + + const data = await fetchFn(); + await this.set(key, data, ttl); + + return { data, fromCache: false, cachedAt: new Date().toISOString() }; + } +} +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/modules/cache/ +git commit -m "feat: add cache module with getOrFetch pattern and configurable TTL" +``` + +--- + +## SPRINT 2: Securities API + +### Task 2.1: Securities search module + +**Files:** +- Create: `apps/backend/src/modules/securities/dto/search-query.dto.ts` +- Create: `apps/backend/src/modules/securities/securities.controller.ts` +- Create: `apps/backend/src/modules/securities/securities.service.ts` +- Create: `apps/backend/src/modules/securities/securities.module.ts` +- Create: `apps/backend/src/modules/securities/securities.controller.spec.ts` +- Create: `apps/backend/src/modules/securities/securities.service.spec.ts` + +- [ ] **Create search-query.dto.ts** + +```typescript +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsEnum, MinLength, MaxLength } from 'class-validator'; + +export enum SecurityType { + ALL = 'all', + SHARE = 'share', + BOND = 'bond', +} + +export class SearchQueryDto { + @ApiProperty({ description: 'Поисковый запрос (тикер, название, ISIN)' }) + @IsString() + @MinLength(1) + @MaxLength(100) + q: string; + + @ApiPropertyOptional({ enum: SecurityType, default: SecurityType.ALL }) + @IsOptional() + @IsEnum(SecurityType) + type?: SecurityType = SecurityType.ALL; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + limit?: number = 20; +} +``` + +- [ ] **Create search result DTO inline or reuse** — add to controller response via transform interceptor. + +- [ ] **Create securities.service.ts** + +```typescript +import { Injectable } from '@nestjs/common'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import { SecurityType } from './dto/search-query.dto'; + +export interface SearchResultItem { + secid: string; + isin: string; + shortName: string; + type: 'share' | 'bond'; + listLevel: number; + currency: string | null; + price: number | null; +} + +@Injectable() +export class SecuritiesService { + constructor( + private readonly moexClient: MoexClientService, + private readonly cache: CacheService, + ) {} + + async search(query: string, type: SecurityType, limit: number): Promise { + const { data } = await this.cache.getOrFetch( + 'search', + [query.toLowerCase()], + async () => { + const results = await this.moexClient.searchSecurities(query); + return results.map((s) => ({ + secid: s.secid, + isin: s.isin, + shortName: s.shortName, + type: (s.group === 'stock_shares' || s.type === 'common_share' || s.type === 'preferred_share') + ? 'share' as const + : (s.group === 'stock_bonds' ? 'bond' as const : null), + listLevel: s.listLevel, + currency: s.faceUnit === 'SUR' ? 'RUB' : s.faceUnit || null, + price: null, + })).filter((r): r is SearchResultItem => r.type !== null); + }, + 'searchTtl', + ); + + let filtered = data; + if (type === SecurityType.SHARE) { + filtered = data.filter((r) => r.type === 'share'); + } else if (type === SecurityType.BOND) { + filtered = data.filter((r) => r.type === 'bond'); + } + + return filtered.slice(0, limit); + } + + async getShareBrief(secid: string): Promise { + try { + const desc = await this.moexClient.getSecurityDescription(secid); + if (!desc) return null; + return { + secid: desc.secid, + isin: desc.isin, + shortName: desc.shortName, + type: 'share', + listLevel: desc.listLevel, + currency: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit, + price: null, + }; + } catch { + return null; + } + } +} +``` + +- [ ] **Create securities.controller.ts** + +```typescript +import { Controller, Get, Query, ValidationPipe } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { SecuritiesService } from './securities.service'; +import { SearchQueryDto, SecurityType } from './dto/search-query.dto'; + +@ApiTags('Securities') +@Controller('securities') +export class SecuritiesController { + constructor(private readonly securitiesService: SecuritiesService) {} + + @Get('search') + @ApiOperation({ summary: 'Поиск по инструментам' }) + async search(@Query(ValidationPipe) query: SearchQueryDto) { + const results = await this.securitiesService.search( + query.q, + query.type || SecurityType.ALL, + query.limit || 20, + ); + return { data: results, meta: { cachedAt: null, fromCache: false } }; + } +} +``` + +- [ ] **Create securities.module.ts** + +```typescript +import { Module } from '@nestjs/common'; +import { SecuritiesController } from './securities.controller'; +import { SecuritiesService } from './securities.service'; + +@Module({ + controllers: [SecuritiesController], + providers: [SecuritiesService], + exports: [SecuritiesService], +}) +export class SecuritiesModule {} +``` + +- [ ] **Create securities.service.spec.ts** + +```typescript +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { SecuritiesService } from './securities.service'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import configuration from '../../config/configuration'; +import { SecurityType } from './dto/search-query.dto'; + +describe('SecuritiesService', () => { + let service: SecuritiesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ load: [configuration] })], + providers: [ + SecuritiesService, + MoexClientService, + { + provide: 'CACHE_MANAGER', + useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() }, + }, + CacheService, + ], + }).compile(); + + service = module.get(SecuritiesService); + }); + + it('should return search results for SBER', async () => { + const results = await service.search('SBER', SecurityType.ALL, 5); + expect(results.length).toBeGreaterThan(0); + expect(results[0].secid).toBeDefined(); + }, 15000); +}); +``` + +- [ ] **Create securities.controller.spec.ts** — similar pattern with mocked service. + +- [ ] **Commit** + +```bash +git add apps/backend/src/modules/securities/ +git commit -m "feat: add securities search endpoint" +``` + +### Task 2.2: Shares module — spec + marketdata + +**Files:** +- Create: `apps/backend/src/modules/shares/dto/share-response.dto.ts` +- Create: `apps/backend/src/modules/shares/dto/share-marketdata-response.dto.ts` +- Create: `apps/backend/src/modules/shares/shares.service.ts` +- Create: `apps/backend/src/modules/shares/shares.controller.ts` +- Create: `apps/backend/src/modules/shares/shares.module.ts` + +- [ ] **Create share-response.dto.ts** + +```typescript +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class StockMarketDataDto { + @ApiProperty({ example: 322.35 }) + price: number; + + @ApiProperty({ example: 1.15 }) + change: number; + + @ApiProperty({ example: 0.36 }) + changePercent: number; + + @ApiProperty({ example: 321.3 }) + open: number; + + @ApiPropertyOptional({ example: 322.66 }) + high: number | null; + + @ApiPropertyOptional({ example: 321.2 }) + low: number | null; + + @ApiProperty({ example: 1925163 }) + volume: number; + + @ApiProperty({ example: 620184479 }) + value: number; + + @ApiPropertyOptional({ example: 6958336818320 }) + issueCapitalization: number | null; + + @ApiProperty() + updatedAt: string; +} + +export class ShareResponseDto { + @ApiProperty({ example: 'SBER' }) + secid: string; + + @ApiProperty({ example: 'RU0009029540' }) + isin: string; + + @ApiProperty({ example: 'Сбербанк России ПАО ао' }) + name: string; + + @ApiProperty({ example: 'Сбербанк' }) + shortName: string; + + @ApiPropertyOptional() + latName: string | null; + + @ApiProperty({ example: 1 }) + listLevel: number; + + @ApiProperty({ example: 21586948000 }) + issueSize: number; + + @ApiProperty({ example: 3 }) + faceValue: number; + + @ApiProperty({ example: 'RUB' }) + faceUnit: string; + + @ApiProperty({ example: 'common_share' }) + type: string; + + @ApiProperty() + marketData: StockMarketDataDto; +} +``` + +- [ ] **Create share-marketdata-response.dto.ts** + +```typescript +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { StockMarketDataDto } from './share-response.dto'; + +export class ShareMarketDataResponseDto extends StockMarketDataDto {} +``` + +- [ ] **Create shares.service.ts** + +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; + +@Injectable() +export class SharesService { + constructor( + private readonly moexClient: MoexClientService, + private readonly cache: CacheService, + ) {} + + async getShare(secid: string) { + const desc = await this.moexClient.getSecurityDescription(secid); + if (!desc || !(desc.group === 'stock_shares' || desc.type === 'common_share' || desc.type === 'preferred_share')) { + throw new NotFoundException(`Share ${secid} not found`); + } + + const { data: marketData } = await this.cache.getOrFetch( + 'marketdata', + ['shares', secid], + () => this.moexClient.getShareMarketData(secid), + 'marketDataTtl', + ); + + const price = marketData?.last ?? (marketData ? null : 0); + const prevPrice = 0; // not stored separately, but available from securities table + const change = marketData?.lastChange ?? 0; + const changePercent = marketData?.lastChangePrcnt ?? 0; + + return { + secid: desc.secid, + isin: desc.isin, + name: desc.name, + shortName: desc.shortName, + latName: desc.latName, + listLevel: desc.listLevel, + issueSize: desc.issueSize, + faceValue: desc.faceValue, + faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit, + type: desc.type, + marketData: { + price: price ?? 0, + change, + changePercent, + open: marketData?.open ?? 0, + high: marketData?.high ?? null, + low: marketData?.low ?? null, + volume: marketData?.volume ?? 0, + value: marketData?.value ?? 0, + issueCapitalization: marketData?.issueCapitalization ?? null, + updatedAt: marketData?.updateTime + ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime + : new Date().toISOString(), + }, + }; + } + + async getMarketData(secid: string) { + const { data: marketData, fromCache, cachedAt } = await this.cache.getOrFetch( + 'marketdata', + ['shares', secid], + () => this.moexClient.getShareMarketData(secid), + 'marketDataTtl', + ); + + if (!marketData) { + throw new NotFoundException(`Market data for ${secid} not found`); + } + + return { + data: { + price: marketData.last ?? 0, + change: marketData.lastChange ?? 0, + changePercent: marketData.lastChangePrcnt ?? 0, + open: marketData.open ?? 0, + high: marketData.high ?? null, + low: marketData.low ?? null, + volume: marketData.volume ?? 0, + value: marketData.value ?? 0, + issueCapitalization: marketData.issueCapitalization ?? null, + updatedAt: marketData.updateTime + ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime + : new Date().toISOString(), + }, + meta: { fromCache, cachedAt }, + }; + } + + async getDividends(secid: string) { + const { data, fromCache, cachedAt } = await this.cache.getOrFetch( + 'dividends', + [secid], + () => this.moexClient.getDividends(secid), + 'dividendsTtl', + ); + + return { + data: data.map((d) => ({ + registryCloseDate: d.registryCloseDate, + value: d.value, + currency: d.currencyId, + })), + meta: { fromCache, cachedAt }, + }; + } +} +``` + +- [ ] **Create shares.controller.ts** + +```typescript +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { SharesService } from './shares.service'; + +@ApiTags('Shares') +@Controller('securities/shares') +export class SharesController { + constructor(private readonly sharesService: SharesService) {} + + @Get(':secid') + @ApiOperation({ summary: 'Получить спецификацию акции' }) + async getShare(@Param('secid') secid: string) { + const share = await this.sharesService.getShare(secid); + return { data: share, meta: { cachedAt: null, fromCache: false } }; + } + + @Get(':secid/marketdata') + @ApiOperation({ summary: 'Получить рыночные данные акции' }) + async getMarketData(@Param('secid') secid: string) { + return this.sharesService.getMarketData(secid); + } + + @Get(':secid/dividends') + @ApiOperation({ summary: 'Получить дивиденды' }) + async getDividends(@Param('secid') secid: string) { + return this.sharesService.getDividends(secid); + } +} +``` + +- [ ] **Create shares.module.ts** + +```typescript +import { Module } from '@nestjs/common'; +import { SharesController } from './shares.controller'; +import { SharesService } from './shares.service'; + +@Module({ + controllers: [SharesController], + providers: [SharesService], + exports: [SharesService], +}) +export class SharesModule {} +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/modules/shares/ +git commit -m "feat: add shares endpoint with market data and dividends" +``` + +--- + +## SPRINT 3: Bonds + History + Candles + +### Task 3.1: Bonds module + +**Files:** +- Create: `apps/backend/src/modules/bonds/dto/bond-response.dto.ts` +- Create: `apps/backend/src/modules/bonds/dto/bond-marketdata-response.dto.ts` +- Create: `apps/backend/src/modules/bonds/bonds.service.ts` +- Create: `apps/backend/src/modules/bonds/bonds.controller.ts` +- Create: `apps/backend/src/modules/bonds/bonds.module.ts` + +- [ ] **Create bond-response.dto.ts** + +```typescript +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class BondMarketDataDto { + @ApiProperty({ description: 'Цена в % от номинала', example: 100.45 }) + price: number; + + @ApiPropertyOptional({ example: 12.71 }) + yieldToMaturity: number | null; + + @ApiPropertyOptional() + duration: number | null; + + @ApiProperty({ example: 29.48 }) + accruedInt: number; + + @ApiProperty({ example: 40.64 }) + couponValue: number; + + @ApiPropertyOptional({ example: 8.15 }) + couponPercent: number | null; + + @ApiPropertyOptional({ example: '2026-08-05' }) + nextCouponDate: string | null; + + @ApiProperty() + open: number; + + @ApiPropertyOptional() + high: number | null; + + @ApiPropertyOptional() + low: number | null; + + @ApiProperty() + volume: number; + + @ApiProperty() + updatedAt: string; +} + +export class BondResponseDto { + @ApiProperty({ example: 'SU26207RMFS9' }) + secid: string; + + @ApiProperty({ example: 'RU000A0JS3W6' }) + isin: string; + + @ApiProperty({ example: 'ОФЗ-ПД 26207 03/02/27' }) + name: string; + + @ApiProperty({ example: 'ОФЗ 26207' }) + shortName: string; + + @ApiPropertyOptional() + latName: string | null; + + @ApiProperty({ example: 1 }) + listLevel: number; + + @ApiProperty({ example: 370200604 }) + issueSize: number; + + @ApiProperty({ example: 1000 }) + faceValue: number; + + @ApiProperty({ example: 'RUB' }) + faceUnit: string; + + @ApiProperty({ example: '2027-02-03' }) + matDate: string; + + @ApiProperty({ example: 40.64 }) + couponValue: number; + + @ApiPropertyOptional({ example: 8.15 }) + couponPercent: number | null; + + @ApiProperty({ example: 182 }) + couponPeriod: number; + + @ApiProperty({ example: '2026-08-05' }) + nextCoupon: string | null; + + @ApiProperty({ example: 29.48 }) + accruedInt: number; + + @ApiProperty({ example: 'Фикс с известным купоном' }) + bondType: string; + + @ApiProperty({ example: 'До погашения' }) + bondSubType: string; + + @ApiPropertyOptional() + offerDate: string | null; + + @ApiPropertyOptional() + buybackDate: string | null; + + @ApiProperty() + marketData: BondMarketDataDto; +} +``` + +- [ ] **Create bonds.service.ts** + +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; + +@Injectable() +export class BondsService { + constructor( + private readonly moexClient: MoexClientService, + private readonly cache: CacheService, + ) {} + + async getBond(secid: string) { + const { data: bond, fromCache, cachedAt } = await this.cache.getOrFetch( + 'bond', + [secid], + () => this.moexClient.getBondData(secid), + 'securityTtl', + ); + + if (!bond) { + throw new NotFoundException(`Bond ${secid} not found`); + } + + const { data: mkt } = await this.cache.getOrFetch( + 'marketdata', + ['bonds', secid], + () => this.moexClient.getBondMarketData(secid), + 'marketDataTtl', + ); + + return { + data: { + secid: bond.secid, + isin: bond.isin, + name: bond.shortName, + shortName: bond.shortName, + latName: null, + listLevel: bond.listLevel, + issueSize: bond.issueSize, + faceValue: bond.faceValue, + faceUnit: bond.isin.startsWith('XS') ? 'USD' : 'RUB', + matDate: bond.matDate, + couponValue: bond.couponValue ?? 0, + couponPercent: bond.couponPercent, + couponPeriod: bond.couponPeriod, + nextCoupon: bond.nextCoupon, + accruedInt: bond.accruedInt ?? 0, + bondType: bond.bondType, + bondSubType: bond.bondSubType, + offerDate: bond.offerDate, + buybackDate: bond.buybackDate, + marketData: { + price: mkt?.last ?? bond.prevPrice ?? 0, + yieldToMaturity: mkt?.yield ?? bond.yieldAtPrevWaprice ?? null, + duration: mkt?.duration ?? null, + accruedInt: bond.accruedInt ?? 0, + couponValue: bond.couponValue ?? 0, + couponPercent: bond.couponPercent, + nextCouponDate: bond.nextCoupon, + open: mkt?.open ?? 0, + high: mkt?.high ?? null, + low: mkt?.low ?? null, + volume: mkt?.volume ?? 0, + updatedAt: mkt?.updateTime + ? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime + : new Date().toISOString(), + }, + }, + meta: { fromCache, cachedAt }, + }; + } + + async getMarketData(secid: string) { + const { data: mkt, fromCache, cachedAt } = await this.cache.getOrFetch( + 'marketdata', + ['bonds', secid], + () => this.moexClient.getBondMarketData(secid), + 'marketDataTtl', + ); + + if (!mkt) { + throw new NotFoundException(`Market data for bond ${secid} not found`); + } + + return { + data: { + price: mkt.last ?? 0, + yieldToMaturity: mkt.yield ?? null, + duration: mkt.duration ?? null, + accruedInt: 0, + couponValue: 0, + couponPercent: null, + nextCouponDate: null, + open: mkt.open ?? 0, + high: mkt.high ?? null, + low: mkt.low ?? null, + volume: mkt.volume ?? 0, + updatedAt: mkt.updateTime + ? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime + : new Date().toISOString(), + }, + meta: { fromCache, cachedAt }, + }; + } + + async getHistory(secid: string, from: string, till: string) { + const { data, fromCache, cachedAt } = await this.cache.getOrFetch( + 'history', + ['bonds', secid, from, till], + () => this.moexClient.getBondHistory(secid, from, till), + 'historyTtl', + ); + + return { + data: data.map((h) => ({ + date: h.tradeDate, + closePrice: h.legalClosePrice ?? h.close ?? 0, + yieldClose: h.yieldClose ?? null, + duration: h.duration ?? null, + })), + meta: { fromCache, cachedAt }, + }; + } +} +``` + +- [ ] **Create bonds.controller.ts** + +```typescript +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { BondsService } from './bonds.service'; + +@ApiTags('Bonds') +@Controller('securities/bonds') +export class BondsController { + constructor(private readonly bondsService: BondsService) {} + + @Get(':secid') + @ApiOperation({ summary: 'Получить спецификацию облигации' }) + async getBond(@Param('secid') secid: string) { + return this.bondsService.getBond(secid); + } + + @Get(':secid/marketdata') + @ApiOperation({ summary: 'Получить рыночные данные облигации' }) + async getMarketData(@Param('secid') secid: string) { + return this.bondsService.getMarketData(secid); + } + + @Get(':secid/history') + @ApiOperation({ summary: 'Получить дневную историю торгов облигации' }) + async getHistory( + @Param('secid') secid: string, + @Query('from') from: string, + @Query('till') till: string, + ) { + return this.bondsService.getHistory(secid, from, till); + } +} +``` + +- [ ] **Create bonds.module.ts** + +```typescript +import { Module } from '@nestjs/common'; +import { BondsController } from './bonds.controller'; +import { BondsService } from './bonds.service'; + +@Module({ + controllers: [BondsController], + providers: [BondsService], + exports: [BondsService], +}) +export class BondsModule {} +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/modules/bonds/ +git commit -m "feat: add bonds endpoint with market data and history" +``` + +### Task 3.2: Candles module (shared by shares + bonds) + +**Files:** +- Create: `apps/backend/src/modules/candles/dto/candles-query.dto.ts` +- Create: `apps/backend/src/modules/candles/candles.service.ts` +- Create: `apps/backend/src/modules/candles/candles.controller.ts` +- Create: `apps/backend/src/modules/candles/candles.module.ts` + +- [ ] **Create candles-query.dto.ts** + +```typescript +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsEnum, IsDateString } from 'class-validator'; + +export enum CandleInterval { + HOUR = '1h', + DAY = '24h', +} + +export class CandlesQueryDto { + @ApiProperty({ enum: CandleInterval }) + @IsEnum(CandleInterval) + interval: CandleInterval; + + @ApiProperty({ format: 'date', example: '2025-06-13' }) + @IsDateString() + from: string; + + @ApiProperty({ format: 'date', example: '2026-06-13' }) + @IsDateString() + till: string; +} +``` + +- [ ] **Create candles.service.ts** + +```typescript +import { Injectable } from '@nestjs/common'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import { CandleInterval } from './dto/candles-query.dto'; + +@Injectable() +export class CandlesService { + constructor( + private readonly moexClient: MoexClientService, + private readonly cache: CacheService, + ) {} + + private mapInterval(interval: CandleInterval): 60 | 24 { + return interval === CandleInterval.HOUR ? 60 : 24; + } + + async getCandles( + market: 'shares' | 'bonds', + secid: string, + interval: CandleInterval, + from: string, + till: string, + ) { + const moexInterval = this.mapInterval(interval); + const { data, fromCache, cachedAt } = await this.cache.getOrFetch( + 'candles', + [market, secid, String(moexInterval), from, till], + () => + this.moexClient.getCandles('stock', market, secid, moexInterval, from, till), + 'candlesTtl', + ); + + return { + data: data.map((c) => ({ + open: c.open, + high: c.high, + low: c.low, + close: c.close, + volume: c.volume, + value: c.value, + begin: c.begin, + end: c.end, + })), + meta: { fromCache, cachedAt }, + }; + } +} +``` + +- [ ] **Create candles.controller.ts** + +```typescript +import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { CandlesService } from './candles.service'; +import { CandlesQueryDto } from './dto/candles-query.dto'; + +@ApiTags('Candles') +@Controller('securities') +export class CandlesController { + constructor(private readonly candlesService: CandlesService) {} + + @Get('shares/:secid/candles') + @ApiOperation({ summary: 'Получить свечи акции' }) + async getShareCandles( + @Param('secid') secid: string, + @Query(ValidationPipe) query: CandlesQueryDto, + ) { + return this.candlesService.getCandles('shares', secid, query.interval, query.from, query.till); + } + + @Get('bonds/:secid/candles') + @ApiOperation({ summary: 'Получить свечи облигации' }) + async getBondCandles( + @Param('secid') secid: string, + @Query(ValidationPipe) query: CandlesQueryDto, + ) { + return this.candlesService.getCandles('bonds', secid, query.interval, query.from, query.till); + } +} +``` + +- [ ] **Create candles.module.ts** + +```typescript +import { Module } from '@nestjs/common'; +import { CandlesController } from './candles.controller'; +import { CandlesService } from './candles.service'; + +@Module({ + controllers: [CandlesController], + providers: [CandlesService], + exports: [CandlesService], +}) +export class CandlesModule {} +``` + +- [ ] **Add OpenAPI decorators to share history endpoint** in `shares.controller.ts`: + +```typescript +@Get(':secid/history') +@ApiOperation({ summary: 'Получить дневную историю торгов акции' }) +async getHistory( + @Param('secid') secid: string, + @Query('from') from: string, + @Query('till') till: string, +) { + return this.sharesService.getHistory(secid, from, till); +} +``` + +- [ ] **Add getHistory method to SharesService**: + +```typescript +async getHistory(secid: string, from: string, till: string) { + const { data, fromCache, cachedAt } = await this.cache.getOrFetch( + 'history', + ['shares', secid, from, till], + () => this.moexClient.getHistory(secid, from, till), + 'historyTtl', + ); + + return { + data: data.map((h) => ({ + date: h.tradeDate, + open: h.open ?? 0, + high: h.high ?? 0, + low: h.low ?? 0, + close: h.close ?? 0, + volume: h.volume, + value: h.value, + })), + meta: { fromCache, cachedAt }, + }; +} +``` + +- [ ] **Commit** + +```bash +git add apps/backend/src/modules/candles/ +git commit -m "feat: add candles module with 1h/24h intervals for shares and bonds" +``` + +--- + +## SPRINT 4: Frontend Foundation + +### Task 4.1: Scaffold React + Vite frontend + +**Files:** +- Create: `apps/frontend/package.json` +- Create: `apps/frontend/tsconfig.json` +- Create: `apps/frontend/tsconfig.node.json` +- Create: `apps/frontend/vite.config.ts` +- Create: `apps/frontend/index.html` +- Create: `apps/frontend/src/vite-env.d.ts` +- Create: `apps/frontend/src/main.tsx` +- Create: `apps/frontend/src/App.tsx` +- Create: `apps/frontend/src/routes.tsx` +- Create: `apps/frontend/src/styles.css` + +- [ ] **Create apps/frontend/package.json** + +```json +{ + "name": "@moex-vibe/frontend", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "codegen": "openapi-typescript http://localhost:3000/api/docs-json -o src/api/types.ts" + }, + "dependencies": { + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^6.20.0", + "@tanstack/react-query": "^5.20.0", + "openapi-fetch": "^0.9.0", + "lightweight-charts": "^4.1.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.3.0", + "vite": "^5.4.0", + "openapi-typescript": "^7.0.0" + } +} +``` + +- [ ] **Create apps/frontend/tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} +``` + +- [ ] **Create apps/frontend/tsconfig.node.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true + }, + "include": ["vite.config.ts"] +} +``` + +- [ ] **Create apps/frontend/vite.config.ts** + +```typescript +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:3000', + changeOrigin: true, + }, + }, + }, +}); +``` + +- [ ] **Create apps/frontend/index.html** + +```html + + + + + + MoexVibe + + +
+ + + +``` + +- [ ] **Create apps/frontend/src/vite-env.d.ts** + +```typescript +/// +``` + +- [ ] **Create apps/frontend/src/main.tsx** + +```typescript +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import App from './App'; +import './styles.css'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 2, + staleTime: 900_000, + refetchOnWindowFocus: false, + }, + }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +); +``` + +- [ ] **Create apps/frontend/src/App.tsx** + +```typescript +import { BrowserRouter } from 'react-router-dom'; +import { AppRoutes } from './routes'; + +export default function App() { + return ( + + + + ); +} +``` + +- [ ] **Create apps/frontend/src/routes.tsx** + +```typescript +import { Routes, Route } from 'react-router-dom'; +import { Layout } from './components/Layout'; +import { HomePage } from './pages/HomePage'; +import { StockPage } from './pages/StockPage'; +import { BondPage } from './pages/BondPage'; + +export function AppRoutes() { + return ( + + }> + } /> + } /> + } /> + + + ); +} +``` + +- [ ] **Create apps/frontend/src/styles.css** — minimal reset: + +```css +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --color-bg: #f5f5f5; + --color-surface: #ffffff; + --color-text: #1a1a1a; + --color-text-secondary: #666; + --color-primary: #1976d2; + --color-positive: #2e7d32; + --color-negative: #c62828; + --border-radius: 8px; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.12); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--color-bg); + color: var(--color-text); + line-height: 1.6; +} + +a { + color: var(--color-primary); + text-decoration: none; +} +``` + +- [ ] **Commit** + +```bash +git add apps/frontend/ +git commit -m "feat: scaffold React + Vite frontend with routing" +``` + +### Task 4.2: Generate API client from OpenAPI schema + +- [ ] **Start backend**, then run: + +```bash +npm run codegen -w apps/frontend +``` + +This creates `apps/frontend/src/api/types.ts` with all typed DTOs. + +- [ ] **Create apps/frontend/src/api/client.ts** — typed fetch wrapper: + +```typescript +import createClient from 'openapi-fetch'; +import type { paths } from './types'; + +export const apiClient = createClient({ + baseUrl: '/api/v1', +}); + +export type ApiResponse = { + data: T; + meta: { + cachedAt: string | null; + fromCache: boolean; + }; +}; +``` + +- [ ] **Commit** + +```bash +git add apps/frontend/src/api/ +git commit -m "feat: add openapi-typescript generated types and API client" +``` + +### Task 4.3: Layout component + +**Files:** +- Create: `apps/frontend/src/components/Layout.tsx` + +- [ ] **Create Layout.tsx** + +```typescript +import { Outlet, Link } from 'react-router-dom'; + +const headerStyle: React.CSSProperties = { + background: 'var(--color-surface)', + borderBottom: '1px solid #e0e0e0', + padding: '12px 24px', + display: 'flex', + alignItems: 'center', + gap: 24, + position: 'sticky', + top: 0, + zIndex: 100, +}; + +const mainStyle: React.CSSProperties = { + maxWidth: 1200, + margin: '0 auto', + padding: '24px 16px', +}; + +export function Layout() { + return ( +
+
+ + MoexVibe + +
+
+ +
+
+ ); +} +``` + +- [ ] **Commit** + +```bash +git add apps/frontend/src/components/Layout.tsx +git commit -m "feat: add Layout component with header" +``` + +### Task 4.4: Search hook + HomePage + +**Files:** +- Create: `apps/frontend/src/hooks/useSearch.ts` +- Create: `apps/frontend/src/components/SearchBar.tsx` +- Create: `apps/frontend/src/components/SecurityCard.tsx` +- Create: `apps/frontend/src/pages/HomePage.tsx` + +- [ ] **Create useSearch.ts** + +```typescript +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '../api/client'; + +export function useSearch(query: string) { + return useQuery({ + queryKey: ['search', query], + queryFn: async () => { + const { data } = await apiClient.GET('/securities/search', { + params: { query: { q: query, limit: 20 } }, + }); + return data?.data ?? []; + }, + enabled: query.length >= 1, + staleTime: 60_000, + }); +} +``` + +- [ ] **Create SearchBar.tsx** + +```typescript +import { useState, useCallback } from 'react'; + +interface SearchBarProps { + onSearch: (query: string) => void; +} + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '12px 16px', + fontSize: 16, + border: '1px solid #ddd', + borderRadius: 'var(--border-radius)', + outline: 'none', +}; + +export function SearchBar({ onSearch }: SearchBarProps) { + const [value, setValue] = useState(''); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const v = e.target.value; + setValue(v); + onSearch(v); + }, + [onSearch], + ); + + return ( + + ); +} +``` + +- [ ] **Create SecurityCard.tsx** + +```typescript +import { Link } from 'react-router-dom'; + +interface SecurityCardProps { + secid: string; + shortName: string; + type: 'share' | 'bond'; + isin: string; + listLevel: number; + currency: string | null; + price: number | null; +} + +const cardStyle: React.CSSProperties = { + background: 'var(--color-surface)', + borderRadius: 'var(--border-radius)', + boxShadow: 'var(--shadow)', + padding: 16, + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}; + +const badgeStyle: React.CSSProperties = { + fontSize: 12, + padding: '2px 8px', + borderRadius: 4, + fontWeight: 600, +}; + +export function SecurityCard({ secid, shortName, type, isin, currency, price }: SecurityCardProps) { + const linkTo = type === 'share' ? `/stocks/${secid}` : `/bonds/${secid}`; + + return ( + +
+
+
{secid}
+
+ {shortName} · {isin} +
+
+
+ + {type === 'share' ? 'Акция' : 'Облигация'} + + {price != null && ( +
+ {price.toLocaleString('ru-RU')} {currency || ''} +
+ )} +
+
+ + ); +} +``` + +- [ ] **Create HomePage.tsx** + +```typescript +import { useState } from 'react'; +import { SearchBar } from '../components/SearchBar'; +import { SecurityCard } from '../components/SecurityCard'; +import { useSearch } from '../hooks/useSearch'; + +export function HomePage() { + const [query, setQuery] = useState(''); + const { data: results, isLoading } = useSearch(query); + + return ( +
+

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

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

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

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

{bond.shortName}

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

График цены

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

Дивиденды

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

График цены

+ +
+
+ ); +} +``` + +- [ ] **Commit** + +```bash +git add apps/frontend/src/pages/ +git commit -m "feat: add StockPage and BondPage with charts and details" +``` + +--- + +## SPRINT 6: Documentation + Infrastructure + +### Task 6.1: OpenAPI spec finalization + +- [ ] **Verify OpenAPI spec** — start backend, check Swagger UI at `/api/docs`. Ensure all endpoints, schemas, and examples are present. + +- [ ] **Sync `docs/openapi/openapi.yaml`** with the generated spec if any changes were made during development. + +- [ ] **Commit** + +```bash +git add docs/openapi/openapi.yaml +git commit -m "docs: finalize OpenAPI specification" +``` + +### Task 6.2: ADR documentation + +- [ ] **Ensure all ADR files exist** in `docs/architecture/adr/` (created during design phase, adjust if needed). + +- [ ] **Add architecture overview diagram** (ASCII sequence diagram or Mermaid): + +```markdown +# Architecture Overview + +```mermaid +sequenceDiagram + participant User + participant Frontend as React SPA + participant Backend as NestJS API + participant Cache as In-Memory Cache + participant MOEX as MOEX ISS + + User->>Frontend: Search / View instrument + Frontend->>Backend: GET /api/v1/securities/search?q=SBER + Backend->>Cache: getOrFetch('search:sber') + alt Cache miss + Cache->>Backend: null + Backend->>MOEX: GET /iss/securities?q=SBER + MOEX-->>Backend: raw data + Backend->>Cache: set('search:sber', normalized, TTL=3600) + else Cache hit + Cache-->>Backend: cached data + end + Backend-->>Frontend: normalized response + Frontend-->>User: rendered UI +``` +``` + +- [ ] **Commit** + +```bash +git add docs/ +git commit -m "docs: add ADR documents and architecture diagrams" +``` + +### Task 6.3: Docker setup + +**Files:** +- Create: `docker/Dockerfile.backend` +- Create: `docker/Dockerfile.frontend` +- Create: `docker/nginx.conf` +- Create: `docker-compose.yml` + +- [ ] **Create Dockerfile.backend** + +```dockerfile +FROM node:20-alpine AS build +WORKDIR /app +COPY apps/backend/package.json ./ +RUN npm install +COPY apps/backend/ ./ +RUN npm run build + +FROM node:20-alpine AS production +WORKDIR /app +COPY --from=build /app/dist ./dist +COPY --from=build /app/node_modules ./node_modules +COPY apps/backend/package.json ./ +EXPOSE 3000 +CMD ["node", "dist/main.js"] +``` + +- [ ] **Create Dockerfile.frontend** + +```dockerfile +FROM node:20-alpine AS build +WORKDIR /app +COPY apps/frontend/package.json ./ +RUN npm install +COPY apps/frontend/ ./ +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] +``` + +- [ ] **Create nginx.conf** + +```nginx +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +- [ ] **Create docker-compose.yml** + +```yaml +services: + backend: + build: + context: . + 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/specs/2026-06-13-moex-vibe-design.md b/docs/superpowers/specs/2026-06-13-moex-vibe-design.md new file mode 100644 index 0000000..d19a920 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-moex-vibe-design.md @@ -0,0 +1,338 @@ +# MoexVibe — MVP Design Specification + +**Date:** 2026-06-13 +**Status:** Draft +**Author:** AI Assistant (Staff+ Architect) + +--- + +## 1. Product Requirements Document (PRD) + +### 1.1 Product Vision +Веб-приложение для анализа ценных бумаг Московской биржи (MOEX). Позволяет искать акции и облигации, просматривать их текущие параметры, доходность, дивиденды/купоны, историю торгов и графики цены. + +### 1.2 Target Audience +Частные инвесторы, интересующиеся российским фондовым рынком. B2C, read-only сервис без аутентификации. + +### 1.3 MVP Scope + +| In Scope | Out of Scope | +|----------|-------------| +| Поиск по инструментам (акции + облигации) | Аутентификация / пользователи | +| Карточка акции (цена, капитализация, дивиденды, график) | Портфели и избранное | +| Карточка облигации (ISIN, купон, НКД, YTM, дюрация, график) | Сравнение инструментов | +| Часовые и дневные свечи (1 год истории) | Финансовая отчётность (МСФО/РСБУ) | +| Docker-ready деплой | Фьючерсы, опционы, валютный рынок | +| Документация (ADR, API, архитектура) | Real-time данные (WebSocket) | +| | Экспорт данных | +| | Мобильные приложения | + +### 1.4 User Stories + +- US-001: Пользователь вводит текст в поиск и видит подходящие акции и облигации +- US-002: Пользователь переходит на карточку акции, видит текущую цену, изменение, капитализацию +- US-003: Пользователь видит историю дивидендных выплат по акции +- US-004: Пользователь видит график цены (дневные и часовые свечи) за последний год +- US-005: Пользователь переходит на карточку облигации, видит ISIN, номинал, купон, дату погашения +- US-006: Пользователь видит НКД, доходность к погашению, дюрацию +- US-007: Пользователь видит график цены облигации за последний год + +### 1.5 Non-Functional Requirements + +- Максимальное время ответа API: < 500ms (p95) при попадании в кеш +- Доступность: бэкенд stateless, готов к масштабированию +- Задержка данных: 15 минут (бесплатный MOEX ISS) +- Все ответы API кешируются на бэкенде + +--- + +## 2. Domain Model + +``` +Security (abstract base) +├── secid: string — "SBER" +├── isin: string — "RU0009029540" +├── name: string — полное наименование +├── shortName: string — краткое наименование +├── latName: string | null +├── listLevel: 1 | 2 | 3 — уровень листинга +├── issueSize: number — объём выпуска +├── faceValue: number — номинал +├── faceUnit: string — "RUB" / "USD" / "SUR" +├── issueDate: string — ISO date +├── isQualifiedInvestors: boolean +├── morningSession: boolean +├── eveningSession: boolean +│ +├── Stock +│ ├── type: "common_share" | "preferred_share" +│ ├── marketData: StockMarketData +│ │ ├── price: number +│ │ ├── change: number +│ │ ├── changePercent: number +│ │ ├── open: number +│ │ ├── high: number +│ │ ├── low: number +│ │ ├── volume: number +│ │ ├── value: number +│ │ └── issueCapitalization: number +│ └── dividends: Dividend[] +│ ├── registryCloseDate: string (ISO date) +│ ├── value: number (RUB per share) +│ └── currency: string +│ +└── Bond + ├── matDate: string — дата погашения + ├── couponValue: number — размер купона (RUB) + ├── couponPercent: number|null — ставка купона (%) + ├── couponPeriod: number — дней между купонами + ├── nextCoupon: string (ISO date) + ├── accruedInt: number — НКД + ├── bondType: string — "Фикс" / "Флоатер" / "Линкер" / etc + ├── bondSubType: string — "До погашения" / "До оферты" + ├── offerDate: string | null + ├── buybackDate: string | null + ├── marketData: BondMarketData + │ ├── price: number — % от номинала + │ ├── yieldToMaturity: number | null + │ ├── duration: number | null + │ ├── open: number + │ ├── high: number | null + │ ├── low: number | null + │ └── volume: number + └── history: BondHistoryEntry[] + ├── date: string + ├── closePrice: number + ├── yieldClose: number + └── duration: number + +Candle +├── open: number +├── high: number +├── low: number +├── close: number +├── volume: number +├── value: number +├── begin: string (ISO datetime) +└── end: string (ISO datetime) + +SearchResult +├── secid: string +├── isin: string +├── shortName: string +├── type: "share" | "bond" +├── listLevel: number +├── currency: string | null +└── price: number | null +``` + +--- + +## 3. Architecture + +``` +┌──────────────┐ ┌─────────────────────────────────────┐ ┌──────────────┐ +│ Browser │────▶│ NestJS Backend │────▶│ MOEX ISS │ +│ (React SPA) │◀────│ (1 instance, stateless) │◀────│ (HTTP) │ +└──────────────┘ │ │ └──────────────┘ + │ ┌─────────────────────────────────┐ │ + │ │ Core Modules │ │ + │ │ ┌──────────┐ ┌───────────────┐ │ │ + │ │ │ Search │ │ SharesModule │ │ │ + │ │ │ Module │ │ (stocks) │ │ │ + │ │ └──────────┘ └───────────────┘ │ │ + │ │ ┌──────────┐ ┌───────────────┐ │ │ + │ │ │ Bonds │ │ Candles │ │ │ + │ │ │ Module │ │ Module │ │ │ + │ │ └──────────┘ └───────────────┘ │ │ + │ └─────────────────────────────────┘ │ + │ ┌─────────────────────────────────┐ │ + │ │ Shared Infrastructure │ │ + │ │ ┌──────────┐ ┌───────────────┐ │ │ + │ │ │ MOEX │ │ Cache │ │ │ + │ │ │ Client │ │ Manager │ │ │ + │ │ │(rate-ltd│ │ (in-memory) │ │ │ + │ │ │ circuit │ │ │ │ │ + │ │ │breaker) │ │ │ │ │ + │ │ └──────────┘ └───────────────┘ │ │ + │ └─────────────────────────────────┘ │ + └─────────────────────────────────────┘ +``` + +### 3.1 Caching Strategy + +| Data Type | Backend TTL | Frontend staleTime | Notes | +|-----------|-------------|-------------------|-------| +| MarketData | 900s (15m) | 900s | Совпадает с задержкой MOEX | +| History | 3600s (1h) | 3600s | Обновляется раз в день после торгов | +| Candles | 3600s (1h) | 3600s | Дневные свечи не меняются intraday | +| Security spec | 86400s (1d) | 86400s | Редко меняется | +| Search results | 3600s (1h) | 3600s | | +| Dividends | 86400s (1d) | 86400s | | + +### 3.2 Error Handling Strategy + +- Все MOEX-ошибки маппятся в нормализованный `ErrorResponse` +- При пустых данных (выходные, праздники) — `200` с `null` значениями, не `404` +- Circuit breaker: при 5+ последовательных ошибках MOEX — пауза 30s +- Graceful degradation: если MOEX недоступен, возвращать последние кешированные данные + +### 3.3 Rate Limiting + +- MOEX Client: очередь запросов ~10 req/s (конфигурируется) +- При превышении — автоматическое ожидание в очереди +- Отсутствие внешнего rate limiter на уровне NestJS (приложение публичное, read-only) + +--- + +## 4. API Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/health` | Healthcheck | +| GET | `/api/v1/securities/search` | Поиск по инструментам | +| GET | `/api/v1/securities/shares/:secid` | Спецификация акции | +| GET | `/api/v1/securities/shares/:secid/marketdata` | Рыночные данные акции | +| GET | `/api/v1/securities/shares/:secid/candles` | Свечи (1h/24h) | +| GET | `/api/v1/securities/shares/:secid/history` | Дневная история | +| GET | `/api/v1/securities/shares/:secid/dividends` | Дивиденды | +| GET | `/api/v1/securities/bonds/:secid` | Спецификация облигации | +| GET | `/api/v1/securities/bonds/:secid/marketdata` | Рыночные данные облигации | +| GET | `/api/v1/securities/bonds/:secid/candles` | Свечи (1h/24h) | +| GET | `/api/v1/securities/bonds/:secid/history` | Дневная история | + +--- + +## 5. Repo Structure + +``` +moex-vibe/ +├── apps/ +│ ├── backend/ +│ │ ├── src/ +│ │ │ ├── main.ts +│ │ │ ├── app.module.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 +│ │ │ ├── config/ +│ │ │ │ └── configuration.ts +│ │ │ └── modules/ +│ │ │ ├── moex-client/ +│ │ │ ├── cache/ +│ │ │ ├── securities/ +│ │ │ ├── shares/ +│ │ │ ├── bonds/ +│ │ │ └── health/ +│ │ ├── test/ +│ │ └── package.json +│ └── frontend/ +│ ├── src/ +│ │ ├── api/ # openapi-typescript generated +│ │ │ ├── types.ts +│ │ │ └── client.ts +│ │ ├── hooks/ +│ │ │ ├── useStock.ts +│ │ │ ├── useBond.ts +│ │ │ ├── useSearch.ts +│ │ │ ├── useCandles.ts +│ │ │ └── useDividends.ts +│ │ ├── pages/ +│ │ │ ├── HomePage.tsx +│ │ │ ├── StockPage.tsx +│ │ │ └── BondPage.tsx +│ │ ├── components/ +│ │ │ ├── Layout/ +│ │ │ ├── SearchBar/ +│ │ │ ├── SecurityCard/ +│ │ │ ├── PriceChart/ +│ │ │ ├── StockDetails/ +│ │ │ └── BondDetails/ +│ │ ├── routes.tsx +│ │ └── main.tsx +│ └── package.json +├── docs/ +│ ├── superpowers/specs/ +│ ├── architecture/ +│ │ ├── adr/ +│ │ ├── diagrams/ +│ │ └── domain-model.md +│ ├── openapi/ +│ │ └── openapi.yaml +│ └── website/ (Docusaurus — post-MVP) +├── package.json +├── tsconfig.base.json +└── .gitignore +``` + +--- + +## 6. Sprint Plan + +### Sprint 1 — Backend Foundation +- NestJS project init + npm workspaces +- ConfigurationModule, Logging, Global filters +- MoexClientModule (rate-limited HTTP client) +- CacheModule (cache-manager in-memory) +- HealthController +- ESLint, Prettier, tsconfig + +### Sprint 2 — Securities API +- SecuritiesModule (search) +- SharesModule (spec + marketdata + dividends) +- OpenAPI decorators +- Unit tests + +### Sprint 3 — Bonds + History +- BondsModule (spec + marketdata) +- CandlesModule (shares + bonds) +- HistoryModule +- OpenAPI decorators +- Unit tests + +### Sprint 4 — Frontend Foundation +- Vite + React + TypeScript init +- openapi-typescript codegen +- TanStack Query + React Router +- Layout, SearchBar, HomePage + +### Sprint 5 — Frontend Details +- StockPage (price block, dividends table, chart) +- BondPage (bond details, chart) +- PriceChart component (lightweight-charts) +- Loading/error states + +### Sprint 6 — Docs + Infrastructure +- ADRs, architecture docs +- OpenAPI spec +- Dockerfile + docker-compose +- README + +--- + +## 7. Risks + +| Risk | Impact | Mitigation | +|------|--------|-----------| +| MOEX ISS API changes | High | MoexClient abstraction layer | +| Rate limiting by MOEX | Medium | p-queue + circuit breaker | +| Empty data on holidays/weekends | Low | Graceful null handling | +| Large search result sets | Low | Server-side limit + frontend debounce | + +## 8. Post-MVP Roadmap + +1. Финансовая отчётность (MOEX CCI — IFRS/RAS) +2. Аутентификация, портфели, избранное +3. Сравнение инструментов (multi-chart) +4. Фьючерсы и опционы +5. Экспорт (CSV, PDF) +6. WebSocket для real-time данных +7. Redis для масштабирования