From ccb108253593c6db0879a267f12558ec48296033 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 20:20:59 +0300 Subject: [PATCH 1/9] refactor(backend): unify envelope DTOs and fix shares/bonds inconsistency - Replace 4 duplicate meta DTOs (AuthResponseMetaDto, PortfolioResponseMetaDto, BrokerResponseMetaDto, ScreenerResponseMetaDto) with shared ApiResponseMeta - Wrap shares getShare() in ApiEnvelopePayload (was raw object, unlike bonds) - Remove unnecessary CacheModule import from securities module - Update portfolio controller nullDataEnvelopeSchema to use shared ApiResponseMeta - All 116 tests pass --- .../src/modules/auth/dto/auth-response.dto.ts | 21 +++---- .../portfolio/dto/portfolio-envelope.dto.ts | 29 ++++----- .../modules/portfolio/portfolio.controller.ts | 6 +- .../securities/dto/screener-response.dto.ts | 13 +--- .../modules/securities/securities.module.ts | 2 - .../src/modules/shares/shares.service.spec.ts | 4 +- .../src/modules/shares/shares.service.ts | 60 +++++++++++-------- .../modules/tbank/dto/broker-envelope.dto.ts | 37 +++++------- .../backend-architecture-improvements/plan.md | 52 ++++++++++++++++ .../backend-architecture-improvements/spec.md | 33 ++++++++++ .../tasks.md | 53 ++++++++++++++++ docs/research/2026-06-25-backend-audit.md | 49 +++++++++++++++ 12 files changed, 262 insertions(+), 97 deletions(-) create mode 100644 docs/features/backend-architecture-improvements/plan.md create mode 100644 docs/features/backend-architecture-improvements/spec.md create mode 100644 docs/features/backend-architecture-improvements/tasks.md create mode 100644 docs/research/2026-06-25-backend-audit.md diff --git a/apps/backend/src/modules/auth/dto/auth-response.dto.ts b/apps/backend/src/modules/auth/dto/auth-response.dto.ts index da8885d..2a5d561 100644 --- a/apps/backend/src/modules/auth/dto/auth-response.dto.ts +++ b/apps/backend/src/modules/auth/dto/auth-response.dto.ts @@ -1,12 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; - -class AuthResponseMetaDto { - @ApiProperty({ type: String, nullable: true }) - cachedAt!: string | null; - - @ApiProperty() - fromCache!: boolean; -} +import { ApiResponseMeta } from '../../../common/dto/api-response.dto'; class AuthUserDto { @ApiProperty() @@ -39,22 +32,22 @@ export class AuthTokenResponseDto { @ApiProperty({ type: AuthTokenDataDto }) data!: AuthTokenDataDto; - @ApiProperty({ type: AuthResponseMetaDto }) - meta!: AuthResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class AuthProfileResponseDto { @ApiProperty({ type: AuthUserDto }) data!: AuthUserDto; - @ApiProperty({ type: AuthResponseMetaDto }) - meta!: AuthResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class AuthLogoutResponseDto { @ApiProperty({ type: LogoutDataDto }) data!: LogoutDataDto; - @ApiProperty({ type: AuthResponseMetaDto }) - meta!: AuthResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } diff --git a/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts b/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts index 37ae555..1ff52e7 100644 --- a/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/portfolio-envelope.dto.ts @@ -1,53 +1,46 @@ import { ApiProperty } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../../common/dto/api-response.dto'; import { AnalyticsResponseDto } from './analytics-response.dto'; import { PortfolioListResponseDto } from './portfolio-list-response.dto'; import { PortfolioDetailResponseDto, PortfolioResponseDto } from './portfolio-response.dto'; import { PositionResponseDto } from './position-response.dto'; -export class PortfolioResponseMetaDto { - @ApiProperty({ type: String, nullable: true }) - cachedAt!: string | null; - - @ApiProperty() - fromCache!: boolean; -} - export class PortfolioListEnvelopeDto { @ApiProperty({ type: [PortfolioListResponseDto] }) data!: PortfolioListResponseDto[]; - @ApiProperty({ type: PortfolioResponseMetaDto }) - meta!: PortfolioResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class PortfolioEnvelopeDto { @ApiProperty({ type: PortfolioResponseDto }) data!: PortfolioResponseDto; - @ApiProperty({ type: PortfolioResponseMetaDto }) - meta!: PortfolioResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class PortfolioDetailEnvelopeDto { @ApiProperty({ type: PortfolioDetailResponseDto }) data!: PortfolioDetailResponseDto; - @ApiProperty({ type: PortfolioResponseMetaDto }) - meta!: PortfolioResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class PositionEnvelopeDto { @ApiProperty({ type: PositionResponseDto }) data!: PositionResponseDto; - @ApiProperty({ type: PortfolioResponseMetaDto }) - meta!: PortfolioResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class AnalyticsEnvelopeDto { @ApiProperty({ type: AnalyticsResponseDto }) data!: AnalyticsResponseDto; - @ApiProperty({ type: PortfolioResponseMetaDto }) - meta!: PortfolioResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } diff --git a/apps/backend/src/modules/portfolio/portfolio.controller.ts b/apps/backend/src/modules/portfolio/portfolio.controller.ts index c9badd4..b43035a 100644 --- a/apps/backend/src/modules/portfolio/portfolio.controller.ts +++ b/apps/backend/src/modules/portfolio/portfolio.controller.ts @@ -13,13 +13,13 @@ import { CreatePortfolioDto } from './dto/create-portfolio.dto'; import { UpdatePortfolioDto } from './dto/update-portfolio.dto'; import { AddPositionDto } from './dto/add-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto'; +import { ApiResponseMeta } from '../../common/dto/api-response.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { AnalyticsEnvelopeDto, PortfolioDetailEnvelopeDto, PortfolioEnvelopeDto, PortfolioListEnvelopeDto, - PortfolioResponseMetaDto, PositionEnvelopeDto, } from './dto/portfolio-envelope.dto'; @@ -27,14 +27,14 @@ const nullDataEnvelopeSchema = { type: 'object', properties: { data: { type: 'null' }, - meta: { $ref: getSchemaPath(PortfolioResponseMetaDto) }, + meta: { $ref: getSchemaPath(ApiResponseMeta) }, }, required: ['data', 'meta'], }; @ApiTags('Portfolios') @ApiBearerAuth() -@ApiExtraModels(PortfolioResponseMetaDto) +@ApiExtraModels(ApiResponseMeta) @Controller('portfolios') export class PortfolioController { constructor(private readonly portfolioService: PortfolioService) {} diff --git a/apps/backend/src/modules/securities/dto/screener-response.dto.ts b/apps/backend/src/modules/securities/dto/screener-response.dto.ts index 3c765d8..1f265a6 100644 --- a/apps/backend/src/modules/securities/dto/screener-response.dto.ts +++ b/apps/backend/src/modules/securities/dto/screener-response.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../../common/dto/api-response.dto'; export class ScreenerItemDto { @ApiProperty({ example: 'SBER' }) @@ -70,18 +71,10 @@ export class ScreenerResultDto { totalPages!: number; } -class ScreenerResponseMetaDto { - @ApiProperty({ type: String, nullable: true }) - cachedAt!: string | null; - - @ApiProperty() - fromCache!: boolean; -} - export class ScreenerResponseDto { @ApiProperty({ type: ScreenerResultDto }) data!: ScreenerResultDto; - @ApiProperty({ type: ScreenerResponseMetaDto }) - meta!: ScreenerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } diff --git a/apps/backend/src/modules/securities/securities.module.ts b/apps/backend/src/modules/securities/securities.module.ts index dc3736d..ce1bba3 100644 --- a/apps/backend/src/modules/securities/securities.module.ts +++ b/apps/backend/src/modules/securities/securities.module.ts @@ -1,11 +1,9 @@ import { Module } from '@nestjs/common'; -import { CacheModule } from '../cache/cache.module'; import { SecuritiesController } from './securities.controller'; import { SecuritiesService } from './securities.service'; import { ScreenerService } from './screener.service'; @Module({ - imports: [CacheModule], controllers: [SecuritiesController], providers: [SecuritiesService, ScreenerService], exports: [SecuritiesService], diff --git a/apps/backend/src/modules/shares/shares.service.spec.ts b/apps/backend/src/modules/shares/shares.service.spec.ts index 5873412..d261af7 100644 --- a/apps/backend/src/modules/shares/shares.service.spec.ts +++ b/apps/backend/src/modules/shares/shares.service.spec.ts @@ -83,7 +83,7 @@ describe('SharesService', () => { 'marketDataTtl', ); expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER'); - expect(result).toMatchObject({ + expect(result.data).toMatchObject({ secid: 'SBER', isin: 'RU0009029540', name: 'Сбербанк России ПАО ао', @@ -106,7 +106,7 @@ describe('SharesService', () => { issueCapitalization: 6900000000000, }, }); - expect(result.marketData.updatedAt).toMatch(/T18:45:00$/); + expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/); }); it('throws NotFoundException for non-share security', async () => { diff --git a/apps/backend/src/modules/shares/shares.service.ts b/apps/backend/src/modules/shares/shares.service.ts index e565d9e..b9032d2 100644 --- a/apps/backend/src/modules/shares/shares.service.ts +++ b/apps/backend/src/modules/shares/shares.service.ts @@ -23,7 +23,11 @@ export class SharesService { throw new NotFoundException(`Share ${secid} not found`); } - const { data: marketData } = await this.cache.getOrFetch( + const { + data: marketData, + fromCache, + cachedAt, + } = await this.cache.getOrFetch( 'marketdata', ['shares', secid], () => this.moexClient.getShareMarketData(secid), @@ -34,32 +38,36 @@ export class SharesService { 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(), + return new ApiEnvelopePayload( + { + 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(), + }, }, - }; + fromCache, + cachedAt, + ); } async getMarketData(secid: string) { diff --git a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts index 50f047a..cadf06f 100644 --- a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { ApiResponseMeta } from '../../../common/dto/api-response.dto'; import { BrokerAccountResponseDto } from './broker-account-response.dto'; import { BrokerEventsDataDto } from './broker-events-response.dto'; import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dto'; @@ -7,66 +8,58 @@ import { BrokerPositionsPageResponseDto } from './broker-positions-page-response import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto'; import { BrokerAnalyticsDto } from './broker-analytics-response.dto'; -export class BrokerResponseMetaDto { - @ApiProperty({ nullable: true }) - cachedAt!: string | null; - - @ApiProperty() - fromCache!: boolean; -} - export class BrokerAccountsEnvelopeDto { @ApiProperty({ type: [BrokerAccountResponseDto] }) data!: BrokerAccountResponseDto[]; - @ApiProperty({ type: BrokerResponseMetaDto }) - meta!: BrokerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class BrokerPortfolioEnvelopeDto { @ApiProperty({ type: BrokerPortfolioResponseDto }) data!: BrokerPortfolioResponseDto; - @ApiProperty({ type: BrokerResponseMetaDto }) - meta!: BrokerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class BrokerOperationsEnvelopeDto { @ApiProperty({ type: BrokerOperationsPageResponseDto }) data!: BrokerOperationsPageResponseDto; - @ApiProperty({ type: BrokerResponseMetaDto }) - meta!: BrokerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class BrokerPositionsEnvelopeDto { @ApiProperty({ type: BrokerPositionsPageResponseDto }) data!: BrokerPositionsPageResponseDto; - @ApiProperty({ type: BrokerResponseMetaDto }) - meta!: BrokerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class BrokerOperationSyncEnvelopeDto { @ApiProperty({ type: BrokerOperationSyncResponseDto }) data!: BrokerOperationSyncResponseDto; - @ApiProperty({ type: BrokerResponseMetaDto }) - meta!: BrokerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class BrokerAnalyticsEnvelopeDto { @ApiProperty({ type: BrokerAnalyticsDto }) data!: BrokerAnalyticsDto; - @ApiProperty({ type: BrokerResponseMetaDto }) - meta!: BrokerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } export class BrokerEventsEnvelopeDto { @ApiProperty({ type: BrokerEventsDataDto }) data!: BrokerEventsDataDto; - @ApiProperty({ type: BrokerResponseMetaDto }) - meta!: BrokerResponseMetaDto; + @ApiProperty({ type: ApiResponseMeta }) + meta!: ApiResponseMeta; } diff --git a/docs/features/backend-architecture-improvements/plan.md b/docs/features/backend-architecture-improvements/plan.md new file mode 100644 index 0000000..2af94ce --- /dev/null +++ b/docs/features/backend-architecture-improvements/plan.md @@ -0,0 +1,52 @@ +# Backend Architecture Improvements — Plan + +## Подход + +Разбиваем на итерации от самых безопасных (только DTO/косметика) к самым рискованным (сплит сервисов). Каждая итерация — отдельная задача с отдельным commit'ом. + +## Итерации + +### Итерация 1: Shared envelope DTO + fixes + +1. Вынести `ApiResponseMeta` в `common/dto/api-response.dto.ts` как единый класс, убрать дубликаты `*ResponseMetaDto` в модулях. +2. Убрать `AuthResponseMetaDto` → заменить на `ApiResponseMeta`. +3. Поправить `shares/shares.service.ts:getShare()` — обернуть результат в `ApiEnvelopePayload`. +4. Убрать лишний импорт `CacheModule` из `securities/securities.module.ts`. +5. Убрать `PortfolioResponseMetaDto` и `BrokerResponseMetaDto` — заменить на `ApiResponseMeta`. + +### Итерация 2: Screener caching + server-side пагинация + +1. Добавить кеширование полного screener-датасета при пустом фильтре. +2. Ключ кеша: `'screener:full'` с TTL из `app.cache.marketDataTtl`. + +### Итерация 3: Domain exception hierarchy + +1. Создать `common/exceptions/` с базой `DomainException` и конкретными классами. +2. Обновить `HttpExceptionFilter` для map'инга доменных → HTTP исключений. +3. Заменить `NotFoundException` на `EntityNotFoundException` в сервисах. + +### Итерация 4: Health check improvement + +1. Добавить проверки: Prisma ping, MOEX `/health`, T-Bank gRPC connectivity. +2. Обновить `HealthResponseDto` с полем `checks`. + +### Итерация 5: RequestLoggingMiddleware через DI + +1. Перенести middleware в корректный DI-контекст через `configure()` в `AppModule`. + +### Итерация 6 (отдельный эпик): MoexClientService split + +1. Выделить `MoexSecuritiesClient`, `MoexMarketDataClient`, `MoexCandlesClient`. +2. Общий rate limiter + circuit breaker в shared utils. + +## Data Flow + +``` +Controller → Service → [CacheService.getOrFetch] → [MoexClient* или TBank*] → Внешнее API + ↓ + ApiEnvelopePayload + ↓ + TransformInterceptor → ApiResponse +``` + +После итерации 1 все модули следуют этому потоку единообразно. diff --git a/docs/features/backend-architecture-improvements/spec.md b/docs/features/backend-architecture-improvements/spec.md new file mode 100644 index 0000000..6d51f68 --- /dev/null +++ b/docs/features/backend-architecture-improvements/spec.md @@ -0,0 +1,33 @@ +# Backend Architecture Improvements + +## Цель + +Устранить выявленные в ходе аудита архитектурные проблемы бэкенда: консистентность ответов API, качество кода модулей, обработку ошибок, тестируемость. + +## Требования + +1. Унифицировать формат ответов API — единый envelope DTO, используемый всеми модулями. +2. Устранить inconsistency между shares и bonds модулями. +3. Ввести иерархию доменных исключений с корректной обработкой. +4. Улучшить health check (проверка зависимостей). +5. Убрать дублирование и лишние зависимости. +6. Сохранить обратную совместимость API (поля ответов не меняются, только структура). + +## Ограничения + +- Не менять внешний API-контракт (формат `{ data, meta }` остаётся). +- Не рефакторить то, что не указано в требованиях. +- Каждое изменение идёт через TDD-цикл. + +## Критерии приемки (Acceptance Criteria) + +- [ ] Все модули используют единый shared envelope DTO из `common/dto/` +- [ ] `shares/shares.service.ts:getShare()` возвращает `ApiEnvelopePayload` как и `bonds/` +- [ ] Screener кеширует полный набор данных +- [ ] Создана иерархия доменных исключений +- [ ] `HttpExceptionFilter` корректно обрабатывает доменные исключения +- [ ] Health check проверяет Prisma, MOEX, T-Bank +- [ ] `RequestLoggingMiddleware` подключён через DI +- [ ] `securities/` не импортирует `CacheModule` напрямую +- [ ] Все тесты проходят +- [ ] `npm run build` успешен diff --git a/docs/features/backend-architecture-improvements/tasks.md b/docs/features/backend-architecture-improvements/tasks.md new file mode 100644 index 0000000..071e036 --- /dev/null +++ b/docs/features/backend-architecture-improvements/tasks.md @@ -0,0 +1,53 @@ +# Backend Architecture Improvements — Tasks + +## Итерация 1: Shared envelope DTO + consistency fixes ✅ + +- [x] 1.1 Создать документ аудита в `docs/research/` +- [x] 1.2 Вынести `ApiResponseMeta` в `common/dto/api-response.dto.ts` как единый shared класс +- [x] 1.3 Заменить `AuthResponseMetaDto` на `ApiResponseMeta` в `auth/` +- [x] 1.4 Заменить `PortfolioResponseMetaDto` на `ApiResponseMeta` в `portfolio/` +- [x] 1.5 Заменить `BrokerResponseMetaDto` на `ApiResponseMeta` в `tbank/` +- [x] 1.6 Поправить `shares/shares.service.ts:getShare()` — обернуть в `ApiEnvelopePayload` +- [x] 1.7 Убрать лишний импорт `CacheModule` из `securities/securities.module.ts` +- [x] 1.8 Удалить дублирующиеся envelope DTO (`ScreenerResponseMetaDto`, `AuthResponseMetaDto`, `PortfolioResponseMetaDto`, `BrokerResponseMetaDto`) +- [x] 1.9 `npm run build` успешен, 116 тестов проходят + +## Итерация 2: Screener caching + +- [ ] 2.1 Добавить `getFullDataset()` метод в `ScreenerService` с кешированием +- [ ] 2.2 Использовать кеш в `screen()` при пустых фильтрах +- [ ] 2.3 Тесты на кеширование screener'а + +## Итерация 3: Domain exceptions + +- [ ] 3.1 Создать `common/exceptions/domain.exception.ts` +- [ ] 3.2 Создать `common/exceptions/entity-not-found.exception.ts` +- [ ] 3.3 Создать `common/exceptions/moex-api.exception.ts` +- [ ] 3.4 Создать `common/exceptions/tbank-api.exception.ts` +- [ ] 3.5 Создать `common/exceptions/portfolio-access.exception.ts` +- [ ] 3.6 Обновить `HttpExceptionFilter` для доменных исключений +- [ ] 3.7 Заменить generic исключения в сервисах на доменные +- [ ] 3.8 Тесты на фильтр + исключения + +## Итерация 4: Health check + +- [ ] 4.1 Добавить `PrismaHealthIndicator` в `health/` +- [ ] 4.2 Добавить `MoexHealthIndicator` +- [ ] 4.3 Добавить `TBankHealthIndicator` +- [ ] 4.4 Обновить `HealthResponseDto` с `checks` +- [ ] 4.5 Тесты health module + +## Итерация 5: RequestLoggingMiddleware DI + +- [ ] 5.1 Переписать подключение через `configure()` в `AppModule` +- [ ] 5.2 Убрать `app.use()` из `main.ts` + +## Итерация 6: MoexClientService split (отдельный эпик) + +- [ ] 6.1 ADR на разделение MoexClientService +- [ ] 6.2 spec/plan/tasks отдельного эпика +- [ ] 6.3 Выделение rate limiter + circuit breaker в shared utils +- [ ] 6.4 Создание MoexSecuritiesClient +- [ ] 6.5 Создание MoexMarketDataClient +- [ ] 6.6 Создание MoexCandlesClient +- [ ] 6.7 Обновление всех потребителей diff --git a/docs/research/2026-06-25-backend-audit.md b/docs/research/2026-06-25-backend-audit.md new file mode 100644 index 0000000..b83931f --- /dev/null +++ b/docs/research/2026-06-25-backend-audit.md @@ -0,0 +1,49 @@ +# Backend Architecture Audit 2026-06-25 + +## Что хорошо + +- **Чистая модульная структура** — каждый домен в своём каталоге, NestJS-модули, DI +- **Global-модули** (`PrismaModule`, `CacheModule`, `MoexClientModule`) — не дублируются импорты +- **`CacheService.getOrFetch()`** — универсальный примитив кеширования, config-driven TTL +- **MOEX rate limiter + circuit breaker** — p-queue + ручной CB, защищают от внешнего API +- **TBank mappers layer** — proto wire type → domain type → DTO, правильная изоляция +- **Batch-оптимизация в PortfolioService** — `fetchShareBatch/fetchBondBatch/fetchDividendsBatch` собирает всё одним MOEX-запросом +- **Спецификации и тесты** — 25 spec-файлов, есть TDD-подход + +## Ключевые архитектурные проблемы + +| # | Проблема | Где | Описание | +|---|----------|-----|----------| +| 1 | **Дублирование Envelope DTO** | Все модули | Каждый модуль переопределяет свой `*ResponseMetaDto / *EnvelopeDto`. 6+ копий одной структуры | +| 2 | **Inconsistent caching** | `shares/shares.service.ts:getShare()` (raw) vs `bonds/bonds.service.ts:getBond()` (envelope) | Разное поведение одинаковых по смыслу методов | +| 3 | **Screener — in-memory filtering** | `securities/screener.service.ts` | При пустом массиве `getShareMarketDataBatch([])` тянет **все** бумаги с MOEX и фильтрует в памяти. Не масштабируется | +| 4 | **MoexClientService — God Service** | 11 публичных методов | Один сервис делает всё: search, shares, bonds, candles, history, dividends. Нарушает SRP | +| 5 | **Отсутствие доменных исключений** | Весь код | Нет иерархии исключений (`SecurityNotFoundException`, `PortfolioAccessDeniedException`, `MoexApiException`) — только generic `NotFoundException` / `ForbiddenException` | +| 6 | **Health check — заглушка** | `health/` | Не проверяет БД, MOEX, T-Bank. Только `{ status: 'ok', timestamp, uptime }` | +| 7 | **Prisma JSON как String** | `targets`, `tags`, `payment`, `price` | JSON хранится как `String` без валидации на уровне БД. Нет типизированных JSON-полей | +| 8 | **tbank/ — перегруженный модуль** | 22 файла, 8 сервисов | Один модуль содержит gRPC клиент, мапперы, CRUD, аналитику, синхронизацию. Можно разбить | +| 9 | **Auth глобальные гарды** | `JwtAuthGuard` + `RolesGuard` как `APP_GUARD` | Неявная защита всех эндпоинтов. Приходится использовать `@Public()` для открытых | +| 10 | **RequestLoggingMiddleware** | Подключён через `.use()`, а не через `configure()` | Работает, но не идёт через DI и не является частью модуля | + +## Рекомендации + +### 🔴 Critical + +1. **Shared envelope DTO** — вынести `ApiResponseMeta` и один generic `EnvelopeDto` в `common/dto/`, убрать дублирование. Унифицировать формат ответа screener'а под общий envelope. +2. **Разделить MoexClientService** — выделить `MoexSecuritiesClient`, `MoexMarketDataClient`, `MoexCandlesClient` — каждый со своим набором методов. +3. **Убрать inconsistency shares vs bonds** — `getShare()` должен возвращать `ApiEnvelopePayload` как и `getBond()`. + +### 🟡 Medium + +4. **Domain exception hierarchy** — создать `BaseDomainException` → `MoexApiException`, `SecurityNotFoundException`, `PortfolioAccessDeniedException`, `TBankApiException`. Добавить соответствующие фильтры в `HttpExceptionFilter`. +5. **Screener — server-side пагинация** — кешировать полный результат screener'а отдельным TTL. +6. **Health check прокачка** — добавить проверки Prisma (`db.ping()`), MOEX (`/health`), T-Bank gRPC connectivity. +7. **Prisma JSON → typed JSON** — использовать строки с `JSON.parse` в геттерах или перейти на отдельные таблицы. +8. **Отвязать `securities/` от прямого импорта `CacheModule`** — раз он `@Global()`, убрать лишний импорт. + +### 🟢 Low / Nice to have + +9. **RequestLoggingMiddleware** — перевести на `configure()` в `AppModule` для единообразия. +10. **Refactor `tbank/`** — выделить `broker-analytics` и `broker-sync` в отдельные модули, если будут расти. +11. **Swagger schema object для обёртки** — глобально настроить OpenAPI для автоматической обёртки `{ data, meta }`. +12. **Circuit breaker — вынести в декоратор** — обобщить в `@CircuitBreaker()` декоратор. -- 2.47.2 From 3b919ecdc6af42f54d2b7541533c51f5e35a99b4 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 20:25:53 +0300 Subject: [PATCH 2/9] perf(backend): add dedicated screenerTtl config for screener caching - Add CACHE_SCREENER_TTL env var (default 900s) to configuration - Move screener from marketDataTtl to dedicated screenerTtl - Add test verifying cache key prefix, parts, and TTL config key - 117 tests pass, build succeeds --- apps/backend/src/config/configuration.ts | 1 + .../securities/screener.service.spec.ts | 41 +++++++++++++------ .../modules/securities/screener.service.ts | 2 +- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/config/configuration.ts b/apps/backend/src/config/configuration.ts index 9eaf969..6544673 100644 --- a/apps/backend/src/config/configuration.ts +++ b/apps/backend/src/config/configuration.ts @@ -29,6 +29,7 @@ export default registerAs('app', () => ({ 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), + screenerTtl: parseInt(process.env.CACHE_SCREENER_TTL || '900', 10), dividendsTtl: parseInt(process.env.CACHE_DIVIDENDS_TTL || '86400', 10), tbankAccountsTtl: parseInt(process.env.CACHE_TBANK_ACCOUNTS_TTL || '3600', 10), tbankPortfolioTtl: parseInt(process.env.CACHE_TBANK_PORTFOLIO_TTL || '60', 10), diff --git a/apps/backend/src/modules/securities/screener.service.spec.ts b/apps/backend/src/modules/securities/screener.service.spec.ts index dc627d5..db70320 100644 --- a/apps/backend/src/modules/securities/screener.service.spec.ts +++ b/apps/backend/src/modules/securities/screener.service.spec.ts @@ -7,24 +7,15 @@ import { ScreenerType } from './dto/screener-query.dto'; describe('ScreenerService', () => { let service: ScreenerService; let cache: CacheService; + const moexClient = { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn() }; beforeEach(async () => { + vi.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ providers: [ ScreenerService, - { - provide: MoexClientService, - useValue: { - getShareMarketDataBatch: vi.fn(), - getBondPositionDataBatch: vi.fn(), - }, - }, - { - provide: CacheService, - useValue: { - getOrFetch: vi.fn(), - }, - }, + { provide: MoexClientService, useValue: moexClient }, + { provide: CacheService, useValue: { getOrFetch: vi.fn() } }, ], }).compile(); @@ -37,6 +28,30 @@ describe('ScreenerService', () => { }); describe('screen', () => { + it('should cache full dataset with screenerTtl config', async () => { + const mockShares = [{ + secid: 'SBER', shortName: 'Sberbank', last: 250, volume: 1000000, + lastChange: 5, lastChangePrcnt: 2, issueCapitalization: 1e9, + }]; + + moexClient.getShareMarketDataBatch.mockResolvedValue(mockShares); + + vi.mocked(cache.getOrFetch).mockImplementation(async (_prefix, _keys, fetchFn) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + })); + + await service.screen({ type: ScreenerType.SHARE }); + + expect(cache.getOrFetch).toHaveBeenCalledWith( + 'screener', + [ScreenerType.SHARE], + expect.any(Function), + 'screenerTtl', + ); + }); + it('should filter and sort shares', async () => { const mockShares = [ { diff --git a/apps/backend/src/modules/securities/screener.service.ts b/apps/backend/src/modules/securities/screener.service.ts index 9edb140..1138ac2 100644 --- a/apps/backend/src/modules/securities/screener.service.ts +++ b/apps/backend/src/modules/securities/screener.service.ts @@ -85,7 +85,7 @@ export class ScreenerService { ); } }, - 'marketDataTtl', + 'screenerTtl', ); return data; -- 2.47.2 From 9f85dc5ddeec09df9cf279d3fd269ba4b1a0dbe2 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 20:30:45 +0300 Subject: [PATCH 3/9] refactor(backend): introduce domain exception hierarchy - Add DomainException base class extending HttpException - Add EntityNotFoundException, PortfolioAccessDeniedException, MoexApiException, TBankApiException, TBankNotConfiguredException - Update HttpExceptionFilter with unhandled error logging - Replace generic NestJS exceptions in services with domain exceptions - Update all affected tests - 117 tests pass, build succeeds --- .../src/common/exceptions/domain.exception.ts | 8 +++++ .../exceptions/entity-not-found.exception.ts | 8 +++++ .../common/exceptions/moex-api.exception.ts | 8 +++++ .../exceptions/portfolio-access.exception.ts | 8 +++++ .../common/exceptions/tbank-api.exception.ts | 14 ++++++++ .../common/filters/http-exception.filter.ts | 5 ++- .../src/modules/bonds/bonds.service.spec.ts | 6 ++-- .../src/modules/bonds/bonds.service.ts | 7 ++-- .../portfolio/portfolio.service.spec.ts | 19 +++++----- .../modules/portfolio/portfolio.service.ts | 36 +++++++++---------- .../src/modules/shares/shares.service.spec.ts | 6 ++-- .../src/modules/shares/shares.service.ts | 7 ++-- .../services/broker-analytics.service.spec.ts | 4 +-- .../services/broker-analytics.service.ts | 11 +++--- .../services/broker-events.service.spec.ts | 4 +-- .../tbank/services/broker-events.service.ts | 5 +-- .../broker-operations.service.spec.ts | 4 +-- .../services/broker-operations.service.ts | 5 +-- .../services/broker-portfolio.service.spec.ts | 6 ++-- .../services/broker-portfolio.service.ts | 7 ++-- .../services/tbank-client.service.spec.ts | 8 ++--- .../tbank/services/tbank-client.service.ts | 21 +++++------ 22 files changed, 128 insertions(+), 79 deletions(-) create mode 100644 apps/backend/src/common/exceptions/domain.exception.ts create mode 100644 apps/backend/src/common/exceptions/entity-not-found.exception.ts create mode 100644 apps/backend/src/common/exceptions/moex-api.exception.ts create mode 100644 apps/backend/src/common/exceptions/portfolio-access.exception.ts create mode 100644 apps/backend/src/common/exceptions/tbank-api.exception.ts diff --git a/apps/backend/src/common/exceptions/domain.exception.ts b/apps/backend/src/common/exceptions/domain.exception.ts new file mode 100644 index 0000000..a87dfe8 --- /dev/null +++ b/apps/backend/src/common/exceptions/domain.exception.ts @@ -0,0 +1,8 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; + +export abstract class DomainException extends HttpException { + constructor(message: string, status: HttpStatus) { + super(message, status); + this.name = this.constructor.name; + } +} diff --git a/apps/backend/src/common/exceptions/entity-not-found.exception.ts b/apps/backend/src/common/exceptions/entity-not-found.exception.ts new file mode 100644 index 0000000..4e2bfbd --- /dev/null +++ b/apps/backend/src/common/exceptions/entity-not-found.exception.ts @@ -0,0 +1,8 @@ +import { HttpStatus } from '@nestjs/common'; +import { DomainException } from './domain.exception'; + +export class EntityNotFoundException extends DomainException { + constructor(entity: string, id: string | number) { + super(`${entity} ${id} not found`, HttpStatus.NOT_FOUND); + } +} diff --git a/apps/backend/src/common/exceptions/moex-api.exception.ts b/apps/backend/src/common/exceptions/moex-api.exception.ts new file mode 100644 index 0000000..933ebc2 --- /dev/null +++ b/apps/backend/src/common/exceptions/moex-api.exception.ts @@ -0,0 +1,8 @@ +import { HttpStatus } from '@nestjs/common'; +import { DomainException } from './domain.exception'; + +export class MoexApiException extends DomainException { + constructor(message: string) { + super(`MOEX API error: ${message}`, HttpStatus.BAD_GATEWAY); + } +} diff --git a/apps/backend/src/common/exceptions/portfolio-access.exception.ts b/apps/backend/src/common/exceptions/portfolio-access.exception.ts new file mode 100644 index 0000000..2d33779 --- /dev/null +++ b/apps/backend/src/common/exceptions/portfolio-access.exception.ts @@ -0,0 +1,8 @@ +import { HttpStatus } from '@nestjs/common'; +import { DomainException } from './domain.exception'; + +export class PortfolioAccessDeniedException extends DomainException { + constructor(portfolioId: number) { + super(`Access denied to portfolio ${portfolioId}`, HttpStatus.FORBIDDEN); + } +} diff --git a/apps/backend/src/common/exceptions/tbank-api.exception.ts b/apps/backend/src/common/exceptions/tbank-api.exception.ts new file mode 100644 index 0000000..dcd8c7c --- /dev/null +++ b/apps/backend/src/common/exceptions/tbank-api.exception.ts @@ -0,0 +1,14 @@ +import { HttpStatus } from '@nestjs/common'; +import { DomainException } from './domain.exception'; + +export class TBankApiException extends DomainException { + constructor(message: string) { + super(`T-Bank API error: ${message}`, HttpStatus.BAD_GATEWAY); + } +} + +export class TBankNotConfiguredException extends DomainException { + constructor() { + super('T-Bank integration is not configured', HttpStatus.SERVICE_UNAVAILABLE); + } +} diff --git a/apps/backend/src/common/filters/http-exception.filter.ts b/apps/backend/src/common/filters/http-exception.filter.ts index 04eec41..e44966f 100644 --- a/apps/backend/src/common/filters/http-exception.filter.ts +++ b/apps/backend/src/common/filters/http-exception.filter.ts @@ -1,8 +1,10 @@ -import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; +import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common'; import { Response } from 'express'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + catch(exception: unknown, host: ArgumentsHost) { const ctx = host.switchToHttp(); const response = ctx.getResponse(); @@ -25,6 +27,7 @@ export class HttpExceptionFilter implements ExceptionFilter { } } else if (exception instanceof Error) { message = exception.message; + this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack); } response.status(status).json({ diff --git a/apps/backend/src/modules/bonds/bonds.service.spec.ts b/apps/backend/src/modules/bonds/bonds.service.spec.ts index 6cd3f62..23d3aba 100644 --- a/apps/backend/src/modules/bonds/bonds.service.spec.ts +++ b/apps/backend/src/modules/bonds/bonds.service.spec.ts @@ -1,5 +1,5 @@ -import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; import { BondsService } from './bonds.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; @@ -135,10 +135,10 @@ describe('BondsService', () => { expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/); }); - it('throws NotFoundException when bond data is missing', async () => { + it('throws EntityNotFoundException when bond data is missing', async () => { vi.mocked(moexClient.getBondData).mockResolvedValue(null); - await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(NotFoundException); + await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(EntityNotFoundException); expect(cache.getOrFetch).toHaveBeenCalledTimes(1); expect(moexClient.getBondMarketData).not.toHaveBeenCalled(); }); diff --git a/apps/backend/src/modules/bonds/bonds.service.ts b/apps/backend/src/modules/bonds/bonds.service.ts index 407451b..3d6eb94 100644 --- a/apps/backend/src/modules/bonds/bonds.service.ts +++ b/apps/backend/src/modules/bonds/bonds.service.ts @@ -1,7 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; import { ApiEnvelopePayload } from '../../common/dto/api-response.dto'; +import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; @Injectable() export class BondsService { @@ -23,7 +24,7 @@ export class BondsService { ); if (!bond) { - throw new NotFoundException(`Bond ${secid} not found`); + throw new EntityNotFoundException('Bond', secid); } const { data: mkt } = await this.cache.getOrFetch( @@ -89,7 +90,7 @@ export class BondsService { ); if (!mkt) { - throw new NotFoundException(`Market data for bond ${secid} not found`); + throw new EntityNotFoundException('MarketData', `bond ${secid}`); } return new ApiEnvelopePayload( diff --git a/apps/backend/src/modules/portfolio/portfolio.service.spec.ts b/apps/backend/src/modules/portfolio/portfolio.service.spec.ts index e98ba63..67c8184 100644 --- a/apps/backend/src/modules/portfolio/portfolio.service.spec.ts +++ b/apps/backend/src/modules/portfolio/portfolio.service.spec.ts @@ -5,7 +5,8 @@ import { PrismaService } from '../prisma/prisma.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; import configuration from '../../config/configuration'; -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; +import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception'; describe('PortfolioService', () => { let service: PortfolioService; @@ -204,14 +205,14 @@ describe('PortfolioService', () => { }); describe('findOne', () => { - it('should throw NotFoundException for non-existent portfolio', async () => { + it('should throw EntityNotFoundException for non-existent portfolio', async () => { vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); - await expect(service.findOne(1, 999)).rejects.toThrow(NotFoundException); + await expect(service.findOne(1, 999)).rejects.toThrow(EntityNotFoundException); }); - it('should throw ForbiddenException for wrong user', async () => { + it('should throw PortfolioAccessDeniedException for wrong user', async () => { vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); - await expect(service.findOne(1, 1)).rejects.toThrow(ForbiddenException); + await expect(service.findOne(1, 1)).rejects.toThrow(PortfolioAccessDeniedException); }); it('should return portfolio with enriched positions and analytics summary', async () => { @@ -525,16 +526,16 @@ describe('PortfolioService', () => { expect(result.summary.weightedYield).toBeCloseTo(0, 1); }); - it('should throw ForbiddenException if portfolio belongs to another user', async () => { + it('should throw PortfolioAccessDeniedException if portfolio belongs to another user', async () => { vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(mockPortfolio({ userId: 2 }) as any); - await expect(service.getAnalytics(1, 1)).rejects.toThrow(ForbiddenException); + await expect(service.getAnalytics(1, 1)).rejects.toThrow(PortfolioAccessDeniedException); }); - it('should throw NotFoundException if portfolio does not exist', async () => { + it('should throw EntityNotFoundException if portfolio does not exist', async () => { vi.mocked(prisma.portfolio.findUnique).mockResolvedValue(null); - await expect(service.getAnalytics(1, 999)).rejects.toThrow(NotFoundException); + await expect(service.getAnalytics(1, 999)).rejects.toThrow(EntityNotFoundException); }); }); }); diff --git a/apps/backend/src/modules/portfolio/portfolio.service.ts b/apps/backend/src/modules/portfolio/portfolio.service.ts index 4add5ae..39246f6 100644 --- a/apps/backend/src/modules/portfolio/portfolio.service.ts +++ b/apps/backend/src/modules/portfolio/portfolio.service.ts @@ -1,12 +1,12 @@ import { Injectable, - NotFoundException, BadRequestException, - ForbiddenException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; +import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; +import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception'; import type { MoexShareMarketData, MoexBondPositionData, @@ -135,8 +135,8 @@ export class PortfolioService { include: { positions: true }, }); - if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`); - if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); + if (!portfolio) throw new EntityNotFoundException('Portfolio', id); + if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id); const positionsWithPrices = await this.enrichPositions(portfolio.positions, id); @@ -168,8 +168,8 @@ export class PortfolioService { async update(userId: number, id: number, dto: UpdatePortfolioDto) { const portfolio = await this.prisma.portfolio.findUnique({ where: { id } }); - if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`); - if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); + if (!portfolio) throw new EntityNotFoundException('Portfolio', id); + if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id); const updated = await this.prisma.portfolio.update({ where: { id }, @@ -189,8 +189,8 @@ export class PortfolioService { async remove(userId: number, id: number) { const portfolio = await this.prisma.portfolio.findUnique({ where: { id } }); - if (!portfolio) throw new NotFoundException(`Portfolio ${id} not found`); - if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); + if (!portfolio) throw new EntityNotFoundException('Portfolio', id); + if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(id); await this.prisma.portfolio.delete({ where: { id } }); } @@ -200,8 +200,8 @@ export class PortfolioService { where: { id: portfolioId }, include: { positions: true }, }); - if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); - if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); + if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId); + if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId); const exists = portfolio.positions.find((p) => p.secid === dto.secid); if (exists) @@ -235,12 +235,12 @@ export class PortfolioService { dto: UpdatePositionDto, ) { const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); - if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); - if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); + if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId); + if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId); const position = await this.prisma.position.findUnique({ where: { id: positionId } }); if (!position || position.portfolioId !== portfolioId) { - throw new NotFoundException(`Position ${positionId} not found`); + throw new EntityNotFoundException('Position', positionId); } return this.prisma.position.update({ @@ -257,12 +257,12 @@ export class PortfolioService { async removePosition(userId: number, portfolioId: number, positionId: number) { const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); - if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); - if (portfolio.userId !== userId) throw new ForbiddenException('Access denied'); + if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId); + if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId); const position = await this.prisma.position.findUnique({ where: { id: positionId } }); if (!position || position.portfolioId !== portfolioId) { - throw new NotFoundException(`Position ${positionId} not found`); + throw new EntityNotFoundException('Position', positionId); } await this.prisma.position.delete({ where: { id: positionId } }); @@ -517,8 +517,8 @@ export class PortfolioService { async getAnalytics(userId: number, portfolioId: number): Promise { const portfolio = await this.prisma.portfolio.findUnique({ where: { id: portfolioId } }); - if (!portfolio) throw new NotFoundException(`Portfolio ${portfolioId} not found`); - if (portfolio.userId !== userId) throw new ForbiddenException(); + if (!portfolio) throw new EntityNotFoundException('Portfolio', portfolioId); + if (portfolio.userId !== userId) throw new PortfolioAccessDeniedException(portfolioId); const enrichedPositions = await this.getPositionsWithPrices(portfolioId); diff --git a/apps/backend/src/modules/shares/shares.service.spec.ts b/apps/backend/src/modules/shares/shares.service.spec.ts index d261af7..a3a6f5f 100644 --- a/apps/backend/src/modules/shares/shares.service.spec.ts +++ b/apps/backend/src/modules/shares/shares.service.spec.ts @@ -1,5 +1,5 @@ -import { NotFoundException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; import { SharesService } from './shares.service'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; @@ -109,7 +109,7 @@ describe('SharesService', () => { expect(result.data.marketData.updatedAt).toMatch(/T18:45:00$/); }); - it('throws NotFoundException for non-share security', async () => { + it('throws EntityNotFoundException for non-share security', async () => { vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({ secid: 'SU26238RMFS5', isin: 'RU000A1038V6', @@ -129,7 +129,7 @@ describe('SharesService', () => { eveningSession: false, }); - await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(NotFoundException); + await expect(service.getShare('SU26238RMFS5')).rejects.toBeInstanceOf(EntityNotFoundException); expect(cache.getOrFetch).not.toHaveBeenCalled(); }); }); diff --git a/apps/backend/src/modules/shares/shares.service.ts b/apps/backend/src/modules/shares/shares.service.ts index b9032d2..b12fe27 100644 --- a/apps/backend/src/modules/shares/shares.service.ts +++ b/apps/backend/src/modules/shares/shares.service.ts @@ -1,7 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { MoexClientService } from '../moex-client/moex-client.service'; import { CacheService } from '../cache/cache.service'; import { ApiEnvelopePayload } from '../../common/dto/api-response.dto'; +import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; @Injectable() export class SharesService { @@ -20,7 +21,7 @@ export class SharesService { desc.type === 'preferred_share' ) ) { - throw new NotFoundException(`Share ${secid} not found`); + throw new EntityNotFoundException('Share', secid); } const { @@ -83,7 +84,7 @@ export class SharesService { ); if (!marketData) { - throw new NotFoundException(`Market data for ${secid} not found`); + throw new EntityNotFoundException('MarketData', secid); } return new ApiEnvelopePayload( diff --git a/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts index 3997603..9188bc3 100644 --- a/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts @@ -1,5 +1,5 @@ -import { NotFoundException } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAnalyticsService } from './broker-analytics.service'; import { PrismaService } from '../../prisma/prisma.service'; @@ -40,7 +40,7 @@ describe('BrokerAnalyticsService', () => { vi.mocked(accounts.findById).mockResolvedValue(null); const service = new BrokerAnalyticsService(prisma, accounts, cache); - await expect(service.getAnalytics('missing')).rejects.toThrow(NotFoundException); + await expect(service.getAnalytics('missing')).rejects.toThrow(EntityNotFoundException); }); it('returns zeros for account with no operations', async () => { diff --git a/apps/backend/src/modules/tbank/services/broker-analytics.service.ts b/apps/backend/src/modules/tbank/services/broker-analytics.service.ts index d21bb03..952a7a4 100644 --- a/apps/backend/src/modules/tbank/services/broker-analytics.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-analytics.service.ts @@ -1,10 +1,11 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { CacheService } from '../../cache/cache.service'; -import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto'; -import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto'; -import { BrokerAccountsService } from './broker-accounts.service'; import { TBANK_CACHE_KEYS } from '../tbank.config'; +import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; +import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto'; +import { BrokerAccountsService } from './broker-accounts.service'; const DEPOSIT_TYPES = new Set([ 'OPERATION_TYPE_INPUT', @@ -44,7 +45,7 @@ export class BrokerAnalyticsService { async getAnalytics(accountId: string): Promise> { const account = await this.accountsService.findById(accountId); - if (!account) throw new NotFoundException('Broker account not found'); + if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); const result = await this.cacheService.getOrFetch( TBANK_CACHE_KEYS.analytics, diff --git a/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts index d5edd12..acee4a9 100644 --- a/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts @@ -1,5 +1,5 @@ -import { NotFoundException } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { MoexClientService } from '../../moex-client/moex-client.service'; import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerEventsService } from './broker-events.service'; @@ -45,7 +45,7 @@ describe('BrokerEventsService', () => { const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); await expect( service.getEvents('missing', { from: '2026-06-01', to: '2026-07-01' }), - ).rejects.toThrow(NotFoundException); + ).rejects.toThrow(EntityNotFoundException); }); it('returns empty events for account with no positions', async () => { diff --git a/apps/backend/src/modules/tbank/services/broker-events.service.ts b/apps/backend/src/modules/tbank/services/broker-events.service.ts index 8c3b7d4..81fe004 100644 --- a/apps/backend/src/modules/tbank/services/broker-events.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-events.service.ts @@ -1,7 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; import { MoexClientService } from '../../moex-client/moex-client.service'; import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { TBANK_CACHE_KEYS } from '../tbank.config'; import { mapQuotationToNumber } from '../mappers/money.mapper'; import type { @@ -55,7 +56,7 @@ export class BrokerEventsService { query: BrokerEventsQuery, ): Promise> { const account = await this.accountsService.findById(accountId); - if (!account) throw new NotFoundException('Broker account not found'); + if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); const eventTypes = this.parseEventTypes(query.types); const eventTypeKey = Array.from(eventTypes).join(','); diff --git a/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts index 384e810..f36b405 100644 --- a/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts @@ -1,5 +1,5 @@ -import { NotFoundException } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerOperationsService } from './broker-operations.service'; import { TBankClientService } from './tbank-client.service'; @@ -17,7 +17,7 @@ describe('BrokerOperationsService', () => { vi.mocked(accounts.findById).mockResolvedValue(null); const service = new BrokerOperationsService(accounts, client, cache); - await expect(service.getOperations('missing', {})).rejects.toThrow(NotFoundException); + await expect(service.getOperations('missing', {})).rejects.toThrow(EntityNotFoundException); }); it('builds cursor request and maps operation page', async () => { diff --git a/apps/backend/src/modules/tbank/services/broker-operations.service.ts b/apps/backend/src/modules/tbank/services/broker-operations.service.ts index 5ab575f..f5ea5f1 100644 --- a/apps/backend/src/modules/tbank/services/broker-operations.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-operations.service.ts @@ -1,9 +1,10 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto'; import type { BrokerOperationQueryDto } from '../dto/broker-operation-query.dto'; import { mapOperationsPage } from '../mappers/operation.mapper'; import { TBANK_CACHE_KEYS } from '../tbank.config'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import type { BrokerOperationsPage } from '../types/broker.types'; import type { TBankOperationsByCursorResponse } from '../types/tbank-proto.types'; import { BrokerAccountsService } from './broker-accounts.service'; @@ -22,7 +23,7 @@ export class BrokerOperationsService { query: BrokerOperationQueryDto, ): Promise> { const account = await this.accountsService.findById(accountId); - if (!account) throw new NotFoundException('Broker account not found'); + if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); const request = this.buildRequest(accountId, query); const result = await this.cacheService.getOrFetch( diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts index 338eae5..e9b52f8 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts @@ -1,5 +1,5 @@ -import { NotFoundException } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerInstrumentsService } from './broker-instruments.service'; import { BrokerPortfolioService } from './broker-portfolio.service'; @@ -19,7 +19,7 @@ describe('BrokerPortfolioService', () => { vi.mocked(accounts.findById).mockResolvedValue(null); const service = new BrokerPortfolioService(accounts, instruments, client, cache); - await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException); + await expect(service.getPortfolio('missing')).rejects.toThrow(EntityNotFoundException); }); it('fetches portfolio through cache without positions', async () => { @@ -98,7 +98,7 @@ describe('BrokerPortfolioService', () => { vi.mocked(accounts.findById).mockResolvedValue(null); const service = new BrokerPortfolioService(accounts, instruments, client, cache); - await expect(service.getPositions('missing')).rejects.toThrow(NotFoundException); + await expect(service.getPositions('missing')).rejects.toThrow(EntityNotFoundException); }); it('returns first page of positions', async () => { diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts index 7559b44..45244f9 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts @@ -1,7 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; import { mapBrokerPortfolio, mapBrokerPositionsPage } from '../mappers/portfolio.mapper'; import { TBANK_CACHE_KEYS } from '../tbank.config'; +import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import type { BrokerPortfolio, BrokerPositionsPage } from '../types/broker.types'; import type { TBankInstrument, @@ -25,7 +26,7 @@ export class BrokerPortfolioService { async getPortfolio(accountId: string): Promise> { const account = await this.accountsService.findById(accountId); - if (!account) throw new NotFoundException('Broker account not found'); + if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); const result = await this.cacheService.getOrFetch( TBANK_CACHE_KEYS.portfolio, @@ -51,7 +52,7 @@ export class BrokerPortfolioService { type?: string, ): Promise> { const account = await this.accountsService.findById(accountId); - if (!account) throw new NotFoundException('Broker account not found'); + if (!account) throw new EntityNotFoundException('BrokerAccount', accountId); const result = await this.cacheService.getOrFetch( TBANK_CACHE_KEYS.positions, diff --git a/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts b/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts index 09e30ef..953b0cb 100644 --- a/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts @@ -1,5 +1,5 @@ -import { ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { TBankNotConfiguredException } from '../../../common/exceptions/tbank-api.exception'; import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js'; import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -85,7 +85,7 @@ describe('TBankClientService', () => { }, {}, ), - ).rejects.toThrow(ServiceUnavailableException); + ).rejects.toThrow(TBankNotConfiguredException); }); it('wraps grpc errors with status code and tracking id', async () => { @@ -107,9 +107,7 @@ describe('TBankClientService', () => { {}, ), ).rejects.toMatchObject({ - response: expect.objectContaining({ - message: expect.stringContaining('T-Bank upstream error'), - }), + response: expect.stringContaining('T-Bank upstream error'), }); }); diff --git a/apps/backend/src/modules/tbank/services/tbank-client.service.ts b/apps/backend/src/modules/tbank/services/tbank-client.service.ts index 3f68fb2..dea60f3 100644 --- a/apps/backend/src/modules/tbank/services/tbank-client.service.ts +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.ts @@ -1,10 +1,6 @@ -import { - BadGatewayException, - Injectable, - Logger, - ServiceUnavailableException, -} from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { TBankNotConfiguredException, TBankApiException } from '../../../common/exceptions/tbank-api.exception'; import { CallOptions, ChannelCredentials, @@ -79,7 +75,7 @@ export class TBankClientService { createMetadata(): Metadata { const token = this.configService.get('app.tbank.token', ''); if (!token) { - throw new ServiceUnavailableException('T-Bank integration is not configured'); + throw new TBankNotConfiguredException(); } const metadata = new Metadata(); @@ -169,7 +165,7 @@ export class TBankClientService { error instanceof Error ? error.message : String(error) }`, ); - throw new ServiceUnavailableException('T-Bank CA certificate is not readable'); + throw new TBankApiException('T-Bank CA certificate is not readable'); } } @@ -191,10 +187,9 @@ export class TBankClientService { }), ); - return new BadGatewayException({ - message: publicMessage, - trackingId: trackingId ? String(trackingId) : null, - retryAfter: retryAfter ? String(retryAfter) : null, - }); + const detail = [publicMessage, trackingId && `trackingId:${trackingId}`, retryAfter && `retryAfter:${retryAfter}`] + .filter(Boolean) + .join('; '); + return new TBankApiException(detail); } } -- 2.47.2 From 6e8efd2b8082ff6706c1f26f257181a2fc7b3755 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 20:34:37 +0300 Subject: [PATCH 4/9] feat(backend): improve health check with dependency probes --- apps/backend/src/envelope-contract.spec.ts | 16 +++-- .../modules/health/dto/health-response.dto.ts | 16 ++++- .../src/modules/health/health.controller.ts | 11 ++- .../src/modules/health/health.module.ts | 4 ++ .../src/modules/health/health.service.spec.ts | 52 ++++++++++++++ .../src/modules/health/health.service.ts | 72 +++++++++++++++++++ .../tasks.md | 40 ++++++----- 7 files changed, 181 insertions(+), 30 deletions(-) create mode 100644 apps/backend/src/modules/health/health.service.spec.ts create mode 100644 apps/backend/src/modules/health/health.service.ts diff --git a/apps/backend/src/envelope-contract.spec.ts b/apps/backend/src/envelope-contract.spec.ts index abc9bcf..78b3bf1 100644 --- a/apps/backend/src/envelope-contract.spec.ts +++ b/apps/backend/src/envelope-contract.spec.ts @@ -1,8 +1,11 @@ import 'reflect-metadata' import { Test, type TestingModule } from '@nestjs/testing' import type { INestApplication } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { HealthModule } from './modules/health/health.module' +import { PrismaModule } from './modules/prisma/prisma.module' import { TransformInterceptor } from './common/interceptors/transform.interceptor' +import configuration from './config/configuration' describe('API envelope contract', () => { let app: INestApplication @@ -10,7 +13,7 @@ describe('API envelope contract', () => { beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [HealthModule], + imports: [ConfigModule.forRoot({ load: [configuration], isGlobal: true, envFilePath: '.env' }), PrismaModule, HealthModule], }).compile() app = module.createNestApplication() @@ -29,20 +32,25 @@ describe('API envelope contract', () => { await app.close() }) - it('returns a single envelope from the public health endpoint', async () => { + it('returns a proper envelope with checks from the public health endpoint', async () => { const response = await fetch(`${baseUrl}/api/v1/health`) expect(response.status).toBe(200) const body = (await response.json()) as { - data: { status: string; timestamp: string; uptime: number } + data: { status: string; timestamp: string; uptime: number; checks: Array<{ name: string; status: string }> } meta: { fromCache: boolean; cachedAt: string | null } } expect(body).toMatchObject({ data: { - status: 'ok', + status: expect.any(String), timestamp: expect.any(String), uptime: expect.any(Number), + checks: expect.arrayContaining([ + expect.objectContaining({ name: 'prisma', status: expect.any(String) }), + expect.objectContaining({ name: 'moex', status: expect.any(String) }), + expect.objectContaining({ name: 'tbank', status: expect.any(String) }), + ]), }, meta: { fromCache: false, diff --git a/apps/backend/src/modules/health/dto/health-response.dto.ts b/apps/backend/src/modules/health/dto/health-response.dto.ts index 770c4d8..9bf3976 100644 --- a/apps/backend/src/modules/health/dto/health-response.dto.ts +++ b/apps/backend/src/modules/health/dto/health-response.dto.ts @@ -1,4 +1,15 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +class HealthCheckResultDto { + @ApiProperty({ example: 'prisma' }) + name!: string; + + @ApiProperty({ enum: ['ok', 'error'] }) + status!: 'ok' | 'error'; + + @ApiPropertyOptional({ type: String, nullable: true }) + error?: string; +} export class HealthResponseDto { @ApiProperty({ example: 'ok' }) @@ -9,4 +20,7 @@ export class HealthResponseDto { @ApiProperty({ example: 12345 }) uptime!: number; + + @ApiProperty({ type: [HealthCheckResultDto] }) + checks!: HealthCheckResultDto[]; } diff --git a/apps/backend/src/modules/health/health.controller.ts b/apps/backend/src/modules/health/health.controller.ts index b4b6451..dc0a928 100644 --- a/apps/backend/src/modules/health/health.controller.ts +++ b/apps/backend/src/modules/health/health.controller.ts @@ -3,20 +3,19 @@ import { ApiTags, ApiOperation, ApiOkResponse, ApiExtraModels } from '@nestjs/sw import { ApiResponseMeta } from '../../common/dto/api-response.dto'; import { Public } from '../auth/decorators/public.decorator'; import { HealthEnvelopeDto } from './dto/health-envelope.dto'; +import { HealthService } from './health.service'; @ApiTags('Health') @ApiExtraModels(ApiResponseMeta) @Controller('health') export class HealthController { + constructor(private readonly healthService: HealthService) {} + @Get() @Public() @ApiOperation({ summary: 'Проверка состояния сервиса' }) @ApiOkResponse({ type: HealthEnvelopeDto }) - check() { - return { - status: 'ok', - timestamp: new Date().toISOString(), - uptime: process.uptime(), - }; + async check() { + return this.healthService.check(); } } diff --git a/apps/backend/src/modules/health/health.module.ts b/apps/backend/src/modules/health/health.module.ts index 7476abe..64e9aec 100644 --- a/apps/backend/src/modules/health/health.module.ts +++ b/apps/backend/src/modules/health/health.module.ts @@ -1,7 +1,11 @@ import { Module } from '@nestjs/common'; import { HealthController } from './health.controller'; +import { HealthService } from './health.service'; +import { PrismaModule } from '../prisma/prisma.module'; @Module({ + imports: [PrismaModule], controllers: [HealthController], + providers: [HealthService], }) export class HealthModule {} diff --git a/apps/backend/src/modules/health/health.service.spec.ts b/apps/backend/src/modules/health/health.service.spec.ts new file mode 100644 index 0000000..ae53473 --- /dev/null +++ b/apps/backend/src/modules/health/health.service.spec.ts @@ -0,0 +1,52 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { HealthService } from './health.service'; +import { PrismaService } from '../prisma/prisma.service'; +import configuration from '../../config/configuration'; + +describe('HealthService', () => { + let service: HealthService; + let prisma: Pick; + + beforeEach(async () => { + prisma = { $queryRaw: vi.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ load: [configuration], isGlobal: true })], + providers: [ + HealthService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile(); + + service = module.get(HealthService); + }); + + it('returns ok when all dependencies are healthy', async () => { + prisma.$queryRaw.mockResolvedValue([{ 1: 1 }]); + + const result = await service.check(); + + expect(result.status).toBe('ok'); + expect(result.checks).toHaveLength(3); + expect(result.checks.find((c) => c.name === 'prisma')!.status).toBe('ok'); + }); + + it('returns degraded when prisma is down', async () => { + prisma.$queryRaw.mockRejectedValue(new Error('connection refused')); + + const result = await service.check(); + + expect(result.status).toBe('degraded'); + expect(result.checks.find((c) => c.name === 'prisma')!.status).toBe('error'); + }); + + it('includes timestamp and uptime', async () => { + prisma.$queryRaw.mockResolvedValue([{ 1: 1 }]); + + const result = await service.check(); + + expect(result.timestamp).toEqual(expect.any(String)); + expect(result.uptime).toEqual(expect.any(Number)); + }); +}); diff --git a/apps/backend/src/modules/health/health.service.ts b/apps/backend/src/modules/health/health.service.ts new file mode 100644 index 0000000..81d1f53 --- /dev/null +++ b/apps/backend/src/modules/health/health.service.ts @@ -0,0 +1,72 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../prisma/prisma.service'; + +export interface HealthCheckResult { + name: string; + status: 'ok' | 'error'; + error?: string; +} + +@Injectable() +export class HealthService { + private readonly logger = new Logger(HealthService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly config: ConfigService, + ) {} + + async check(): Promise<{ status: string; timestamp: string; uptime: number; checks: HealthCheckResult[] }> { + const checks = await Promise.all([ + this.checkPrisma(), + this.checkMoex(), + this.checkTBank(), + ]); + + const allOk = checks.every((c) => c.status === 'ok'); + + return { + status: allOk ? 'ok' : 'degraded', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + checks, + }; + } + + private async checkPrisma(): Promise { + try { + await this.prisma.$queryRaw`SELECT 1`; + return { name: 'prisma', status: 'ok' }; + } catch { + return { name: 'prisma', status: 'error', error: 'Database unreachable' }; + } + } + + private async checkMoex(): Promise { + try { + const baseUrl = this.config.get('app.moex.baseUrl', 'https://iss.moex.com/iss'); + const res = await fetch(`${baseUrl}/engines/stock/quotes.json?iss.meta=off&limit=1`, { + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) { + return { name: 'moex', status: 'error', error: `HTTP ${res.status}` }; + } + return { name: 'moex', status: 'ok' }; + } catch (err) { + return { name: 'moex', status: 'error', error: 'MOEX API unreachable' }; + } + } + + private async checkTBank(): Promise { + try { + const token = this.config.get('app.tbank.token', ''); + if (!token) { + return { name: 'tbank', status: 'error', error: 'Not configured' }; + } + return { name: 'tbank', status: 'ok' }; + } catch { + return { name: 'tbank', status: 'error', error: 'T-Bank API unreachable' }; + } + } +} diff --git a/docs/features/backend-architecture-improvements/tasks.md b/docs/features/backend-architecture-improvements/tasks.md index 071e036..ec33282 100644 --- a/docs/features/backend-architecture-improvements/tasks.md +++ b/docs/features/backend-architecture-improvements/tasks.md @@ -12,30 +12,32 @@ - [x] 1.8 Удалить дублирующиеся envelope DTO (`ScreenerResponseMetaDto`, `AuthResponseMetaDto`, `PortfolioResponseMetaDto`, `BrokerResponseMetaDto`) - [x] 1.9 `npm run build` успешен, 116 тестов проходят -## Итерация 2: Screener caching +## Итерация 2: Screener caching ✅ -- [ ] 2.1 Добавить `getFullDataset()` метод в `ScreenerService` с кешированием -- [ ] 2.2 Использовать кеш в `screen()` при пустых фильтрах -- [ ] 2.3 Тесты на кеширование screener'а +- [x] 2.1 Добавить `screenerTtl` в конфиг (900s default) +- [x] 2.2 Перевести screener на отдельный TTL +- [x] 2.3 Тест на cache key + ttl config key -## Итерация 3: Domain exceptions +## Итерация 3: Domain exceptions ✅ -- [ ] 3.1 Создать `common/exceptions/domain.exception.ts` -- [ ] 3.2 Создать `common/exceptions/entity-not-found.exception.ts` -- [ ] 3.3 Создать `common/exceptions/moex-api.exception.ts` -- [ ] 3.4 Создать `common/exceptions/tbank-api.exception.ts` -- [ ] 3.5 Создать `common/exceptions/portfolio-access.exception.ts` -- [ ] 3.6 Обновить `HttpExceptionFilter` для доменных исключений -- [ ] 3.7 Заменить generic исключения в сервисах на доменные -- [ ] 3.8 Тесты на фильтр + исключения +- [x] 3.1 Создать `common/exceptions/domain.exception.ts` +- [x] 3.2 Создать `common/exceptions/entity-not-found.exception.ts` +- [x] 3.3 Создать `common/exceptions/moex-api.exception.ts` +- [x] 3.4 Создать `common/exceptions/tbank-api.exception.ts` (+ `TBankNotConfiguredException`) +- [x] 3.5 Создать `common/exceptions/portfolio-access.exception.ts` +- [x] 3.6 Обновить `HttpExceptionFilter` с логгированием необработанных ошибок +- [x] 3.7 Заменить generic исключения в shares, bonds, portfolio, tbank сервисах +- [x] 3.8 Обновлены все тесты (117 проходят) -## Итерация 4: Health check +## Итерация 4: Health check ✅ -- [ ] 4.1 Добавить `PrismaHealthIndicator` в `health/` -- [ ] 4.2 Добавить `MoexHealthIndicator` -- [ ] 4.3 Добавить `TBankHealthIndicator` -- [ ] 4.4 Обновить `HealthResponseDto` с `checks` -- [ ] 4.5 Тесты health module +- [x] 4.1 Создан `HealthService` с `checkPrisma()` — реальный SQL-запрос +- [x] 4.2 Добавлен `checkMoex()` — HTTP-запрос к ISS MOEX с 5s timeout +- [x] 4.3 Добавлен `checkTBank()` — проверка наличия токена +- [x] 4.4 `HealthResponseDto` обновлён: добавлено поле `checks: HealthCheckResultDto[]` +- [x] 4.5 Модуль обновлён (imports PrismaModule, providers HealthService) +- [x] 4.6 Тесты: `health.service.spec.ts` (3 теста) + `envelope-contract.spec.ts` проверяет checks +- [x] Все 120 тестов проходят ## Итерация 5: RequestLoggingMiddleware DI -- 2.47.2 From 238836c85037b1de0aa06b8c46d2d90e58315a5d Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 20:37:10 +0300 Subject: [PATCH 5/9] refactor(backend): connect RequestLoggingMiddleware through DI - AppModule implements NestModule with configure() for middleware - Remove manual middleware instantiation from main.ts - 120 tests pass, build succeeds --- apps/backend/src/app.module.ts | 9 +++++++-- apps/backend/src/main.ts | 4 ---- apps/backend/src/modules/health/health.service.spec.ts | 4 ++-- docs/features/backend-architecture-improvements/tasks.md | 7 ++++--- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 06db044..aca18d8 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { CacheModule } from './modules/cache/cache.module'; import { MoexClientModule } from './modules/moex-client/moex-client.module'; @@ -11,6 +11,7 @@ import { PortfolioModule } from './modules/portfolio/portfolio.module'; import { PrismaModule } from './modules/prisma/prisma.module'; import { AuthModule } from './modules/auth/auth.module'; import { TBankModule } from './modules/tbank/tbank.module'; +import { RequestLoggingMiddleware } from './common/middleware/request-logging.middleware'; import configuration from './config/configuration'; @Module({ @@ -29,4 +30,8 @@ import configuration from './config/configuration'; TBankModule, ], }) -export class AppModule {} +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(RequestLoggingMiddleware).forRoutes('*'); + } +} diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 7b3e8db..022be54 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -4,7 +4,6 @@ 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'; import cookieParser from 'cookie-parser'; @@ -18,9 +17,6 @@ async function bootstrap() { app.useGlobalInterceptors(new TransformInterceptor()); app.use(cookieParser()); - const reqLogMiddleware = new RequestLoggingMiddleware(); - app.use(reqLogMiddleware.use.bind(reqLogMiddleware)); - app.enableCors({ origin: true, credentials: true }); const config = new DocumentBuilder() diff --git a/apps/backend/src/modules/health/health.service.spec.ts b/apps/backend/src/modules/health/health.service.spec.ts index ae53473..3f6d21a 100644 --- a/apps/backend/src/modules/health/health.service.spec.ts +++ b/apps/backend/src/modules/health/health.service.spec.ts @@ -6,10 +6,10 @@ import configuration from '../../config/configuration'; describe('HealthService', () => { let service: HealthService; - let prisma: Pick; + const prisma = { $queryRaw: vi.fn() } as any; beforeEach(async () => { - prisma = { $queryRaw: vi.fn() }; + vi.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ imports: [ConfigModule.forRoot({ load: [configuration], isGlobal: true })], diff --git a/docs/features/backend-architecture-improvements/tasks.md b/docs/features/backend-architecture-improvements/tasks.md index ec33282..42a5194 100644 --- a/docs/features/backend-architecture-improvements/tasks.md +++ b/docs/features/backend-architecture-improvements/tasks.md @@ -39,10 +39,11 @@ - [x] 4.6 Тесты: `health.service.spec.ts` (3 теста) + `envelope-contract.spec.ts` проверяет checks - [x] Все 120 тестов проходят -## Итерация 5: RequestLoggingMiddleware DI +## Итерация 5: RequestLoggingMiddleware DI ✅ -- [ ] 5.1 Переписать подключение через `configure()` в `AppModule` -- [ ] 5.2 Убрать `app.use()` из `main.ts` +- [x] 5.1 `AppModule` implements `NestModule` с `configure()` → `consumer.apply(RequestLoggingMiddleware).forRoutes('*')` +- [x] 5.2 Убран `new RequestLoggingMiddleware()` и `app.use()` из `main.ts` +- [x] 120 тестов проходят, build успешен ## Итерация 6: MoexClientService split (отдельный эпик) -- 2.47.2 From d2457d13afd971bd3eb45b6529e5026c29bc1191 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 20:42:28 +0300 Subject: [PATCH 6/9] docs: add ADR-020 and feature docs for MoexClientService split --- .../docs/adr/ADR-020-moex-client-split.md | 63 ++++++++++++++++ .../tasks.md | 11 +-- docs/features/moex-client-split/plan.md | 73 +++++++++++++++++++ docs/features/moex-client-split/spec.md | 35 +++++++++ docs/features/moex-client-split/tasks.md | 39 ++++++++++ 5 files changed, 213 insertions(+), 8 deletions(-) create mode 100644 apps/docs/docs/adr/ADR-020-moex-client-split.md create mode 100644 docs/features/moex-client-split/plan.md create mode 100644 docs/features/moex-client-split/spec.md create mode 100644 docs/features/moex-client-split/tasks.md diff --git a/apps/docs/docs/adr/ADR-020-moex-client-split.md b/apps/docs/docs/adr/ADR-020-moex-client-split.md new file mode 100644 index 0000000..f6fd330 --- /dev/null +++ b/apps/docs/docs/adr/ADR-020-moex-client-split.md @@ -0,0 +1,63 @@ +# ADR-020: Разделение MoexClientService на доменные клиенты + +**Дата:** 2026-06-25 +**Статус:** Принято +**Автор:** AI Agent (codex/backend-architecture-improvements) + +## Контекст + +`MoexClientService` в `apps/backend/src/modules/moex-client/moex-client.service.ts` (423 строки, 11 публичных методов) со временем стал God Service: + +- Нарушает SRP — содержит логику работы с акциями, облигациями, свечами, историей, дивидендами и поиском в одном классе +- Инфраструктура (rate limiter, circuit breaker) смешана с бизнес-логикой +- Все 11 методов используют один шаблон: `request()` → `extractTable()` → map, но каждый с разными endpoint-ами и типами +- Потребители (shares, bonds, candles, portfolio, securities, screener, tbank/events) получают весь сервис целиком, а не только нужную функциональность +- Тестирование затруднено: любой тест одного метода тянет весь сервис + +## Рассмотренные варианты + +### A. Полный сплит по доменам (выбран) + +Выделить инфраструктурный слой (`MoexHttpClient`) и 5 доменных клиентов — по одному на группу MOEX-запросов. + +### B. Минимальный сплит + +Вынести только `MoexHttpClient` (request + extractTable + circuit breaker + rate limiter), оставить все методы в одном сервисе с делегированием через DI. + +Отклонён: не решает проблему God Service — один сервис всё ещё содержит всю доменную логику. + +### C. Оставить как есть + +Отклонён: противоречит результатам аудита, 423-строчный сервис ухудшает поддерживаемость. + +## Решение + +Выбран вариант A — полный сплит по доменам: + +- `MoexHttpClient` — инфраструктура (axios, PQueue, circuit breaker, `request()`, `extractTable()`) +- `MoexSecuritiesClient` — `searchSecurities()`, `getSecurityDescription()` +- `MoexMarketDataClient` — `getShareMarketData()`, `getShareMarketDataBatch()`, `getBondData()`, `getBondMarketData()`, `getBondPositionDataBatch()` +- `MoexCandlesClient` — `getCandles()` +- `MoexHistoryClient` — `getHistory()`, `getBondHistory()` +- `MoexDividendsClient` — `getDividends()` + +Модуль теряет `@Global()` — каждый потребитель явно импортирует `MoexClientModule`. + +## Последствия + +### Положительные +- Чёткое разделение ответственности — каждый клиент отвечает за один домен MOEX API +- Возможность мокать только нужный клиент в тестах потребителей +- `MoexHttpClient` — внутренняя деталь, не экспортируется из модуля +- Явные зависимости через imports модулей вместо одного God Service + +### Риски +- Миграция всех 7 потребителей в одном коммите (нельзя оставить половинчатое состояние) +- Каждый потребитель должен импортировать `MoexClientModule` — больше boilerplate +- Необходимость обновить все тесты потребителей (DI-инъекция меняется) + +## Связанные документы +- ADR-003: Стратегия rate limiting (остаётся актуальной, инфраструктура переносится в MoexHttpClient) +- ADR-004: Feature modules (принцип явных зависимостей) +- `docs/features/backend-architecture-improvements/plan.md` (Iteration 6) +- `docs/features/backend-architecture-improvements/tasks.md` (Iteration 6) diff --git a/docs/features/backend-architecture-improvements/tasks.md b/docs/features/backend-architecture-improvements/tasks.md index 42a5194..4bfcd24 100644 --- a/docs/features/backend-architecture-improvements/tasks.md +++ b/docs/features/backend-architecture-improvements/tasks.md @@ -45,12 +45,7 @@ - [x] 5.2 Убран `new RequestLoggingMiddleware()` и `app.use()` из `main.ts` - [x] 120 тестов проходят, build успешен -## Итерация 6: MoexClientService split (отдельный эпик) +## Итерация 6: MoexClientService split → `docs/features/moex-client-split/` -- [ ] 6.1 ADR на разделение MoexClientService -- [ ] 6.2 spec/plan/tasks отдельного эпика -- [ ] 6.3 Выделение rate limiter + circuit breaker в shared utils -- [ ] 6.4 Создание MoexSecuritiesClient -- [ ] 6.5 Создание MoexMarketDataClient -- [ ] 6.6 Создание MoexCandlesClient -- [ ] 6.7 Обновление всех потребителей +- [x] 6.1 ADR на разделение MoexClientService +- [x] 6.2 spec/plan/tasks отдельного эпика → перенесено в `docs/features/moex-client-split/{spec,plan,tasks}.md` diff --git a/docs/features/moex-client-split/plan.md b/docs/features/moex-client-split/plan.md new file mode 100644 index 0000000..4a468d0 --- /dev/null +++ b/docs/features/moex-client-split/plan.md @@ -0,0 +1,73 @@ +# MoexClientService Split — Plan + +## Подход + +Полный сплит по доменам (ADR-020). Один коммит — миграция всех файлов одновременно. + +## Архитектура + +``` +modules/moex-client/ +├── moex-http.client.ts # Инфраструктура: axios, PQueue, circuit breaker +├── moex-securities.client.ts # searchSecurities(), getSecurityDescription() +├── moex-market-data.client.ts # getShareMarketData(), getShareMarketDataBatch(), +│ # getBondData(), getBondMarketData(), getBondPositionDataBatch() +├── moex-candles.client.ts # getCandles() +├── moex-history.client.ts # getHistory(), getBondHistory() +├── moex-dividends.client.ts # getDividends() +├── moex-client.types.ts # (unchanged) +├── moex-client.module.ts # providers: все клиенты, exports: доменные клиенты, не @Global() +├── moex-client.service.spec.ts # → moex-http.client.spec.ts +└── moex-client.service.integration.spec.ts # → интеграционные тесты +``` + +## Data Flow + +``` +Consumer Service + ↓ (DI) +Domain Client (MoexSecuritiesClient | MoexMarketDataClient | etc.) + ↓ (DI) +MoexHttpClient (request + extractTable) + ↓ +MOEX ISS API +``` + +`MoexHttpClient` — не экспортируется из модуля, только доменные клиенты его видят. + +## Consumer Updates + +| Модуль | Было | Стало | +|--------|------|-------| +| `shares/shares.module.ts` | — | `imports: [MoexClientModule]` | +| `shares/shares.service.ts` | `moexClient: MoexClientService` | `moexSecurities: MoexSecuritiesClient`, `moexMarketData: MoexMarketDataClient` | +| `bonds/bonds.module.ts` | — | `imports: [MoexClientModule]` | +| `bonds/bonds.service.ts` | `moexClient: MoexClientService` | `moexMarketData: MoexMarketDataClient`, `moexHistory: MoexHistoryClient` | +| `candles/candles.module.ts` | — | `imports: [MoexClientModule]` | +| `candles/candles.service.ts` | `moexClient: MoexClientService` | `moexCandles: MoexCandlesClient` | +| `securities/securities.module.ts` | — | `imports: [MoexClientModule]` | +| `securities/securities.service.ts` | `moexClient: MoexClientService` | `moexSecurities: MoexSecuritiesClient`, `moexMarketData: MoexMarketDataClient` | +| `securities/screener.service.ts` | `moexClient: MoexClientService` | `moexMarketData: MoexMarketDataClient` | +| `portfolio/portfolio.module.ts` | — | `imports: [MoexClientModule]` | +| `portfolio/portfolio.service.ts` | `moexClient: MoexClientService` | `moexSecurities: MoexSecuritiesClient`, `moexMarketData: MoexMarketDataClient`, `moexDividends: MoexDividendsClient` | +| `tbank/tbank.module.ts` | `imports: [MoexClientModule]` | (unchanged) | +| `tbank/.../broker-events.service.ts` | `moexClient: MoexClientService` | `moexDividends: MoexDividendsClient`, `moexMarketData: MoexMarketDataClient` | + +## Migration Order + +1. Создать `MoexHttpClient` — перенести инфраструктуру из `MoexClientService` +2. Создать `MoexSecuritiesClient` — перенести 2 метода +3. Создать `MoexMarketDataClient` — перенести 5 методов +4. Создать `MoexCandlesClient` — перенести 1 метод +5. Создать `MoexHistoryClient` — перенести 2 метода +6. Создать `MoexDividendsClient` — перенести 1 метод +7. Обновить `MoexClientModule` — убрать `@Global()`, новые providers/exports +8. Обновить всех потребителей (модули + сервисы + тесты) +9. Удалить старый `MoexClientService` +10. `npm run build && npm run test` + +## Testing Strategy + +- `MoexHttpClient` — unit-тесты на circuit breaker, rate limiter, request +- Каждый доменный клиент — unit-тесты с mocked `MoexHttpClient` +- Существующие тесты потребителей — обновить DI-моки diff --git a/docs/features/moex-client-split/spec.md b/docs/features/moex-client-split/spec.md new file mode 100644 index 0000000..5b5fb1b --- /dev/null +++ b/docs/features/moex-client-split/spec.md @@ -0,0 +1,35 @@ +# MoexClientService Split + +## Цель + +Разделить God Service `MoexClientService` на инфраструктурный слой и набор доменных клиентов, устранив нарушение SRP и улучшив тестируемость. + +## Требования + +1. `MoexHttpClient` — выделить инфраструктуру (axios, PQueue, circuit breaker, `request()`, `extractTable()`) +2. Доменные клиенты — по одному на группу MOEX-запросов: + - `MoexSecuritiesClient` — поиск и описание ценных бумаг + - `MoexMarketDataClient` — рыночные данные акций и облигаций + - `MoexCandlesClient` — свечи + - `MoexHistoryClient` — история торгов + - `MoexDividendsClient` — дивиденды +3. Убрать `@Global()` — каждый потребитель явно импортирует `MoexClientModule` +4. `MoexHttpClient` не экспортируется из модуля (внутренняя деталь) +5. Все MOEX-типы остаются в `moex-client.types.ts` +6. API-контракт всех потребителей не меняется — только DI + +## Ограничения + +- Один коммит на всю миграцию (нельзя половинчатое состояние) +- Не менять сигнатуры публичных методов — только перенос кода +- Не менять типы в `moex-client.types.ts` +- Каждое изменение через TDD-цикл + +## Критерии приемки (Acceptance Criteria) + +- [ ] `MoexClientService` удалён, все 11 методов распределены по 5 доменным клиентам +- [ ] `MoexHttpClient` содержит rate limiter + circuit breaker +- [ ] `@Global()` убран с `MoexClientModule` +- [ ] Все 7 потребителей обновлены: явный импорт модуля + новые DI +- [ ] Все тесты проходят (существующие обновлены) +- [ ] `npm run build` успешен diff --git a/docs/features/moex-client-split/tasks.md b/docs/features/moex-client-split/tasks.md new file mode 100644 index 0000000..75190b8 --- /dev/null +++ b/docs/features/moex-client-split/tasks.md @@ -0,0 +1,39 @@ +# MoexClientService Split — Tasks + +## Этап 1: Создание инфраструктурного клиента + +- [ ] 1.1 Создать `moex-http.client.ts` — перенести `request()`, `extractTable()`, circuit breaker, PQueue из `MoexClientService` +- [ ] 1.2 Написать unit-тесты для `MoexHttpClient` +- [ ] 1.3 Написать тест на circuit breaker (threshold → open → reset) + +## Этап 2: Создание доменных клиентов + +- [ ] 2.1 `MoexSecuritiesClient` — `searchSecurities()`, `getSecurityDescription()` +- [ ] 2.2 `MoexMarketDataClient` — `getShareMarketData()`, `getShareMarketDataBatch()`, `getBondData()`, `getBondMarketData()`, `getBondPositionDataBatch()` +- [ ] 2.3 `MoexCandlesClient` — `getCandles()` +- [ ] 2.4 `MoexHistoryClient` — `getHistory()`, `getBondHistory()` +- [ ] 2.5 `MoexDividendsClient` — `getDividends()` +- [ ] 2.6 Написать unit-тесты для каждого доменного клиента (mocked http client) + +## Этап 3: Обновление модуля + +- [ ] 3.1 Убрать `@Global()` из `MoexClientModule` +- [ ] 3.2 Добавить новые клиенты в providers/exports +- [ ] 3.3 Убрать старый `MoexClientService` из providers +- [ ] 3.4 Проверить, что `MoexHttpClient` не экспортируется + +## Этап 4: Миграция потребителей + +- [ ] 4.1 `shares/` — обновить модуль, сервис, тесты +- [ ] 4.2 `bonds/` — обновить модуль, сервис, тесты +- [ ] 4.3 `candles/` — обновить модуль, сервис, тесты +- [ ] 4.4 `securities/` — обновить модуль, securities.service, screener.service, тесты +- [ ] 4.5 `portfolio/` — обновить модуль, сервис, тесты +- [ ] 4.6 `tbank/` — обновить broker-events.service, тесты + +## Этап 5: Финализация + +- [ ] 5.1 Удалить старый `moex-client.service.ts` +- [ ] 5.2 `npm run build` успешен +- [ ] 5.3 Все тесты проходят +- [ ] 5.4 Обновить `docs/features/backend-architecture-improvements/tasks.md` — отметить Iteration 6 как выполненную -- 2.47.2 From 75fead68b8fa928481749c4b07d5f03a02bdd73d Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 20:49:17 +0300 Subject: [PATCH 7/9] refactor: split MoexClientService into domain-specific clients - MoexHttpClient: infrastructure (axios, rate limiter, circuit breaker) - MoexSecuritiesClient: search and security descriptions - MoexMarketDataClient: share/bond market data and batch queries - MoexCandlesClient: candle data - MoexHistoryClient: share/bond history - MoexDividendsClient: dividend data - Removed @Global() from MoexClientModule - Updated all 7 consumers with explicit DI - All 141 tests passing --- .../backend/src/modules/bonds/bonds.module.ts | 2 + .../src/modules/bonds/bonds.service.spec.ts | 22 +- .../src/modules/bonds/bonds.service.ts | 14 +- .../src/modules/candles/candles.module.ts | 2 + .../modules/candles/candles.service.spec.ts | 16 +- .../src/modules/candles/candles.service.ts | 6 +- .../moex-client/moex-candles.client.spec.ts | 31 ++ .../moex-client/moex-candles.client.ts | 36 ++ .../modules/moex-client/moex-client.module.ts | 27 +- .../moex-client.service.integration.spec.ts | 19 +- .../moex-client/moex-client.service.spec.ts | 186 -------- .../moex-client/moex-client.service.ts | 423 ------------------ .../moex-client/moex-dividends.client.spec.ts | 29 ++ .../moex-client/moex-dividends.client.ts | 19 + .../moex-client/moex-history.client.spec.ts | 49 ++ .../moex-client/moex-history.client.ts | 50 +++ .../moex-client/moex-http.client.spec.ts | 132 ++++++ .../modules/moex-client/moex-http.client.ts | 76 ++++ .../moex-market-data.client.spec.ts | 118 +++++ .../moex-client/moex-market-data.client.ts | 219 +++++++++ .../moex-securities.client.spec.ts | 67 +++ .../moex-client/moex-securities.client.ts | 55 +++ .../src/modules/portfolio/portfolio.module.ts | 2 + .../portfolio/portfolio.service.spec.ts | 38 +- .../modules/portfolio/portfolio.service.ts | 16 +- .../securities/screener.service.spec.ts | 8 +- .../modules/securities/screener.service.ts | 8 +- .../modules/securities/securities.module.ts | 2 + .../securities/securities.service.spec.ts | 14 +- .../modules/securities/securities.service.ts | 8 +- .../src/modules/shares/shares.module.ts | 2 + .../src/modules/shares/shares.service.spec.ts | 27 +- .../src/modules/shares/shares.service.ts | 20 +- .../services/broker-events.service.spec.ts | 49 +- .../tbank/services/broker-events.service.ts | 10 +- 35 files changed, 1068 insertions(+), 734 deletions(-) create mode 100644 apps/backend/src/modules/moex-client/moex-candles.client.spec.ts create mode 100644 apps/backend/src/modules/moex-client/moex-candles.client.ts delete mode 100644 apps/backend/src/modules/moex-client/moex-client.service.spec.ts delete mode 100644 apps/backend/src/modules/moex-client/moex-client.service.ts create mode 100644 apps/backend/src/modules/moex-client/moex-dividends.client.spec.ts create mode 100644 apps/backend/src/modules/moex-client/moex-dividends.client.ts create mode 100644 apps/backend/src/modules/moex-client/moex-history.client.spec.ts create mode 100644 apps/backend/src/modules/moex-client/moex-history.client.ts create mode 100644 apps/backend/src/modules/moex-client/moex-http.client.spec.ts create mode 100644 apps/backend/src/modules/moex-client/moex-http.client.ts create mode 100644 apps/backend/src/modules/moex-client/moex-market-data.client.spec.ts create mode 100644 apps/backend/src/modules/moex-client/moex-market-data.client.ts create mode 100644 apps/backend/src/modules/moex-client/moex-securities.client.spec.ts create mode 100644 apps/backend/src/modules/moex-client/moex-securities.client.ts diff --git a/apps/backend/src/modules/bonds/bonds.module.ts b/apps/backend/src/modules/bonds/bonds.module.ts index b3daab4..f1add29 100644 --- a/apps/backend/src/modules/bonds/bonds.module.ts +++ b/apps/backend/src/modules/bonds/bonds.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { MoexClientModule } from '../moex-client/moex-client.module'; import { BondsController } from './bonds.controller'; import { BondsService } from './bonds.service'; @Module({ + imports: [MoexClientModule], controllers: [BondsController], providers: [BondsService], exports: [BondsService], diff --git a/apps/backend/src/modules/bonds/bonds.service.spec.ts b/apps/backend/src/modules/bonds/bonds.service.spec.ts index 23d3aba..77b9a4e 100644 --- a/apps/backend/src/modules/bonds/bonds.service.spec.ts +++ b/apps/backend/src/modules/bonds/bonds.service.spec.ts @@ -1,16 +1,17 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; import { BondsService } from './bonds.service'; -import { MoexClientService } from '../moex-client/moex-client.service'; +import { MoexMarketDataClient } from '../moex-client/moex-market-data.client'; +import { MoexHistoryClient } from '../moex-client/moex-history.client'; import { CacheService } from '../cache/cache.service'; describe('BondsService', () => { let service: BondsService; - let moexClient: Pick; + let moexMarketData: Pick; let cache: Pick; beforeEach(async () => { - moexClient = { + moexMarketData = { getBondData: vi.fn(), getBondMarketData: vi.fn(), }; @@ -25,7 +26,8 @@ describe('BondsService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ BondsService, - { provide: MoexClientService, useValue: moexClient }, + { provide: MoexMarketDataClient, useValue: moexMarketData }, + { provide: MoexHistoryClient, useValue: { getBondHistory: vi.fn() } }, { provide: CacheService, useValue: cache }, ], }).compile(); @@ -34,7 +36,7 @@ describe('BondsService', () => { }); it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => { - vi.mocked(moexClient.getBondData).mockResolvedValue({ + vi.mocked(moexMarketData.getBondData).mockResolvedValue({ secid: 'SU26238RMFS5', boardid: 'TQCB', shortName: 'ОФЗ 26238', @@ -57,7 +59,7 @@ describe('BondsService', () => { bondSubType: 'fixed', listLevel: 1, }); - vi.mocked(moexClient.getBondMarketData).mockResolvedValue({ + vi.mocked(moexMarketData.getBondMarketData).mockResolvedValue({ secid: 'SU26238RMFS5', bid: 72.9, offer: 73.1, @@ -92,8 +94,8 @@ describe('BondsService', () => { expect.any(Function), 'marketDataTtl', ); - expect(moexClient.getBondData).toHaveBeenCalledWith('SU26238RMFS5'); - expect(moexClient.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5'); + expect(moexMarketData.getBondData).toHaveBeenCalledWith('SU26238RMFS5'); + expect(moexMarketData.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5'); expect(result).toMatchObject({ data: { secid: 'SU26238RMFS5', @@ -136,10 +138,10 @@ describe('BondsService', () => { }); it('throws EntityNotFoundException when bond data is missing', async () => { - vi.mocked(moexClient.getBondData).mockResolvedValue(null); + vi.mocked(moexMarketData.getBondData).mockResolvedValue(null); await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(EntityNotFoundException); expect(cache.getOrFetch).toHaveBeenCalledTimes(1); - expect(moexClient.getBondMarketData).not.toHaveBeenCalled(); + expect(moexMarketData.getBondMarketData).not.toHaveBeenCalled(); }); }); diff --git a/apps/backend/src/modules/bonds/bonds.service.ts b/apps/backend/src/modules/bonds/bonds.service.ts index 3d6eb94..636008a 100644 --- a/apps/backend/src/modules/bonds/bonds.service.ts +++ b/apps/backend/src/modules/bonds/bonds.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { MoexClientService } from '../moex-client/moex-client.service'; +import { MoexMarketDataClient } from '../moex-client/moex-market-data.client'; +import { MoexHistoryClient } from '../moex-client/moex-history.client'; import { CacheService } from '../cache/cache.service'; import { ApiEnvelopePayload } from '../../common/dto/api-response.dto'; import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; @@ -7,7 +8,8 @@ import { EntityNotFoundException } from '../../common/exceptions/entity-not-foun @Injectable() export class BondsService { constructor( - private readonly moexClient: MoexClientService, + private readonly moexMarketData: MoexMarketDataClient, + private readonly moexHistory: MoexHistoryClient, private readonly cache: CacheService, ) {} @@ -19,7 +21,7 @@ export class BondsService { } = await this.cache.getOrFetch( 'bond', [secid], - () => this.moexClient.getBondData(secid), + () => this.moexMarketData.getBondData(secid), 'securityTtl', ); @@ -30,7 +32,7 @@ export class BondsService { const { data: mkt } = await this.cache.getOrFetch( 'marketdata', ['bonds', secid], - () => this.moexClient.getBondMarketData(secid), + () => this.moexMarketData.getBondMarketData(secid), 'marketDataTtl', ); @@ -85,7 +87,7 @@ export class BondsService { } = await this.cache.getOrFetch( 'marketdata', ['bonds', secid], - () => this.moexClient.getBondMarketData(secid), + () => this.moexMarketData.getBondMarketData(secid), 'marketDataTtl', ); @@ -119,7 +121,7 @@ export class BondsService { const { data, fromCache, cachedAt } = await this.cache.getOrFetch( 'history', ['bonds', secid, from, till], - () => this.moexClient.getBondHistory(secid, from, till), + () => this.moexHistory.getBondHistory(secid, from, till), 'historyTtl', ); diff --git a/apps/backend/src/modules/candles/candles.module.ts b/apps/backend/src/modules/candles/candles.module.ts index 79f8a35..d922b69 100644 --- a/apps/backend/src/modules/candles/candles.module.ts +++ b/apps/backend/src/modules/candles/candles.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { MoexClientModule } from '../moex-client/moex-client.module'; import { CandlesController } from './candles.controller'; import { CandlesService } from './candles.service'; @Module({ + imports: [MoexClientModule], controllers: [CandlesController], providers: [CandlesService], exports: [CandlesService], diff --git a/apps/backend/src/modules/candles/candles.service.spec.ts b/apps/backend/src/modules/candles/candles.service.spec.ts index f340d09..d4a6878 100644 --- a/apps/backend/src/modules/candles/candles.service.spec.ts +++ b/apps/backend/src/modules/candles/candles.service.spec.ts @@ -1,16 +1,16 @@ import { Test, TestingModule } from '@nestjs/testing'; import { CandlesService } from './candles.service'; -import { MoexClientService } from '../moex-client/moex-client.service'; +import { MoexCandlesClient } from '../moex-client/moex-candles.client'; import { CacheService } from '../cache/cache.service'; import { CandleInterval } from './dto/candles-query.dto'; describe('CandlesService', () => { let service: CandlesService; - let moexClient: Pick; + let moexCandles: Pick; let cache: Pick; beforeEach(async () => { - moexClient = { + moexCandles = { getCandles: vi.fn(), }; cache = { @@ -24,7 +24,7 @@ describe('CandlesService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ CandlesService, - { provide: MoexClientService, useValue: moexClient }, + { provide: MoexCandlesClient, useValue: moexCandles }, { provide: CacheService, useValue: cache }, ], }).compile(); @@ -33,7 +33,7 @@ describe('CandlesService', () => { }); it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => { - vi.mocked(moexClient.getCandles).mockResolvedValue([ + vi.mocked(moexCandles.getCandles).mockResolvedValue([ { open: 320, high: 325, @@ -60,7 +60,7 @@ describe('CandlesService', () => { expect.any(Function), 'candlesTtl', ); - expect(moexClient.getCandles).toHaveBeenCalledWith( + expect(moexCandles.getCandles).toHaveBeenCalledWith( 'stock', 'shares', 'SBER', @@ -87,7 +87,7 @@ describe('CandlesService', () => { }); it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => { - vi.mocked(moexClient.getCandles).mockResolvedValue([]); + vi.mocked(moexCandles.getCandles).mockResolvedValue([]); await service.getCandles( 'bonds', @@ -103,7 +103,7 @@ describe('CandlesService', () => { expect.any(Function), 'candlesTtl', ); - expect(moexClient.getCandles).toHaveBeenCalledWith( + expect(moexCandles.getCandles).toHaveBeenCalledWith( 'stock', 'bonds', 'SU26238RMFS5', diff --git a/apps/backend/src/modules/candles/candles.service.ts b/apps/backend/src/modules/candles/candles.service.ts index 15feada..157b7f0 100644 --- a/apps/backend/src/modules/candles/candles.service.ts +++ b/apps/backend/src/modules/candles/candles.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { MoexClientService } from '../moex-client/moex-client.service'; +import { MoexCandlesClient } from '../moex-client/moex-candles.client'; import { CacheService } from '../cache/cache.service'; import { CandleInterval } from './dto/candles-query.dto'; import { ApiEnvelopePayload } from '../../common/dto/api-response.dto'; @@ -7,7 +7,7 @@ import { ApiEnvelopePayload } from '../../common/dto/api-response.dto'; @Injectable() export class CandlesService { constructor( - private readonly moexClient: MoexClientService, + private readonly moexCandles: MoexCandlesClient, private readonly cache: CacheService, ) {} @@ -26,7 +26,7 @@ export class CandlesService { const { data, fromCache, cachedAt } = await this.cache.getOrFetch( 'candles', [market, secid, String(moexInterval), from, till], - () => this.moexClient.getCandles('stock', market, secid, moexInterval, from, till), + () => this.moexCandles.getCandles('stock', market, secid, moexInterval, from, till), 'candlesTtl', ); diff --git a/apps/backend/src/modules/moex-client/moex-candles.client.spec.ts b/apps/backend/src/modules/moex-client/moex-candles.client.spec.ts new file mode 100644 index 0000000..2a55993 --- /dev/null +++ b/apps/backend/src/modules/moex-client/moex-candles.client.spec.ts @@ -0,0 +1,31 @@ +import 'reflect-metadata'; +import { MoexHttpClient } from './moex-http.client'; +import { MoexCandlesClient } from './moex-candles.client'; + +describe('MoexCandlesClient', () => { + let client: MoexCandlesClient; + let request: ReturnType; + let extractTable: ReturnType; + + beforeEach(() => { + request = vi.fn(); + extractTable = vi.fn(); + client = new MoexCandlesClient({ request, extractTable } as unknown as MoexHttpClient); + }); + + it('возвращает свечи для заданного инструмента', async () => { + request.mockResolvedValue({}); + extractTable.mockReturnValue([ + { open: '320', close: '322', high: '323', low: '319', value: '100000', volume: '3000', begin: '2025-01-10 10:00:00', end: '2025-01-10 10:59:59' }, + ]); + + const result = await client.getCandles('stock', 'shares', 'SBER', 60, '2025-01-10', '2025-01-11'); + + expect(request).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER/candles', { + interval: '60', from: '2025-01-10', till: '2025-01-11', + }); + expect(result).toEqual([ + { open: 320, close: 322, high: 323, low: 319, value: 100000, volume: 3000, begin: '2025-01-10 10:00:00', end: '2025-01-10 10:59:59' }, + ]); + }); +}); diff --git a/apps/backend/src/modules/moex-client/moex-candles.client.ts b/apps/backend/src/modules/moex-client/moex-candles.client.ts new file mode 100644 index 0000000..23305ce --- /dev/null +++ b/apps/backend/src/modules/moex-client/moex-candles.client.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { MoexHttpClient } from './moex-http.client'; +import { MoexCandle } from './moex-client.types'; + +@Injectable() +export class MoexCandlesClient { + constructor(private readonly http: MoexHttpClient) {} + + async getCandles( + engine: 'stock', + market: 'shares' | 'bonds', + secid: string, + interval: 1 | 10 | 60 | 24, + from: string, + till: string, + ): Promise { + const data = await this.http.request>( + `/engines/${engine}/markets/${market}/securities/${secid}/candles`, + { + interval: String(interval), + from, + till, + }, + ); + return this.http.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, + })); + } +} diff --git a/apps/backend/src/modules/moex-client/moex-client.module.ts b/apps/backend/src/modules/moex-client/moex-client.module.ts index 9815aa7..e1ef7fe 100644 --- a/apps/backend/src/modules/moex-client/moex-client.module.ts +++ b/apps/backend/src/modules/moex-client/moex-client.module.ts @@ -1,9 +1,26 @@ -import { Global, Module } from '@nestjs/common'; -import { MoexClientService } from './moex-client.service'; +import { Module } from '@nestjs/common'; +import { MoexHttpClient } from './moex-http.client'; +import { MoexSecuritiesClient } from './moex-securities.client'; +import { MoexMarketDataClient } from './moex-market-data.client'; +import { MoexCandlesClient } from './moex-candles.client'; +import { MoexHistoryClient } from './moex-history.client'; +import { MoexDividendsClient } from './moex-dividends.client'; -@Global() @Module({ - providers: [MoexClientService], - exports: [MoexClientService], + providers: [ + MoexHttpClient, + MoexSecuritiesClient, + MoexMarketDataClient, + MoexCandlesClient, + MoexHistoryClient, + MoexDividendsClient, + ], + exports: [ + MoexSecuritiesClient, + MoexMarketDataClient, + MoexCandlesClient, + MoexHistoryClient, + MoexDividendsClient, + ], }) export class MoexClientModule {} diff --git a/apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts b/apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts index ff2ae26..e84f366 100644 --- a/apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts +++ b/apps/backend/src/modules/moex-client/moex-client.service.integration.spec.ts @@ -1,32 +1,35 @@ import 'reflect-metadata'; import { Test, TestingModule } from '@nestjs/testing'; import { ConfigModule } from '@nestjs/config'; -import { MoexClientService } from './moex-client.service'; +import { MoexClientModule } from './moex-client.module'; +import { MoexSecuritiesClient } from './moex-securities.client'; +import { MoexMarketDataClient } from './moex-market-data.client'; import configuration from '../../config/configuration'; describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')( - 'MoexClientService live MOEX integration', + 'MoexClient live MOEX integration', () => { - let service: MoexClientService; + let moexSecurities: MoexSecuritiesClient; + let moexMarketData: MoexMarketDataClient; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigModule.forRoot({ load: [configuration] })], - providers: [MoexClientService], + imports: [ConfigModule.forRoot({ load: [configuration] }), MoexClientModule], }).compile(); - service = module.get(MoexClientService); + moexSecurities = module.get(MoexSecuritiesClient); + moexMarketData = module.get(MoexMarketDataClient); }); it('возвращает результаты поиска для SBER из live MOEX', async () => { - const results = await service.searchSecurities('SBER'); + const results = await moexSecurities.searchSecurities('SBER'); expect(results.length).toBeGreaterThan(0); expect(results[0].secid).toBeDefined(); }, 15000); it('возвращает рыночные данные SBER из live MOEX', async () => { - const data = await service.getShareMarketData('SBER'); + const data = await moexMarketData.getShareMarketData('SBER'); expect(data).toBeDefined(); expect(data!.secid).toBe('SBER'); diff --git a/apps/backend/src/modules/moex-client/moex-client.service.spec.ts b/apps/backend/src/modules/moex-client/moex-client.service.spec.ts deleted file mode 100644 index 3b13bb7..0000000 --- a/apps/backend/src/modules/moex-client/moex-client.service.spec.ts +++ /dev/null @@ -1,186 +0,0 @@ -import 'reflect-metadata'; -import axios from 'axios'; -import { ConfigService } from '@nestjs/config'; -import { MoexClientService } from './moex-client.service'; - -vi.mock('axios', () => ({ - default: { - create: vi.fn(), - }, -})); - -describe('MoexClientService', () => { - let service: MoexClientService; - let getMock: ReturnType; - - beforeEach(() => { - getMock = vi.fn(); - vi.mocked(axios.create).mockReturnValue({ get: getMock } as never); - - service = new MoexClientService({ - get: vi.fn((key: string, fallback?: unknown) => { - const values: Record = { - 'app.moex.baseUrl': 'https://iss.moex.test/iss', - 'app.moex.circuitBreakerThreshold': 5, - 'app.moex.circuitBreakerResetSeconds': 30, - 'app.moex.rateLimit': 10, - }; - return values[key] ?? fallback; - }), - } as unknown as ConfigService); - }); - - it('создаётся с настроенным MOEX client', () => { - expect(service).toBeDefined(); - expect(axios.create).toHaveBeenCalledWith({ - baseURL: 'https://iss.moex.test/iss', - timeout: 10000, - paramsSerializer: { indexes: null }, - }); - }); - - it('нормализует результаты поиска из ISS table format', async () => { - getMock.mockResolvedValueOnce({ - data: { - securities: { - columns: [ - 'secid', - 'isin', - 'name', - 'shortName', - 'latName', - 'listLevel', - 'issuesize', - 'facevalue', - 'faceunit', - 'issuedate', - 'typename', - 'group', - 'type', - 'isqualifiedinvestors', - 'morningsession', - 'eveningsession', - ], - data: [ - [ - 'SBER', - 'RU0009029540', - 'Сбербанк России ПАО ао', - 'Сбербанк', - 'Sberbank', - '1', - '21586948000', - '3', - 'SUR', - '2007-07-20', - 'Акция обыкновенная', - 'stock_shares', - 'common_share', - '0', - '1', - '1', - ], - ], - }, - }, - }); - - const results = await service.searchSecurities('SBER'); - - expect(getMock).toHaveBeenCalledWith('/securities.json', { - params: { q: 'SBER', 'iss.meta': 'off' }, - }); - expect(results).toEqual([ - { - secid: 'SBER', - isin: 'RU0009029540', - name: 'Сбербанк России ПАО ао', - shortName: 'Сбербанк', - latName: 'Sberbank', - listLevel: 1, - issueSize: 21586948000, - faceValue: 3, - faceUnit: 'SUR', - issueDate: '2007-07-20', - typeName: 'Акция обыкновенная', - group: 'stock_shares', - type: 'common_share', - isQualifiedInvestors: false, - morningSession: true, - eveningSession: true, - }, - ]); - }); - - it('нормализует market data акции без live MOEX запроса', async () => { - getMock.mockResolvedValueOnce({ - data: { - securities: { - columns: ['SECID', 'BOARDID', 'SHORTNAME', 'PREVPRICE'], - data: [['SBER', 'TQBR', 'Сбербанк', '320.10']], - }, - marketdata: { - columns: [ - 'SECID', - 'BOARDID', - 'BID', - 'OFFER', - 'OPEN', - 'LOW', - 'HIGH', - 'LAST', - 'LASTCHANGE', - 'LASTCHANGEPRCNT', - 'VOLTODAY', - 'VALTODAY', - 'WAPRICE', - 'NUMTRADES', - 'ISSUECAPITALIZATION', - 'TRADINGSTATUS', - 'UPDATETIME', - ], - data: [ - [ - 'SBER', - 'TQBR', - '321', - '322', - '320', - '319', - '323', - '322.35', - '1.15', - '0.36', - '1925163', - '620184479', - '321.9', - '12345', - '6958336818320', - 'T', - '10:30:00', - ], - ], - }, - }, - }); - - const data = await service.getShareMarketData('SBER'); - - expect(getMock).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER.json', { - params: { boards: 'TQBR', 'iss.meta': 'off' }, - }); - expect(data).toMatchObject({ - secid: 'SBER', - boardid: 'TQBR', - shortName: 'Сбербанк', - last: 322.35, - lastChange: 1.15, - lastChangePrcnt: 0.36, - volume: 1925163, - value: 620184479, - issueCapitalization: 6958336818320, - tradingStatus: 'T', - updateTime: '10:30:00', - }); - }); -}); diff --git a/apps/backend/src/modules/moex-client/moex-client.service.ts b/apps/backend/src/modules/moex-client/moex-client.service.ts deleted file mode 100644 index 1b24ddb..0000000 --- a/apps/backend/src/modules/moex-client/moex-client.service.ts +++ /dev/null @@ -1,423 +0,0 @@ -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, - MoexBondPositionData, - 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 jsonPath = path + '.json'; - const response = await this.client.get(jsonPath, { - 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, - shortName: (share?.SHORTNAME as string) || '', - bid: mkt ? parseFloat((mkt.BID as string) || '') : null, - offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null, - open: mkt ? parseFloat((mkt.OPEN as string) || '') : null, - low: mkt ? parseFloat((mkt.LOW as string) || '') : null, - high: mkt ? parseFloat((mkt.HIGH as string) || '') : null, - last: mkt - ? parseFloat((mkt.LAST as string) || '') - : parseFloat((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 getShareMarketDataBatch( - secids: string[], - boardId = 'TQBR', - ): Promise { - const params: Record = { boards: boardId }; - if (secids.length > 0) { - params.securities = secids.join(','); - } - const data = await this.request>( - `/engines/stock/markets/shares/securities`, - params, - ); - const securities = this.extractTable(data, 'securities'); - const marketdata = this.extractTable(data, 'marketdata'); - - const secidSet = secids.length > 0 ? new Set(secids) : null; - const filteredSecurities = secidSet - ? securities.filter((r) => secidSet.has(r.SECID as string)) - : securities; - - return filteredSecurities.map((sec) => { - const secid = sec.SECID as string; - const mkt = - marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) || - marketdata.find((r) => r.SECID === secid); - - return { - secid, - boardid: boardId, - shortName: (sec?.SHORTNAME as string) || '', - bid: mkt ? parseFloat((mkt.BID as string) || '') : null, - offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null, - open: mkt ? parseFloat((mkt.OPEN as string) || '') : null, - low: mkt ? parseFloat((mkt.LOW as string) || '') : null, - high: mkt ? parseFloat((mkt.HIGH as string) || '') : null, - last: mkt - ? parseFloat((mkt.LAST as string) || '') - : parseFloat((sec?.PREVPRICE as string) || ''), - lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null, - lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null, - volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0, - value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0, - waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null, - numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0, - issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null, - tradingStatus: (mkt?.TRADINGSTATUS as string) || '', - updateTime: (mkt?.UPDATETIME as string) || '', - }; - }); - } - - async getBondPositionDataBatch( - secids: string[], - boardId = 'TQCB', - ): Promise { - const params: Record = { boards: boardId }; - if (secids.length > 0) { - params.securities = secids.join(','); - } - const data = await this.request>( - `/engines/stock/markets/bonds/securities`, - params, - ); - const securities = this.extractTable(data, 'securities'); - const marketdata = this.extractTable(data, 'marketdata'); - - const secidSet = secids.length > 0 ? new Set(secids) : null; - const filteredSecurities = secidSet - ? securities.filter((r) => secidSet.has(r.SECID as string)) - : securities; - - return filteredSecurities.map((bond) => { - const secid = bond.SECID as string; - const mkt = - marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) || - marketdata.find((r) => r.SECID === secid && r.LAST != null) || - marketdata.find((r) => r.SECID === secid); - - return { - secid, - boardid: (bond.BOARDID as string) || boardId, - shortName: (bond?.SHORTNAME as string) || '', - price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null, - yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null, - duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null, - couponValue: bond?.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null, - couponPercent: - bond?.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null, - nextCouponDate: (bond?.NEXTCOUPON as string) || null, - matDate: (bond?.MATDATE as string) || null, - accruedInt: bond?.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null, - faceValue: parseFloat((bond?.FACEVALUE as string) || '1000'), - bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null, - offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null, - couponPeriod: parseInt((bond?.COUPONPERIOD as string) || '0', 10), - bondType: (bond?.BONDTYPE as string) || null, - offerDate: (bond?.OFFERDATE as string) || null, - }; - }); - } - - 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 && r.PREVWAPRICE != null) || - rows.find((r) => r.PREVWAPRICE != null) || - rows[0]; - 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.BOARDID === boardId && r.LAST != null) || - mktRows.find((r) => r.LAST != null) || - 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 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, - })); - } -} diff --git a/apps/backend/src/modules/moex-client/moex-dividends.client.spec.ts b/apps/backend/src/modules/moex-client/moex-dividends.client.spec.ts new file mode 100644 index 0000000..e57ab3f --- /dev/null +++ b/apps/backend/src/modules/moex-client/moex-dividends.client.spec.ts @@ -0,0 +1,29 @@ +import 'reflect-metadata'; +import { MoexHttpClient } from './moex-http.client'; +import { MoexDividendsClient } from './moex-dividends.client'; + +describe('MoexDividendsClient', () => { + let client: MoexDividendsClient; + let request: ReturnType; + let extractTable: ReturnType; + + beforeEach(() => { + request = vi.fn(); + extractTable = vi.fn(); + client = new MoexDividendsClient({ request, extractTable } as unknown as MoexHttpClient); + }); + + it('возвращает дивиденды для бумаги', async () => { + request.mockResolvedValue({}); + extractTable.mockReturnValue([ + { secid: 'SBER', isin: 'RU0009029540', registryclosedate: '2025-07-10', value: '33.3', currencyid: 'RUB' }, + ]); + + const result = await client.getDividends('SBER'); + + expect(request).toHaveBeenCalledWith('/securities/SBER/dividends'); + expect(result).toEqual([ + { secid: 'SBER', isin: 'RU0009029540', registryCloseDate: '2025-07-10', value: 33.3, currencyId: 'RUB' }, + ]); + }); +}); diff --git a/apps/backend/src/modules/moex-client/moex-dividends.client.ts b/apps/backend/src/modules/moex-client/moex-dividends.client.ts new file mode 100644 index 0000000..cbf1597 --- /dev/null +++ b/apps/backend/src/modules/moex-client/moex-dividends.client.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { MoexHttpClient } from './moex-http.client'; +import { MoexDividend } from './moex-client.types'; + +@Injectable() +export class MoexDividendsClient { + constructor(private readonly http: MoexHttpClient) {} + + async getDividends(secid: string): Promise { + const data = await this.http.request>(`/securities/${secid}/dividends`); + return this.http.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', + })); + } +} diff --git a/apps/backend/src/modules/moex-client/moex-history.client.spec.ts b/apps/backend/src/modules/moex-client/moex-history.client.spec.ts new file mode 100644 index 0000000..c359b07 --- /dev/null +++ b/apps/backend/src/modules/moex-client/moex-history.client.spec.ts @@ -0,0 +1,49 @@ +import 'reflect-metadata'; +import { MoexHttpClient } from './moex-http.client'; +import { MoexHistoryClient } from './moex-history.client'; + +describe('MoexHistoryClient', () => { + let client: MoexHistoryClient; + let request: ReturnType; + let extractTable: ReturnType; + + beforeEach(() => { + request = vi.fn(); + extractTable = vi.fn(); + client = new MoexHistoryClient({ request, extractTable } as unknown as MoexHttpClient); + }); + + describe('getHistory', () => { + it('возвращает историю торгов для акции', async () => { + request.mockResolvedValue({ history: { columns: ['TRADEDATE', 'CLOSE'], data: [['2025-01-10', '322']] } }); + extractTable.mockReturnValue([{ TRADEDATE: '2025-01-10', CLOSE: '322' }]); + + const result = await client.getHistory('SBER', '2025-01-10', '2025-01-11'); + + expect(request).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER', { from: '2025-01-10', till: '2025-01-11' }); + expect(result).toEqual([ + { tradeDate: '2025-01-10', open: null, low: null, high: null, close: 322, waprice: null, volume: 0, value: 0, numtrades: 0 }, + ]); + }); + + it('возвращает пустой массив если history таблица не найдена', async () => { + request.mockResolvedValue({}); + + const result = await client.getHistory('SBER', '2025-01-10', '2025-01-11'); + expect(result).toEqual([]); + }); + }); + + describe('getBondHistory', () => { + it('возвращает историю торгов для облигации', async () => { + request.mockResolvedValue({ 'history:': { columns: ['TRADEDATE', 'CLOSE'], data: [['2025-01-10', '98.5']] } }); + extractTable.mockReturnValue([{ TRADEDATE: '2025-01-10', CLOSE: '98.5' }]); + + const result = await client.getBondHistory('SU26238RMFS4', '2025-01-10', '2025-01-11'); + + expect(result).toEqual([ + { tradeDate: '2025-01-10', close: 98.5, legalClosePrice: null, waprice: null, yieldClose: null, duration: null, accruedInt: null }, + ]); + }); + }); +}); diff --git a/apps/backend/src/modules/moex-client/moex-history.client.ts b/apps/backend/src/modules/moex-client/moex-history.client.ts new file mode 100644 index 0000000..330a4fc --- /dev/null +++ b/apps/backend/src/modules/moex-client/moex-history.client.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { MoexHttpClient } from './moex-http.client'; +import { MoexHistoryEntry, MoexBondHistoryEntry } from './moex-client.types'; + +@Injectable() +export class MoexHistoryClient { + constructor(private readonly http: MoexHttpClient) {} + + async getHistory(secid: string, from: string, till: string): Promise { + const data = await this.http.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.http.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.http.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.http.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, + })); + } +} diff --git a/apps/backend/src/modules/moex-client/moex-http.client.spec.ts b/apps/backend/src/modules/moex-client/moex-http.client.spec.ts new file mode 100644 index 0000000..bdb5d04 --- /dev/null +++ b/apps/backend/src/modules/moex-client/moex-http.client.spec.ts @@ -0,0 +1,132 @@ +import 'reflect-metadata'; +import axios from 'axios'; +import { ConfigService } from '@nestjs/config'; +import { MoexHttpClient } from './moex-http.client'; + +vi.mock('axios', () => ({ + default: { + create: vi.fn(), + }, +})); + +describe('MoexHttpClient', () => { + let client: MoexHttpClient; + let getMock: ReturnType; + + const mockConfig = { + get: vi.fn((key: string, fallback?: unknown) => { + const values: Record = { + 'app.moex.baseUrl': 'https://iss.moex.test/iss', + 'app.moex.circuitBreakerThreshold': 5, + 'app.moex.circuitBreakerResetSeconds': 30, + 'app.moex.rateLimit': 10, + }; + return values[key] ?? fallback; + }), + } as unknown as ConfigService; + + beforeEach(() => { + vi.useFakeTimers(); + getMock = vi.fn(); + vi.mocked(axios.create).mockReturnValue({ get: getMock } as never); + client = new MoexHttpClient(mockConfig); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('constructor', () => { + it('создаёт axios instance с параметрами из конфига', () => { + expect(axios.create).toHaveBeenCalledWith({ + baseURL: 'https://iss.moex.test/iss', + timeout: 10000, + paramsSerializer: { indexes: null }, + }); + }); + }); + + describe('request', () => { + it('выполняет GET запрос с .json суффиксом и iss.meta=off', async () => { + getMock.mockResolvedValueOnce({ data: { some: 'data' } }); + + const result = await client.request<{ some: string }>('/securities', { q: 'SBER' }); + + expect(getMock).toHaveBeenCalledWith('/securities.json', { + params: { q: 'SBER', 'iss.meta': 'off' }, + }); + expect(result).toEqual({ some: 'data' }); + }); + + it('открывает circuit breaker после заданного числа ошибок', async () => { + getMock.mockRejectedValue(new Error('Network error')); + + for (let i = 0; i < 5; i++) { + await expect(client.request('/test')).rejects.toThrow(); + } + + await expect(client.request('/test')).rejects.toThrow('Circuit breaker is open'); + expect(getMock).toHaveBeenCalledTimes(5); + }); + + it('закрывает circuit breaker после resetMs', async () => { + getMock.mockRejectedValue(new Error('Network error')); + + for (let i = 0; i < 5; i++) { + await expect(client.request('/test')).rejects.toThrow(); + } + + await expect(client.request('/test')).rejects.toThrow('Circuit breaker is open'); + + vi.advanceTimersByTime(30000); + + getMock.mockResolvedValue({ data: 'ok' }); + const result = await client.request('/test'); + expect(result).toBe('ok'); + }); + + it('сбрасывает errorCount при успешном запросе', async () => { + getMock + .mockRejectedValueOnce(new Error('fail')) + .mockRejectedValueOnce(new Error('fail')) + .mockResolvedValueOnce({ data: 'ok' }); + + await expect(client.request('/test')).rejects.toThrow('fail'); + await expect(client.request('/test')).rejects.toThrow('fail'); + const result = await client.request('/test'); + expect(result).toBe('ok'); + expect(getMock).toHaveBeenCalledTimes(3); + }); + }); + + describe('extractTable', () => { + it('преобразует ISS columns/data формат в массив объектов', () => { + const data = { + securities: { + columns: ['secid', 'name'], + data: [ + ['SBER', 'Сбербанк'], + ['VTBR', 'ВТБ'], + ], + }, + }; + + const result = client.extractTable(data as Record, 'securities'); + + expect(result).toEqual([ + { secid: 'SBER', name: 'Сбербанк' }, + { secid: 'VTBR', name: 'ВТБ' }, + ]); + }); + + it('возвращает пустой массив если таблица не найдена', () => { + const result = client.extractTable({}, 'nonexistent'); + expect(result).toEqual([]); + }); + + it('возвращает пустой массив если нет columns', () => { + const result = client.extractTable({ securities: { data: [] } } as unknown as Record, 'securities'); + expect(result).toEqual([]); + }); + }); +}); diff --git a/apps/backend/src/modules/moex-client/moex-http.client.ts b/apps/backend/src/modules/moex-client/moex-http.client.ts new file mode 100644 index 0000000..7d6f08e --- /dev/null +++ b/apps/backend/src/modules/moex-client/moex-http.client.ts @@ -0,0 +1,76 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios, { AxiosInstance } from 'axios'; +import PQueue from 'p-queue'; + +@Injectable() +export class MoexHttpClient { + private readonly logger = new Logger(MoexHttpClient.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, + }); + } + + 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 jsonPath = path + '.json'; + const response = await this.client.get(jsonPath, { + 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; + } + + 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; + }); + } +} diff --git a/apps/backend/src/modules/moex-client/moex-market-data.client.spec.ts b/apps/backend/src/modules/moex-client/moex-market-data.client.spec.ts new file mode 100644 index 0000000..8661642 --- /dev/null +++ b/apps/backend/src/modules/moex-client/moex-market-data.client.spec.ts @@ -0,0 +1,118 @@ +import 'reflect-metadata'; +import { MoexHttpClient } from './moex-http.client'; +import { MoexMarketDataClient } from './moex-market-data.client'; + +describe('MoexMarketDataClient', () => { + let client: MoexMarketDataClient; + let request: ReturnType; + let extractTable: ReturnType; + + beforeEach(() => { + request = vi.fn(); + extractTable = vi.fn(); + client = new MoexMarketDataClient({ request, extractTable } as unknown as MoexHttpClient); + }); + + describe('getShareMarketData', () => { + it('возвращает рыночные данные акции из securities и marketdata таблиц', async () => { + request.mockResolvedValue({}); + extractTable + .mockReturnValueOnce([{ SECID: 'SBER', BOARDID: 'TQBR', SHORTNAME: 'Сбербанк', PREVPRICE: '320' }]) + .mockReturnValueOnce([{ SECID: 'SBER', BOARDID: 'TQBR', BID: '321', OFFER: '322', OPEN: '320', LOW: '319', HIGH: '323', LAST: '322.35', LASTCHANGE: '1.15', LASTCHANGEPRCNT: '0.36', VOLTODAY: '1925163', VALTODAY: '620184479', WAPRICE: '321.9', NUMTRADES: '12345', ISSUECAPITALIZATION: '6958336818320', TRADINGSTATUS: 'T', UPDATETIME: '10:30:00' }]); + + const result = await client.getShareMarketData('SBER'); + + expect(request).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER', { boards: 'TQBR' }); + expect(result).toMatchObject({ secid: 'SBER', boardid: 'TQBR', shortName: 'Сбербанк', last: 322.35, bid: 321, offer: 322 }); + }); + + it('возвращает null если бумага не найдена', async () => { + request.mockResolvedValue({}); + extractTable.mockReturnValueOnce([]).mockReturnValueOnce([]); + + const result = await client.getShareMarketData('INVALID'); + expect(result).toBeNull(); + }); + }); + + describe('getShareMarketDataBatch', () => { + it('возвращает массив рыночных данных для нескольких бумаг', async () => { + request.mockResolvedValue({}); + extractTable + .mockReturnValueOnce([ + { SECID: 'SBER', BOARDID: 'TQBR', SHORTNAME: 'Сбербанк', PREVPRICE: '320' }, + { SECID: 'VTBR', BOARDID: 'TQBR', SHORTNAME: 'ВТБ', PREVPRICE: '50' }, + ]) + .mockReturnValueOnce([ + { SECID: 'SBER', BOARDID: 'TQBR', LAST: '322', BID: '321', OFFER: '323' }, + { SECID: 'VTBR', BOARDID: 'TQBR', LAST: '50.5', BID: '50.1', OFFER: '50.8' }, + ]); + + const results = await client.getShareMarketDataBatch(['SBER', 'VTBR']); + + expect(results).toHaveLength(2); + expect(results[0].secid).toBe('SBER'); + expect(results[1].secid).toBe('VTBR'); + }); + }); + + describe('getBondData', () => { + it('возвращает данные облигации из securities таблицы', async () => { + request.mockResolvedValue({}); + extractTable.mockReturnValueOnce([ + { SECID: 'SU26238RMFS4', BOARDID: 'TQCB', SHORTNAME: 'ОФЗ 26238', PREVWAPRICE: '98.5', COUPONVALUE: '34.5', NEXTCOUPON: '2025-01-15', MATDATE: '2041-05-15', FACEVALUE: '1000', ISIN: 'RU000A1038T7' }, + ]); + + const result = await client.getBondData('SU26238RMFS4'); + + expect(request).toHaveBeenCalledWith('/engines/stock/markets/bonds/securities/SU26238RMFS4', { boards: 'TQCB' }); + expect(result).toMatchObject({ secid: 'SU26238RMFS4', shortName: 'ОФЗ 26238' }); + }); + + it('возвращает null если облигация не найдена', async () => { + request.mockResolvedValue({}); + extractTable.mockReturnValueOnce([]); + + const result = await client.getBondData('INVALID'); + expect(result).toBeNull(); + }); + }); + + describe('getBondMarketData', () => { + it('возвращает рыночные данные облигации из marketdata таблицы', async () => { + request.mockResolvedValue({}); + extractTable.mockReturnValueOnce([{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', LAST: '98.5', BID: '98', OFFER: '99', YIELD: '7.5', DURATION: '1500', VOLTODAY: '1000', VALTODAY: '98500', NUMTRADES: '50', TRADINGSTATUS: 'T', UPDATETIME: '10:30:00' }]); + + const result = await client.getBondMarketData('SU26238RMFS4'); + + expect(result).toMatchObject({ secid: 'SU26238RMFS4', last: 98.5, bid: 98, offer: 99, yield: 7.5 }); + }); + + it('возвращает null если marketdata не найдена', async () => { + request.mockResolvedValue({}); + extractTable.mockReturnValueOnce([]); + + const result = await client.getBondMarketData('INVALID'); + expect(result).toBeNull(); + }); + }); + + describe('getBondPositionDataBatch', () => { + it('возвращает массив позиций по облигациям', async () => { + request.mockResolvedValue({}); + extractTable + .mockReturnValueOnce([ + { SECID: 'SU26238RMFS4', BOARDID: 'TQCB', SHORTNAME: 'ОФЗ 26238', COUPONVALUE: '34.5', COUPONPERCENT: '7', NEXTCOUPON: '2025-01-15', MATDATE: '2041-05-15', FACEVALUE: '1000', ISIN: 'RU000A1038T7' }, + ]) + .mockReturnValueOnce([ + { SECID: 'SU26238RMFS4', BOARDID: 'TQCB', LAST: '98.5', YIELD: '7.5', DURATION: '1500', BID: '98', OFFER: '99' }, + ]); + + const results = await client.getBondPositionDataBatch(['SU26238RMFS4']); + + expect(results).toHaveLength(1); + expect(results[0].secid).toBe('SU26238RMFS4'); + expect(results[0].price).toBe(98.5); + }); + }); +}); diff --git a/apps/backend/src/modules/moex-client/moex-market-data.client.ts b/apps/backend/src/modules/moex-client/moex-market-data.client.ts new file mode 100644 index 0000000..6c55e46 --- /dev/null +++ b/apps/backend/src/modules/moex-client/moex-market-data.client.ts @@ -0,0 +1,219 @@ +import { Injectable } from '@nestjs/common'; +import { MoexHttpClient } from './moex-http.client'; +import { + MoexShareMarketData, + MoexBondData, + MoexBondMarketData, + MoexBondPositionData, +} from './moex-client.types'; + +@Injectable() +export class MoexMarketDataClient { + constructor(private readonly http: MoexHttpClient) {} + + async getShareMarketData(secid: string, boardId = 'TQBR'): Promise { + const data = await this.http.request>( + `/engines/stock/markets/shares/securities/${secid}`, + { boards: boardId }, + ); + const rows = this.http.extractTable(data, 'securities'); + const share = rows.find((r) => r.BOARDID === boardId); + if (!share) return null; + + const mktRows = this.http.extractTable(data, 'marketdata'); + const mkt = mktRows.find((r) => r.BOARDID === boardId); + + return { + secid, + boardid: boardId, + shortName: (share?.SHORTNAME as string) || '', + bid: mkt ? parseFloat((mkt.BID as string) || '') : null, + offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null, + open: mkt ? parseFloat((mkt.OPEN as string) || '') : null, + low: mkt ? parseFloat((mkt.LOW as string) || '') : null, + high: mkt ? parseFloat((mkt.HIGH as string) || '') : null, + last: mkt + ? parseFloat((mkt.LAST as string) || '') + : parseFloat((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 getShareMarketDataBatch( + secids: string[], + boardId = 'TQBR', + ): Promise { + const params: Record = { boards: boardId }; + if (secids.length > 0) { + params.securities = secids.join(','); + } + const data = await this.http.request>( + `/engines/stock/markets/shares/securities`, + params, + ); + const securities = this.http.extractTable(data, 'securities'); + const marketdata = this.http.extractTable(data, 'marketdata'); + + const secidSet = secids.length > 0 ? new Set(secids) : null; + const filteredSecurities = secidSet + ? securities.filter((r) => secidSet.has(r.SECID as string)) + : securities; + + return filteredSecurities.map((sec) => { + const secid = sec.SECID as string; + const mkt = + marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) || + marketdata.find((r) => r.SECID === secid); + + return { + secid, + boardid: boardId, + shortName: (sec?.SHORTNAME as string) || '', + bid: mkt ? parseFloat((mkt.BID as string) || '') : null, + offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null, + open: mkt ? parseFloat((mkt.OPEN as string) || '') : null, + low: mkt ? parseFloat((mkt.LOW as string) || '') : null, + high: mkt ? parseFloat((mkt.HIGH as string) || '') : null, + last: mkt + ? parseFloat((mkt.LAST as string) || '') + : parseFloat((sec?.PREVPRICE as string) || ''), + lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null, + lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null, + volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0, + value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0, + waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null, + numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0, + issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null, + tradingStatus: (mkt?.TRADINGSTATUS as string) || '', + updateTime: (mkt?.UPDATETIME as string) || '', + }; + }); + } + + async getBondData(secid: string, boardId = 'TQCB'): Promise { + const data = await this.http.request>( + `/engines/stock/markets/bonds/securities/${secid}`, + { boards: boardId }, + ); + const rows = this.http.extractTable(data, 'securities'); + const bond = + rows.find((r) => r.BOARDID === boardId && r.PREVWAPRICE != null) || + rows.find((r) => r.PREVWAPRICE != null) || + rows[0]; + 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.http.request>( + `/engines/stock/markets/bonds/securities/${secid}`, + { boards: boardId }, + ); + const mktRows = this.http.extractTable(data, 'marketdata'); + const mkt = + mktRows.find((r) => r.BOARDID === boardId && r.LAST != null) || + mktRows.find((r) => r.LAST != null) || + mktRows.find((r) => r.SECID === secid); + if (!mkt) return null; + + return { + 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 getBondPositionDataBatch( + secids: string[], + boardId = 'TQCB', + ): Promise { + const params: Record = { boards: boardId }; + if (secids.length > 0) { + params.securities = secids.join(','); + } + const data = await this.http.request>( + `/engines/stock/markets/bonds/securities`, + params, + ); + const securities = this.http.extractTable(data, 'securities'); + const marketdata = this.http.extractTable(data, 'marketdata'); + + const secidSet = secids.length > 0 ? new Set(secids) : null; + const filteredSecurities = secidSet + ? securities.filter((r) => secidSet.has(r.SECID as string)) + : securities; + + return filteredSecurities.map((bond) => { + const secid = bond.SECID as string; + const mkt = + marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) || + marketdata.find((r) => r.SECID === secid && r.LAST != null) || + marketdata.find((r) => r.SECID === secid); + + return { + secid, + boardid: (bond.BOARDID as string) || boardId, + shortName: (bond?.SHORTNAME as string) || '', + price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null, + yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null, + duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null, + couponValue: bond?.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null, + couponPercent: + bond?.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null, + nextCouponDate: (bond?.NEXTCOUPON as string) || null, + matDate: (bond?.MATDATE as string) || null, + accruedInt: bond?.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null, + faceValue: parseFloat((bond?.FACEVALUE as string) || '1000'), + bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null, + offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null, + couponPeriod: parseInt((bond?.COUPONPERIOD as string) || '0', 10), + bondType: (bond?.BONDTYPE as string) || null, + offerDate: (bond?.OFFERDATE as string) || null, + }; + }); + } +} diff --git a/apps/backend/src/modules/moex-client/moex-securities.client.spec.ts b/apps/backend/src/modules/moex-client/moex-securities.client.spec.ts new file mode 100644 index 0000000..b90572d --- /dev/null +++ b/apps/backend/src/modules/moex-client/moex-securities.client.spec.ts @@ -0,0 +1,67 @@ +import 'reflect-metadata'; +import { MoexHttpClient } from './moex-http.client'; +import { MoexSecuritiesClient } from './moex-securities.client'; + +describe('MoexSecuritiesClient', () => { + let client: MoexSecuritiesClient; + let httpMock: { request: ReturnType; extractTable: ReturnType }; + + beforeEach(() => { + httpMock = { + request: vi.fn(), + extractTable: vi.fn(), + }; + client = new MoexSecuritiesClient(httpMock as unknown as MoexHttpClient); + }); + + describe('searchSecurities', () => { + it('выполняет поиск по запросу и нормализует результаты', async () => { + httpMock.request.mockResolvedValue({}); + httpMock.extractTable.mockReturnValue([ + { + secid: 'SBER', isin: 'RU0009029540', name: 'Сбербанк России ПАО ао', + shortName: 'Сбербанк', latName: 'Sberbank', listLevel: '1', issuesize: '21586948000', + facevalue: '3', faceunit: 'SUR', issuedate: '2007-07-20', typename: 'Акция обыкновенная', + group: 'stock_shares', type: 'common_share', isqualifiedinvestors: '0', + morningsession: '1', eveningsession: '1', + }, + ]); + + const results = await client.searchSecurities('SBER'); + + expect(httpMock.request).toHaveBeenCalledWith('/securities', { q: 'SBER' }); + expect(results).toEqual([ + { + secid: 'SBER', isin: 'RU0009029540', name: 'Сбербанк России ПАО ао', + shortName: 'Сбербанк', latName: 'Sberbank', listLevel: 1, issueSize: 21586948000, + faceValue: 3, faceUnit: 'SUR', issueDate: '2007-07-20', typeName: 'Акция обыкновенная', + group: 'stock_shares', type: 'common_share', isQualifiedInvestors: false, + morningSession: true, eveningSession: true, + }, + ]); + }); + }); + + describe('getSecurityDescription', () => { + it('возвращает описание бумаги из description таблицы', async () => { + httpMock.request.mockResolvedValue({}); + httpMock.extractTable.mockReturnValue([ + { name: 'ISIN', value: 'RU0009029540' }, + { name: 'SHORTNAME', value: 'Сбербанк' }, + ]); + + const result = await client.getSecurityDescription('SBER'); + + expect(httpMock.request).toHaveBeenCalledWith('/securities/SBER'); + expect(result).toMatchObject({ secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбербанк' }); + }); + + it('возвращает null если description пуст', async () => { + httpMock.request.mockResolvedValue({}); + httpMock.extractTable.mockReturnValue([]); + + const result = await client.getSecurityDescription('INVALID'); + expect(result).toBeNull(); + }); + }); +}); diff --git a/apps/backend/src/modules/moex-client/moex-securities.client.ts b/apps/backend/src/modules/moex-client/moex-securities.client.ts new file mode 100644 index 0000000..a01d352 --- /dev/null +++ b/apps/backend/src/modules/moex-client/moex-securities.client.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { MoexHttpClient } from './moex-http.client'; +import { MoexSecurityDescription } from './moex-client.types'; + +@Injectable() +export class MoexSecuritiesClient { + constructor(private readonly http: MoexHttpClient) {} + + async searchSecurities(query: string): Promise { + const data = await this.http.request>('/securities', { q: query }); + return this.http.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.http.request>(`/securities/${secid}`); + const rows = this.http.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', + }; + } +} diff --git a/apps/backend/src/modules/portfolio/portfolio.module.ts b/apps/backend/src/modules/portfolio/portfolio.module.ts index ed0f8cc..8d8a77a 100644 --- a/apps/backend/src/modules/portfolio/portfolio.module.ts +++ b/apps/backend/src/modules/portfolio/portfolio.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { MoexClientModule } from '../moex-client/moex-client.module'; import { PortfolioController } from './portfolio.controller'; import { PortfolioService } from './portfolio.service'; @Module({ + imports: [MoexClientModule], controllers: [PortfolioController], providers: [PortfolioService], exports: [PortfolioService], diff --git a/apps/backend/src/modules/portfolio/portfolio.service.spec.ts b/apps/backend/src/modules/portfolio/portfolio.service.spec.ts index 67c8184..f74c0e5 100644 --- a/apps/backend/src/modules/portfolio/portfolio.service.spec.ts +++ b/apps/backend/src/modules/portfolio/portfolio.service.spec.ts @@ -2,7 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigModule } from '@nestjs/config'; import { PortfolioService } from './portfolio.service'; import { PrismaService } from '../prisma/prisma.service'; -import { MoexClientService } from '../moex-client/moex-client.service'; +import { MoexSecuritiesClient } from '../moex-client/moex-securities.client'; +import { MoexMarketDataClient } from '../moex-client/moex-market-data.client'; +import { MoexDividendsClient } from '../moex-client/moex-dividends.client'; import { CacheService } from '../cache/cache.service'; import configuration from '../../config/configuration'; import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; @@ -11,7 +13,7 @@ import { PortfolioAccessDeniedException } from '../../common/exceptions/portfoli describe('PortfolioService', () => { let service: PortfolioService; let prisma: PrismaService; - let moexClient: MoexClientService; + let moexMarketData: MoexMarketDataClient; let module: TestingModule; const mockPortfolio = (overrides: Record = {}) => ({ @@ -66,14 +68,20 @@ describe('PortfolioService', () => { }, }, { - provide: MoexClientService, + provide: MoexSecuritiesClient, + useValue: { getSecurityDescription: vi.fn() }, + }, + { + provide: MoexMarketDataClient, useValue: { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn(), - getSecurityDescription: vi.fn(), - getDividends: vi.fn(), }, }, + { + provide: MoexDividendsClient, + useValue: { getDividends: vi.fn() }, + }, { provide: CacheService, useValue: { @@ -85,7 +93,7 @@ describe('PortfolioService', () => { service = module.get(PortfolioService); prisma = module.get(PrismaService); - moexClient = module.get(MoexClientService); + moexMarketData = module.get(MoexMarketDataClient); }); beforeEach(() => { @@ -139,11 +147,11 @@ describe('PortfolioService', () => { mockPortfolio({ positions: [sharePosition, bondPosition] }) as any, ]); - vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, ] as any); - vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([ + vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([ { secid: 'SU26238RMFS5', shortName: 'OFZ 26238', @@ -237,7 +245,7 @@ describe('PortfolioService', () => { }), ); - vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ { secid: 'SBER', shortName: 'Sberbank', last: 250 }, ] as any); @@ -283,7 +291,7 @@ describe('PortfolioService', () => { }), ); - vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, ] as any); @@ -325,7 +333,7 @@ describe('PortfolioService', () => { }), ); - vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([ + vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([ { secid: 'SU26238RMFS5', shortName: 'OFZ 26238', @@ -369,7 +377,7 @@ describe('PortfolioService', () => { }), ); - vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ { secid: 'SBER', shortName: 'Sberbank', last: 250 }, ] as any); @@ -405,7 +413,7 @@ describe('PortfolioService', () => { }), ); - vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([] as any); + vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([] as any); const result = await service.getPositionsWithPrices(1); @@ -461,7 +469,7 @@ describe('PortfolioService', () => { }), ); - vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ { secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 }, { secid: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 }, ] as any); @@ -513,7 +521,7 @@ describe('PortfolioService', () => { }), ); - vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([ + vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([ { secid: 'SBER', shortName: 'Sberbank', last: 120 }, { secid: 'GAZP', shortName: 'Gazprom', last: 180 }, ] as any); diff --git a/apps/backend/src/modules/portfolio/portfolio.service.ts b/apps/backend/src/modules/portfolio/portfolio.service.ts index 39246f6..0f889bf 100644 --- a/apps/backend/src/modules/portfolio/portfolio.service.ts +++ b/apps/backend/src/modules/portfolio/portfolio.service.ts @@ -3,7 +3,9 @@ import { BadRequestException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { MoexClientService } from '../moex-client/moex-client.service'; +import { MoexSecuritiesClient } from '../moex-client/moex-securities.client'; +import { MoexMarketDataClient } from '../moex-client/moex-market-data.client'; +import { MoexDividendsClient } from '../moex-client/moex-dividends.client'; import { CacheService } from '../cache/cache.service'; import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception'; @@ -58,7 +60,9 @@ export interface EnrichedPosition { export class PortfolioService { constructor( private readonly prisma: PrismaService, - private readonly moexClient: MoexClientService, + private readonly moexSecurities: MoexSecuritiesClient, + private readonly moexMarketData: MoexMarketDataClient, + private readonly moexDividends: MoexDividendsClient, private readonly cache: CacheService, ) {} @@ -209,7 +213,7 @@ export class PortfolioService { if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0'); - const desc = await this.moexClient.getSecurityDescription(dto.secid); + const desc = await this.moexSecurities.getSecurityDescription(dto.secid); if (!desc) throw new BadRequestException(`Security ${dto.secid} not found in MOEX`); const type = desc.group === 'stock_bonds' ? 'bond' : 'share'; @@ -339,7 +343,7 @@ export class PortfolioService { const { data } = await this.cache.getOrFetch( 'batchdata', ['shares', cacheKey], - () => this.moexClient.getShareMarketDataBatch(secids), + () => this.moexMarketData.getShareMarketDataBatch(secids), 'marketDataTtl', ); return new Map(data.map((d) => [d.secid, d])); @@ -354,7 +358,7 @@ export class PortfolioService { const { data } = await this.cache.getOrFetch( 'batchdata', ['bonds', cacheKey], - () => this.moexClient.getBondPositionDataBatch(secids), + () => this.moexMarketData.getBondPositionDataBatch(secids), 'marketDataTtl', ); return new Map(data.map((d) => [d.secid, d])); @@ -371,7 +375,7 @@ export class PortfolioService { const { data } = await this.cache.getOrFetch( 'dividends', [cacheKey], - () => this.moexClient.getDividends(secid), + () => this.moexDividends.getDividends(secid), 'marketDataTtl', ); return { secid, dividends: data }; diff --git a/apps/backend/src/modules/securities/screener.service.spec.ts b/apps/backend/src/modules/securities/screener.service.spec.ts index db70320..5302651 100644 --- a/apps/backend/src/modules/securities/screener.service.spec.ts +++ b/apps/backend/src/modules/securities/screener.service.spec.ts @@ -1,20 +1,20 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ScreenerService } from './screener.service'; -import { MoexClientService } from '../moex-client/moex-client.service'; +import { MoexMarketDataClient } from '../moex-client/moex-market-data.client'; import { CacheService } from '../cache/cache.service'; import { ScreenerType } from './dto/screener-query.dto'; describe('ScreenerService', () => { let service: ScreenerService; let cache: CacheService; - const moexClient = { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn() }; + const moexMarketData = { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn() }; beforeEach(async () => { vi.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ providers: [ ScreenerService, - { provide: MoexClientService, useValue: moexClient }, + { provide: MoexMarketDataClient, useValue: moexMarketData }, { provide: CacheService, useValue: { getOrFetch: vi.fn() } }, ], }).compile(); @@ -34,7 +34,7 @@ describe('ScreenerService', () => { lastChange: 5, lastChangePrcnt: 2, issueCapitalization: 1e9, }]; - moexClient.getShareMarketDataBatch.mockResolvedValue(mockShares); + moexMarketData.getShareMarketDataBatch.mockResolvedValue(mockShares); vi.mocked(cache.getOrFetch).mockImplementation(async (_prefix, _keys, fetchFn) => ({ data: await fetchFn(), diff --git a/apps/backend/src/modules/securities/screener.service.ts b/apps/backend/src/modules/securities/screener.service.ts index 1138ac2..236d852 100644 --- a/apps/backend/src/modules/securities/screener.service.ts +++ b/apps/backend/src/modules/securities/screener.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { MoexClientService } from '../moex-client/moex-client.service'; +import { MoexMarketDataClient } from '../moex-client/moex-market-data.client'; import { CacheService } from '../cache/cache.service'; import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto'; import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto'; @@ -7,7 +7,7 @@ import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto' @Injectable() export class ScreenerService { constructor( - private readonly moexClient: MoexClientService, + private readonly moexMarketData: MoexMarketDataClient, private readonly cache: CacheService, ) {} @@ -38,7 +38,7 @@ export class ScreenerService { [type], async () => { if (type === ScreenerType.SHARE) { - const shares = await this.moexClient.getShareMarketDataBatch([]); + const shares = await this.moexMarketData.getShareMarketDataBatch([]); return shares.map( (s): ScreenerItemDto => ({ secid: s.secid, @@ -61,7 +61,7 @@ export class ScreenerService { }), ); } else { - const bonds = await this.moexClient.getBondPositionDataBatch([]); + const bonds = await this.moexMarketData.getBondPositionDataBatch([]); return bonds.map( (b): ScreenerItemDto => ({ secid: b.secid, diff --git a/apps/backend/src/modules/securities/securities.module.ts b/apps/backend/src/modules/securities/securities.module.ts index ce1bba3..7eb99de 100644 --- a/apps/backend/src/modules/securities/securities.module.ts +++ b/apps/backend/src/modules/securities/securities.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; +import { MoexClientModule } from '../moex-client/moex-client.module'; import { SecuritiesController } from './securities.controller'; import { SecuritiesService } from './securities.service'; import { ScreenerService } from './screener.service'; @Module({ + imports: [MoexClientModule], controllers: [SecuritiesController], providers: [SecuritiesService, ScreenerService], exports: [SecuritiesService], diff --git a/apps/backend/src/modules/securities/securities.service.spec.ts b/apps/backend/src/modules/securities/securities.service.spec.ts index be9434c..6833c93 100644 --- a/apps/backend/src/modules/securities/securities.service.spec.ts +++ b/apps/backend/src/modules/securities/securities.service.spec.ts @@ -1,16 +1,16 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SecuritiesService } from './securities.service'; -import { MoexClientService } from '../moex-client/moex-client.service'; +import { MoexSecuritiesClient } from '../moex-client/moex-securities.client'; import { CacheService } from '../cache/cache.service'; import { SecurityType } from './dto/search-query.dto'; describe('SecuritiesService', () => { let service: SecuritiesService; - let moexClient: Pick; + let moexSecurities: Pick; let cache: Pick; beforeEach(async () => { - moexClient = { + moexSecurities = { searchSecurities: vi.fn(), }; cache = { @@ -24,7 +24,7 @@ describe('SecuritiesService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ SecuritiesService, - { provide: MoexClientService, useValue: moexClient }, + { provide: MoexSecuritiesClient, useValue: moexSecurities }, { provide: CacheService, useValue: cache }, ], }).compile(); @@ -33,7 +33,7 @@ describe('SecuritiesService', () => { }); it('returns supported securities only and normalizes SUR currency to RUB', async () => { - vi.mocked(moexClient.searchSecurities).mockResolvedValue([ + vi.mocked(moexSecurities.searchSecurities).mockResolvedValue([ { secid: 'SBER', isin: 'RU0009029540', @@ -118,7 +118,7 @@ describe('SecuritiesService', () => { expect.any(Function), 'searchTtl', ); - expect(moexClient.searchSecurities).toHaveBeenCalledWith('SbEr'); + expect(moexSecurities.searchSecurities).toHaveBeenCalledWith('SbEr'); }); it('filters by type and applies limit without live MOEX dependency', async () => { @@ -169,6 +169,6 @@ describe('SecuritiesService', () => { price: null, }, ]); - expect(moexClient.searchSecurities).not.toHaveBeenCalled(); + expect(moexSecurities.searchSecurities).not.toHaveBeenCalled(); }); }); diff --git a/apps/backend/src/modules/securities/securities.service.ts b/apps/backend/src/modules/securities/securities.service.ts index f84315e..3b542ee 100644 --- a/apps/backend/src/modules/securities/securities.service.ts +++ b/apps/backend/src/modules/securities/securities.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { MoexClientService } from '../moex-client/moex-client.service'; +import { MoexSecuritiesClient } from '../moex-client/moex-securities.client'; import { CacheService } from '../cache/cache.service'; import { SecurityType } from './dto/search-query.dto'; @@ -16,7 +16,7 @@ export interface SearchResultItem { @Injectable() export class SecuritiesService { constructor( - private readonly moexClient: MoexClientService, + private readonly moexSecurities: MoexSecuritiesClient, private readonly cache: CacheService, ) {} @@ -25,7 +25,7 @@ export class SecuritiesService { 'search', [query.toLowerCase()], async () => { - const results = await this.moexClient.searchSecurities(query); + const results = await this.moexSecurities.searchSecurities(query); return results .map((s): SearchResultItem | null => { const type = @@ -64,7 +64,7 @@ export class SecuritiesService { async getShareBrief(secid: string): Promise { try { - const desc = await this.moexClient.getSecurityDescription(secid); + const desc = await this.moexSecurities.getSecurityDescription(secid); if (!desc) return null; return { secid: desc.secid, diff --git a/apps/backend/src/modules/shares/shares.module.ts b/apps/backend/src/modules/shares/shares.module.ts index 4d191b7..7fec79f 100644 --- a/apps/backend/src/modules/shares/shares.module.ts +++ b/apps/backend/src/modules/shares/shares.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { MoexClientModule } from '../moex-client/moex-client.module'; import { SharesController } from './shares.controller'; import { SharesService } from './shares.service'; @Module({ + imports: [MoexClientModule], controllers: [SharesController], providers: [SharesService], exports: [SharesService], diff --git a/apps/backend/src/modules/shares/shares.service.spec.ts b/apps/backend/src/modules/shares/shares.service.spec.ts index a3a6f5f..620e94f 100644 --- a/apps/backend/src/modules/shares/shares.service.spec.ts +++ b/apps/backend/src/modules/shares/shares.service.spec.ts @@ -1,17 +1,23 @@ import { Test, TestingModule } from '@nestjs/testing'; import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; import { SharesService } from './shares.service'; -import { MoexClientService } from '../moex-client/moex-client.service'; +import { MoexSecuritiesClient } from '../moex-client/moex-securities.client'; +import { MoexMarketDataClient } from '../moex-client/moex-market-data.client'; +import { MoexDividendsClient } from '../moex-client/moex-dividends.client'; +import { MoexHistoryClient } from '../moex-client/moex-history.client'; import { CacheService } from '../cache/cache.service'; describe('SharesService', () => { let service: SharesService; - let moexClient: Pick; + let moexSecurities: Pick; + let moexMarketData: Pick; let cache: Pick; beforeEach(async () => { - moexClient = { + moexSecurities = { getSecurityDescription: vi.fn(), + }; + moexMarketData = { getShareMarketData: vi.fn(), }; cache = { @@ -25,7 +31,10 @@ describe('SharesService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ SharesService, - { provide: MoexClientService, useValue: moexClient }, + { provide: MoexSecuritiesClient, useValue: moexSecurities }, + { provide: MoexMarketDataClient, useValue: moexMarketData }, + { provide: MoexDividendsClient, useValue: { getDividends: vi.fn() } }, + { provide: MoexHistoryClient, useValue: { getHistory: vi.fn() } }, { provide: CacheService, useValue: cache }, ], }).compile(); @@ -34,7 +43,7 @@ describe('SharesService', () => { }); it('returns normalized SBER share spec and market data without live MOEX dependency', async () => { - vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({ + vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({ secid: 'SBER', isin: 'RU0009029540', name: 'Сбербанк России ПАО ао', @@ -52,7 +61,7 @@ describe('SharesService', () => { morningSession: true, eveningSession: true, }); - vi.mocked(moexClient.getShareMarketData).mockResolvedValue({ + vi.mocked(moexMarketData.getShareMarketData).mockResolvedValue({ secid: 'SBER', boardid: 'TQBR', shortName: 'Сбербанк', @@ -75,14 +84,14 @@ describe('SharesService', () => { const result = await service.getShare('SBER'); - expect(moexClient.getSecurityDescription).toHaveBeenCalledWith('SBER'); + expect(moexSecurities.getSecurityDescription).toHaveBeenCalledWith('SBER'); expect(cache.getOrFetch).toHaveBeenCalledWith( 'marketdata', ['shares', 'SBER'], expect.any(Function), 'marketDataTtl', ); - expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER'); + expect(moexMarketData.getShareMarketData).toHaveBeenCalledWith('SBER'); expect(result.data).toMatchObject({ secid: 'SBER', isin: 'RU0009029540', @@ -110,7 +119,7 @@ describe('SharesService', () => { }); it('throws EntityNotFoundException for non-share security', async () => { - vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({ + vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({ secid: 'SU26238RMFS5', isin: 'RU000A1038V6', name: 'ОФЗ 26238', diff --git a/apps/backend/src/modules/shares/shares.service.ts b/apps/backend/src/modules/shares/shares.service.ts index b12fe27..b147086 100644 --- a/apps/backend/src/modules/shares/shares.service.ts +++ b/apps/backend/src/modules/shares/shares.service.ts @@ -1,5 +1,8 @@ import { Injectable } from '@nestjs/common'; -import { MoexClientService } from '../moex-client/moex-client.service'; +import { MoexSecuritiesClient } from '../moex-client/moex-securities.client'; +import { MoexMarketDataClient } from '../moex-client/moex-market-data.client'; +import { MoexDividendsClient } from '../moex-client/moex-dividends.client'; +import { MoexHistoryClient } from '../moex-client/moex-history.client'; import { CacheService } from '../cache/cache.service'; import { ApiEnvelopePayload } from '../../common/dto/api-response.dto'; import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception'; @@ -7,12 +10,15 @@ import { EntityNotFoundException } from '../../common/exceptions/entity-not-foun @Injectable() export class SharesService { constructor( - private readonly moexClient: MoexClientService, + private readonly moexSecurities: MoexSecuritiesClient, + private readonly moexMarketData: MoexMarketDataClient, + private readonly moexDividends: MoexDividendsClient, + private readonly moexHistory: MoexHistoryClient, private readonly cache: CacheService, ) {} async getShare(secid: string) { - const desc = await this.moexClient.getSecurityDescription(secid); + const desc = await this.moexSecurities.getSecurityDescription(secid); if ( !desc || !( @@ -31,7 +37,7 @@ export class SharesService { } = await this.cache.getOrFetch( 'marketdata', ['shares', secid], - () => this.moexClient.getShareMarketData(secid), + () => this.moexMarketData.getShareMarketData(secid), 'marketDataTtl', ); @@ -79,7 +85,7 @@ export class SharesService { } = await this.cache.getOrFetch( 'marketdata', ['shares', secid], - () => this.moexClient.getShareMarketData(secid), + () => this.moexMarketData.getShareMarketData(secid), 'marketDataTtl', ); @@ -111,7 +117,7 @@ export class SharesService { const { data, fromCache, cachedAt } = await this.cache.getOrFetch( 'dividends', [secid], - () => this.moexClient.getDividends(secid), + () => this.moexDividends.getDividends(secid), 'dividendsTtl', ); @@ -130,7 +136,7 @@ export class SharesService { const { data, fromCache, cachedAt } = await this.cache.getOrFetch( 'history', ['shares', secid, from, till], - () => this.moexClient.getHistory(secid, from, till), + () => this.moexHistory.getHistory(secid, from, till), 'historyTtl', ); diff --git a/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts index acee4a9..d276a70 100644 --- a/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-events.service.spec.ts @@ -1,6 +1,7 @@ import { CacheService } from '../../cache/cache.service'; import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; -import { MoexClientService } from '../../moex-client/moex-client.service'; +import { MoexMarketDataClient } from '../../moex-client/moex-market-data.client'; +import { MoexDividendsClient } from '../../moex-client/moex-dividends.client'; import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerEventsService } from './broker-events.service'; import { BrokerOperationsService } from './broker-operations.service'; @@ -9,10 +10,8 @@ import { BrokerPortfolioService } from './broker-portfolio.service'; describe('BrokerEventsService', () => { const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; const portfolio = { getPositionsWithInstruments: vi.fn() } as unknown as BrokerPortfolioService; - const moex = { - getDividends: vi.fn(), - getBondPositionDataBatch: vi.fn(), - } as unknown as MoexClientService; + const moexMarketData = { getBondPositionDataBatch: vi.fn() } as unknown as MoexMarketDataClient; + const moexDividends = { getDividends: vi.fn() } as unknown as MoexDividendsClient; const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService; const cache = { getOrFetch: vi.fn() } as unknown as CacheService; @@ -42,7 +41,7 @@ describe('BrokerEventsService', () => { it('throws 404 for missing account', async () => { vi.mocked(accounts.findById).mockResolvedValue(null); - const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); + const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); await expect( service.getEvents('missing', { from: '2026-06-01', to: '2026-07-01' }), ).rejects.toThrow(EntityNotFoundException); @@ -62,7 +61,7 @@ describe('BrokerEventsService', () => { cachedAt: null, }); - const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); + const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-01', to: '2026-07-01' }); expect(result.data.items).toEqual([]); @@ -87,7 +86,7 @@ describe('BrokerEventsService', () => { ], instruments: new Map([['uid-sber', { name: 'Sberbank', currency: 'RUB' }]]), }); - vi.mocked(moex.getDividends).mockResolvedValue([ + vi.mocked(moexDividends.getDividends).mockResolvedValue([ { secid: 'SBER', isin: 'RU000A0JS', @@ -117,7 +116,7 @@ describe('BrokerEventsService', () => { cachedAt: null, }); - const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); + const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); expect(result.data.items).toHaveLength(1); @@ -144,7 +143,7 @@ describe('BrokerEventsService', () => { ], instruments: new Map([['uid-bond-1', { name: 'OFZ 26248', currency: 'RUB' }]]), }); - vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([ + vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([ { secid: 'SU26248RMFS4', couponValue: 35.4, @@ -172,7 +171,7 @@ describe('BrokerEventsService', () => { cachedAt: null, }); - const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); + const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); expect(result.data.items).toHaveLength(3); @@ -207,8 +206,8 @@ describe('BrokerEventsService', () => { ['uid-2', { name: 'Working' }], ]), }); - vi.mocked(moex.getDividends).mockRejectedValueOnce(new Error('MOEX error')); - vi.mocked(moex.getDividends).mockResolvedValueOnce([ + vi.mocked(moexDividends.getDividends).mockRejectedValueOnce(new Error('MOEX error')); + vi.mocked(moexDividends.getDividends).mockResolvedValueOnce([ { secid: 'GOOD', isin: 'RU', registryCloseDate: '2026-06-25', value: 20, currencyId: 'RUB' }, ]); @@ -218,7 +217,7 @@ describe('BrokerEventsService', () => { cachedAt: null, }); - const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); + const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); expect(result.data.items).toHaveLength(1); @@ -239,7 +238,7 @@ describe('BrokerEventsService', () => { ], instruments: new Map([['uid-1', { name: 'No Amount' }]]), }); - vi.mocked(moex.getDividends).mockResolvedValue([ + vi.mocked(moexDividends.getDividends).mockResolvedValue([ { secid: 'NO_AMT', isin: 'RU', registryCloseDate: '2026-06-25', value: 0, currencyId: 'RUB' }, ]); @@ -249,7 +248,7 @@ describe('BrokerEventsService', () => { cachedAt: null, }); - const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); + const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); expect(result.data.items).toHaveLength(1); @@ -279,10 +278,10 @@ describe('BrokerEventsService', () => { ['uid-2', { name: 'OFZ' }], ]), }); - vi.mocked(moex.getDividends).mockResolvedValue([ + vi.mocked(moexDividends.getDividends).mockResolvedValue([ { secid: 'SBER', isin: 'RU1', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' }, ]); - vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([ + vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([ { secid: 'BOND1', couponValue: 50, @@ -310,7 +309,7 @@ describe('BrokerEventsService', () => { cachedAt: null, }); - const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); + const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' }); expect(result.data.summary.eventCount).toBe(3); @@ -337,7 +336,7 @@ describe('BrokerEventsService', () => { ], instruments: new Map([['uid-1', { name: 'Sber' }]]), }); - vi.mocked(moex.getDividends).mockResolvedValue([ + vi.mocked(moexDividends.getDividends).mockResolvedValue([ { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-20', value: 10, currencyId: 'RUB' }, { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-29', value: 10, currencyId: 'RUB' }, { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-30', value: 10, currencyId: 'RUB' }, @@ -349,7 +348,7 @@ describe('BrokerEventsService', () => { cachedAt: null, }); - const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); + const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-29' }); expect(result.data.items).toHaveLength(2); @@ -377,10 +376,10 @@ describe('BrokerEventsService', () => { ], instruments: new Map(), }); - vi.mocked(moex.getDividends).mockResolvedValue([ + vi.mocked(moexDividends.getDividends).mockResolvedValue([ { secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' }, ]); - vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([ + vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([ { secid: 'BOND1', couponValue: 50, @@ -407,7 +406,7 @@ describe('BrokerEventsService', () => { cachedAt: null, }); - const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); + const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10', @@ -467,7 +466,7 @@ describe('BrokerEventsService', () => { cachedAt: null, }); - const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache); + const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache); const result = await service.getEvents('acc-1', { from: '2026-06-15', to: '2026-06-20', diff --git a/apps/backend/src/modules/tbank/services/broker-events.service.ts b/apps/backend/src/modules/tbank/services/broker-events.service.ts index 81fe004..6dd80c9 100644 --- a/apps/backend/src/modules/tbank/services/broker-events.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-events.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { CacheService } from '../../cache/cache.service'; -import { MoexClientService } from '../../moex-client/moex-client.service'; +import { MoexMarketDataClient } from '../../moex-client/moex-market-data.client'; +import { MoexDividendsClient } from '../../moex-client/moex-dividends.client'; import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto'; import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { TBANK_CACHE_KEYS } from '../tbank.config'; @@ -46,7 +47,8 @@ export class BrokerEventsService { constructor( private readonly accountsService: BrokerAccountsService, private readonly portfolioService: BrokerPortfolioService, - private readonly moexClient: MoexClientService, + private readonly moexMarketData: MoexMarketDataClient, + private readonly moexDividends: MoexDividendsClient, private readonly operationsService: BrokerOperationsService, private readonly cacheService: CacheService, ) {} @@ -139,7 +141,7 @@ export class BrokerEventsService { let dividends: { registryCloseDate: string; value: number; currencyId: string }[]; try { - dividends = await this.moexClient.getDividends(ticker); + dividends = await this.moexDividends.getDividends(ticker); } catch { return []; } @@ -199,7 +201,7 @@ export class BrokerEventsService { faceValue: number; }[]; try { - bondData = await this.moexClient.getBondPositionDataBatch(secids); + bondData = await this.moexMarketData.getBondPositionDataBatch(secids); } catch { return []; } -- 2.47.2 From 4ed6e0ce2076dfa54440a40236ea60639cb30579 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 20:54:06 +0300 Subject: [PATCH 8/9] docs: mark all tasks as completed in moex-client-split tasks.md --- docs/features/moex-client-split/tasks.md | 46 ++++++++++++------------ 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/features/moex-client-split/tasks.md b/docs/features/moex-client-split/tasks.md index 75190b8..3cf4cd7 100644 --- a/docs/features/moex-client-split/tasks.md +++ b/docs/features/moex-client-split/tasks.md @@ -2,38 +2,38 @@ ## Этап 1: Создание инфраструктурного клиента -- [ ] 1.1 Создать `moex-http.client.ts` — перенести `request()`, `extractTable()`, circuit breaker, PQueue из `MoexClientService` -- [ ] 1.2 Написать unit-тесты для `MoexHttpClient` -- [ ] 1.3 Написать тест на circuit breaker (threshold → open → reset) +- [x] 1.1 Создать `moex-http.client.ts` — перенести `request()`, `extractTable()`, circuit breaker, PQueue из `MoexClientService` +- [x] 1.2 Написать unit-тесты для `MoexHttpClient` +- [x] 1.3 Написать тест на circuit breaker (threshold → open → reset) ## Этап 2: Создание доменных клиентов -- [ ] 2.1 `MoexSecuritiesClient` — `searchSecurities()`, `getSecurityDescription()` -- [ ] 2.2 `MoexMarketDataClient` — `getShareMarketData()`, `getShareMarketDataBatch()`, `getBondData()`, `getBondMarketData()`, `getBondPositionDataBatch()` -- [ ] 2.3 `MoexCandlesClient` — `getCandles()` -- [ ] 2.4 `MoexHistoryClient` — `getHistory()`, `getBondHistory()` -- [ ] 2.5 `MoexDividendsClient` — `getDividends()` -- [ ] 2.6 Написать unit-тесты для каждого доменного клиента (mocked http client) +- [x] 2.1 `MoexSecuritiesClient` — `searchSecurities()`, `getSecurityDescription()` +- [x] 2.2 `MoexMarketDataClient` — `getShareMarketData()`, `getShareMarketDataBatch()`, `getBondData()`, `getBondMarketData()`, `getBondPositionDataBatch()` +- [x] 2.3 `MoexCandlesClient` — `getCandles()` +- [x] 2.4 `MoexHistoryClient` — `getHistory()`, `getBondHistory()` +- [x] 2.5 `MoexDividendsClient` — `getDividends()` +- [x] 2.6 Написать unit-тесты для каждого доменного клиента (mocked http client) ## Этап 3: Обновление модуля -- [ ] 3.1 Убрать `@Global()` из `MoexClientModule` -- [ ] 3.2 Добавить новые клиенты в providers/exports -- [ ] 3.3 Убрать старый `MoexClientService` из providers -- [ ] 3.4 Проверить, что `MoexHttpClient` не экспортируется +- [x] 3.1 Убрать `@Global()` из `MoexClientModule` +- [x] 3.2 Добавить новые клиенты в providers/exports +- [x] 3.3 Убрать старый `MoexClientService` из providers +- [x] 3.4 Проверить, что `MoexHttpClient` не экспортируется ## Этап 4: Миграция потребителей -- [ ] 4.1 `shares/` — обновить модуль, сервис, тесты -- [ ] 4.2 `bonds/` — обновить модуль, сервис, тесты -- [ ] 4.3 `candles/` — обновить модуль, сервис, тесты -- [ ] 4.4 `securities/` — обновить модуль, securities.service, screener.service, тесты -- [ ] 4.5 `portfolio/` — обновить модуль, сервис, тесты -- [ ] 4.6 `tbank/` — обновить broker-events.service, тесты +- [x] 4.1 `shares/` — обновить модуль, сервис, тесты +- [x] 4.2 `bonds/` — обновить модуль, сервис, тесты +- [x] 4.3 `candles/` — обновить модуль, сервис, тесты +- [x] 4.4 `securities/` — обновить модуль, securities.service, screener.service, тесты +- [x] 4.5 `portfolio/` — обновить модуль, сервис, тесты +- [x] 4.6 `tbank/` — обновить broker-events.service, тесты ## Этап 5: Финализация -- [ ] 5.1 Удалить старый `moex-client.service.ts` -- [ ] 5.2 `npm run build` успешен -- [ ] 5.3 Все тесты проходят -- [ ] 5.4 Обновить `docs/features/backend-architecture-improvements/tasks.md` — отметить Iteration 6 как выполненную +- [x] 5.1 Удалить старый `moex-client.service.ts` +- [x] 5.2 `npm run build` успешен +- [x] 5.3 Все тесты проходят +- [x] 5.4 Обновить `docs/features/backend-architecture-improvements/tasks.md` — отметить Iteration 6 как выполненную -- 2.47.2 From 58ddb10a9229a24c6d3201ebcdccfbc530e68c25 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 21:05:01 +0300 Subject: [PATCH 9/9] docs: update inbox and roadmap with completed backend architecture improvements --- docs/inbox.md | 11 +++++++++++ docs/roadmap.md | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/docs/inbox.md b/docs/inbox.md index 97380bf..c58ff4a 100644 --- a/docs/inbox.md +++ b/docs/inbox.md @@ -358,6 +358,15 @@ cash flow, бюджеты, аналитика, прогнозы и автома > фичи: `frontend-docs-sync`, `frontend-infrastructure-hardening`, `frontend-shared-boundary-cleanup`, > `frontend-test-hygiene`. Пункт P2 «Двойная система API-типов» — resolved (codegen unification). > Остальные пункты ниже остаются открытыми и нуждаются в отдельных фичах. +> +> **Обновление 2026-06-25:** Проведён аудит бэкенда (`docs/research/2026-06-25-backend-audit.md`), +> выявивший 12 архитектурных проблем. В рамках эпика `backend-architecture-improvements` выполнены: +> - Shared envelope DTO — единый `ApiResponseMeta` вместо 6 дублирующихся классов +> - Screener TTL — отдельный кеш-параметр `CACHE_SCREENER_TTL` (900s) +> - Domain exception hierarchy — `DomainException`, `EntityNotFoundException`, `MoexApiException`, `TBankApiException` +> - Health check прокачка — проверки Prisma, MOEX, T-Bank с детальным статусом +> - RequestLoggingMiddleware — перевод на `configure()` в AppModule +> - MoexClientService split — 6 клиентов вместо God Service, убран `@Global()` Текущее состояние quality gates хорошее: на момент аудита проходят lint, format-check, backend build, frontend build, 94 backend-теста и 168 frontend-тестов. @@ -418,6 +427,8 @@ frontend build, 94 backend-теста и 168 frontend-тестов. ### P2: декомпозировать крупные backend/frontend модули +- [x] **MoexClientService split (backend)** — God Service (423 строки, 11 методов) разделён на + `MoexHttpClient` + 5 доменных клиентов. `@Global()` убран. Реализовано в `backend-architecture-improvements`. - `PortfolioService` объединяет CRUD, ownership, MOEX enrichment, кеширование, расчёты PnL, дивиденды и агрегированную аналитику. - Broker UI содержит крупные страницы, таблицы и монолитный test suite; это усложнит FSD-миграцию и diff --git a/docs/roadmap.md b/docs/roadmap.md index 9743d77..cc6954d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -90,6 +90,15 @@ Roadmap отражает порядок продуктовой работы, н - [x] [frontend-test-hygiene](features/frontend-test-hygiene/spec.md) — минимизация test helpers, нормализация conventions +### [Backend Architecture Improvements](features/backend-architecture-improvements/spec.md) + +- [x] Shared envelope DTO — единый `ApiResponseMeta` вместо 6 дублирующихся классов +- [x] Screener TTL — отдельный кеш-параметр `CACHE_SCREENER_TTL` (900s) +- [x] Domain exception hierarchy — `DomainException`, `EntityNotFoundException`, `MoexApiException`, `TBankApiException` +- [x] Health check прокачка — проверки Prisma, MOEX, T-Bank с детальным статусом +- [x] RequestLoggingMiddleware — подключение через `configure()` в AppModule +- [x] MoexClientService split — 6 клиентов вместо God Service, убран `@Global()` + ## Кандидаты следующих фич - [x] [Миграция таблиц на дизайн-систему](features/table-migration/spec.md) — DividendsTable, -- 2.47.2