From 15427be3842eef422e81a5a3f143a6781ba36577 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 24 Jun 2026 09:30:03 +0300 Subject: [PATCH 1/9] docs: add spec for broker account analytics --- .../features/broker-account-analytics/spec.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/features/broker-account-analytics/spec.md diff --git a/docs/features/broker-account-analytics/spec.md b/docs/features/broker-account-analytics/spec.md new file mode 100644 index 0000000..848488c --- /dev/null +++ b/docs/features/broker-account-analytics/spec.md @@ -0,0 +1,112 @@ +# Аналитика прибыльности брокерского счёта + +Дата: 2026-06-24 +Статус: спецификация +Эпик: [Портфель брокера](../../epics/BrokerPortfolio.md) + +## Цель + +Дать пользователю брокерского счёта T-Bank отдельный раздел, где можно увидеть агрегированную аналитику прибыльности портфеля: сколько всего было вложено (нетто), сколько получено (дивиденды + купоны) и общую доходность. + +## Пользовательский результат + +Пользователь может: + +- открыть вкладку `Аналитика` внутри брокерского счёта; +- увидеть, сколько всего денег было внесено на счёт (пополнения); +- увидеть, сколько всего денег было выведено со счёта; +- увидеть нетто-вложения (пополнения минус выводы); +- увидеть сумму полученных дивидендов; +- увидеть сумму полученных купонов; +- увидеть общую сумму полученного дохода (дивиденды + купоны); +- увидеть доходность в процентах от нетто-вложений. + +## Область изменений + +Фича относится только к брокерским счетам T-Bank и включает: + +- новый backend endpoint `GET /api/v1/broker/accounts/:accountId/analytics`; +- новый сервис `BrokerAnalyticsService` с агрегацией через raw SQL; +- новую вкладку `Аналитика` в навигации брокерского счёта; +- страницу аналитики с отображением вложений, полученного дохода и сводки. + +## Требования + +### 1. Источники данных + +- Все данные агрегируются из таблицы `BrokerOperation`. +- Для расчёта вложений используются операции с типами пополнений (`OPERATION_TYPE_INPUT` и аналоги) и выводов (`OPERATION_TYPE_OUTPUT` и аналоги). +- Для расчёта полученного дохода используются операции с типами дивидендов (`OPERATION_TYPE_DIVIDEND`, `OPERATION_TYPE_DIV_EXT`) и купонов (`OPERATION_TYPE_COUPON`). +- Данные считаются «навсегда»: без фильтра по дате, за всё время существования счёта. +- Аналитика read-only и не изменяет данные. + +### 2. Endpoint + +- `GET /api/v1/broker/accounts/:accountId/analytics` — возвращает агрегированные показатели. +- Кешируется с TTL 5 минут (как существующие broker-эндпоинты). +- При отсутствии счёта возвращает 404. + +### 3. Показатели + +Endpoint возвращает: + +| Поле | Описание | +|------|----------| +| `totalDeposits` | Сумма всех пополнений счёта | +| `totalWithdrawn` | Сумма всех выводов со счёта | +| `netInvested` | `totalDeposits − totalWithdrawn` (нетто-вложения) | +| `totalDividends` | Сумма полученных дивидендов | +| `totalCoupons` | Сумма полученных купонов | +| `totalReceived` | `totalDividends + totalCoupons` | +| `totalReturnPercent` | `(totalReceived / netInvested) × 100`, если `netInvested > 0`, иначе `null` | +| `currency` | Валюта (RUB) | + +### 4. Агрегация + +- Запрос выполняется одним агрегирующим запросом к таблице `BrokerOperation`. +- Из выборки исключаются операции с `payment IS NULL`. +- Учитываются только исполненные операции (state = `OPERATION_STATE_EXECUTED` или `null`). + +### 5. Вкладка + +- В навигации брокерского счёта появляется вкладка `Аналитика` рядом с `События`. +- Вкладка использует `useBrokerAccountContext()` для получения `accountId`. +- Страница показывает три блока: + 1. **Вложено** — пополнения, выводы, нетто-итог + 2. **Получено** — дивиденды, купоны, итог + 3. **Сводка** — нетто-вложения, полученный доход, доходность в процентах + +### 6. Пустые состояния и ошибки + +- Если аналитика недоступна (нет операций), показывается пустое состояние. +- Если нетто-вложения равны нулю, `totalReturnPercent` не показывается. +- Ошибка загрузки не ломает навигацию счёта. + +## Ограничения + +- Фича работает только внутри маршрутов `/broker/:accountId/*`. +- Ручные портфели `PortfolioModule` не входят в область фичи. +- Фича не учитывает налоги, комиссии и валютную конвертацию. +- Фича не учитывает нереализованную прибыль/убыток по текущим позициям. +- Доходность считается только по полученным выплатам (дивиденды + купоны), без учёта изменения цены бумаг. + +## Acceptance Criteria + +- На странице брокерского счёта есть вкладка `Аналитика`. +- Вкладка показывает нетто-вложения (пополнения минус выводы). +- Вкладка показывает сумму полученных дивидендов. +- Вкладка показывает сумму полученных купонов. +- Вкладка показывает общую сумму полученного дохода. +- Доходность в процентах отображается, если нетто-вложения > 0. +- Данные кешируются на 5 минут. +- Пустое состояние отображается при отсутствии данных. +- Ошибка загрузки не ломает навигацию. + +## Вне области фичи + +- учёт налогов и комиссий; +- нереализованная прибыль/убыток по текущим позициям; +- ручные портфели; +- мультивалютность; +- графики и визуализация динамики; +- экспорт данных. -- 2.47.2 From 61e3398c71a53880ee3eee24a40ce510924c5f57 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 24 Jun 2026 09:38:25 +0300 Subject: [PATCH 2/9] feat(backend): add broker analytics DTO and service --- .../dto/broker-analytics-response.dto.ts | 27 + .../services/broker-analytics.service.ts | 110 +++ .../features/broker-account-analytics/plan.md | 652 ++++++++++++++++++ .../broker-account-analytics/tasks.md | 16 + 4 files changed, 805 insertions(+) create mode 100644 apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts create mode 100644 apps/backend/src/modules/tbank/services/broker-analytics.service.ts create mode 100644 docs/features/broker-account-analytics/plan.md create mode 100644 docs/features/broker-account-analytics/tasks.md diff --git a/apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts new file mode 100644 index 0000000..6aa9a89 --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class BrokerAnalyticsDto { + @ApiProperty() + totalDeposits!: number; + + @ApiProperty() + totalWithdrawn!: number; + + @ApiProperty() + netInvested!: number; + + @ApiProperty() + totalDividends!: number; + + @ApiProperty() + totalCoupons!: number; + + @ApiProperty() + totalReceived!: number; + + @ApiProperty({ type: Number, nullable: true }) + totalReturnPercent!: number | null; + + @ApiProperty() + currency!: string; +} diff --git a/apps/backend/src/modules/tbank/services/broker-analytics.service.ts b/apps/backend/src/modules/tbank/services/broker-analytics.service.ts new file mode 100644 index 0000000..e129eb4 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-analytics.service.ts @@ -0,0 +1,110 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CacheService } from '../../cache/cache.service'; +import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto'; +import { BrokerAccountsService } from './broker-accounts.service'; +import { TBANK_CACHE_KEYS } from '../tbank.config'; + +const DEPOSIT_TYPES = new Set([ + 'OPERATION_TYPE_INPUT', + 'OPERATION_TYPE_INPUT_SWIFT', + 'OPERATION_TYPE_INPUT_ACQUIRING', + 'OPERATION_TYPE_INP_MULTI', + 'OPERATION_TYPE_OVER_PLACEMENT', + 'OPERATION_TYPE_TRANS_IIS_BS', + 'OPERATION_TYPE_TRANS_BS_BS', +]); + +const WITHDRAWAL_TYPES = new Set([ + 'OPERATION_TYPE_OUTPUT', + 'OPERATION_TYPE_OUTPUT_SWIFT', + 'OPERATION_TYPE_OUTPUT_ACQUIRING', + 'OPERATION_TYPE_OUT_MULTI', +]); + +const DIVIDEND_TYPES = new Set(['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_EXT']); + +const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']); + +const ANALYTICS_TYPES = new Set([ + ...DEPOSIT_TYPES, + ...WITHDRAWAL_TYPES, + ...DIVIDEND_TYPES, + ...COUPON_TYPES, +]); + +@Injectable() +export class BrokerAnalyticsService { + constructor( + private readonly prisma: PrismaService, + private readonly accountsService: BrokerAccountsService, + private readonly cacheService: CacheService, + ) {} + + async getAnalytics(accountId: string): Promise<{ + data: BrokerAnalyticsDto; + meta: { fromCache: boolean; cachedAt: string | null }; + }> { + const account = await this.accountsService.findById(accountId); + if (!account) throw new NotFoundException('Broker account not found'); + + const result = await this.cacheService.getOrFetch( + TBANK_CACHE_KEYS.analytics, + [accountId], + () => this.computeAnalytics(accountId), + 'tbankAnalyticsTtl', + ); + + return { + data: result.data, + meta: { fromCache: result.fromCache, cachedAt: result.cachedAt }, + }; + } + + private async computeAnalytics(accountId: string): Promise { + const operations = await this.prisma.brokerOperation.findMany({ + where: { + accountId, + type: { in: Array.from(ANALYTICS_TYPES) }, + payment: { not: null }, + }, + select: { type: true, payment: true }, + }); + + let totalDeposits = 0; + let totalWithdrawn = 0; + let totalDividends = 0; + let totalCoupons = 0; + + for (const op of operations) { + const payment = JSON.parse(op.payment!); + const value = payment.value ?? 0; + + if (DEPOSIT_TYPES.has(op.type)) { + totalDeposits += value; + } else if (WITHDRAWAL_TYPES.has(op.type)) { + totalWithdrawn += Math.abs(value); + } else if (DIVIDEND_TYPES.has(op.type)) { + totalDividends += value; + } else if (COUPON_TYPES.has(op.type)) { + totalCoupons += value; + } + } + + const netInvested = totalDeposits - totalWithdrawn; + const totalReceived = totalDividends + totalCoupons; + const totalReturnPercent = + netInvested > 0 ? Math.round((totalReceived / netInvested) * 10000) / 100 : null; + + return { + totalDeposits: Math.round(totalDeposits * 100) / 100, + totalWithdrawn: Math.round(totalWithdrawn * 100) / 100, + netInvested: Math.round(netInvested * 100) / 100, + totalDividends: Math.round(totalDividends * 100) / 100, + totalCoupons: Math.round(totalCoupons * 100) / 100, + totalReceived: Math.round(totalReceived * 100) / 100, + totalReturnPercent, + currency: 'RUB', + }; + } +} diff --git a/docs/features/broker-account-analytics/plan.md b/docs/features/broker-account-analytics/plan.md new file mode 100644 index 0000000..2b8cc3e --- /dev/null +++ b/docs/features/broker-account-analytics/plan.md @@ -0,0 +1,652 @@ +# Аналитика прибыльности брокерского счёта — Implementation Plan + +> **For agentic workers:** Use subagent-driven-development or executing-plans to implement this plan task-by-task. + +**Goal:** Add an analytics tab to the broker account page showing net invested vs received (dividends + coupons). + +**Architecture:** New backend service aggregates BrokerOperation records by type via Prisma, returns DTO. New frontend tab page displays invested/received blocks. + +**Tech Stack:** NestJS + Prisma (SQLite), React + TanStack Query + react-router + +--- + +## File Structure + +### Backend (new/modified) +- Create: `apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts` +- Create: `apps/backend/src/modules/tbank/services/broker-analytics.service.ts` +- Modify: `apps/backend/src/modules/tbank/tbank.controller.ts` — add `GET /analytics` endpoint +- Modify: `apps/backend/src/modules/tbank/tbank.module.ts` — register service + +### Frontend (new/modified) +- Create: `apps/frontend/src/entities/broker-analytics/api/brokerAnalyticsApi.ts` +- Create: `apps/frontend/src/entities/broker-analytics/model/useBrokerAnalytics.ts` +- Create: `apps/frontend/src/entities/broker-analytics/index.ts` +- Create: `apps/frontend/src/pages/broker-analytics/ui/BrokerAnalyticsPage.tsx` +- Create: `apps/frontend/src/pages/broker-analytics/index.ts` +- Modify: `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx` +- Modify: `apps/frontend/src/app/routing/routeTree.tsx` +- Modify: `apps/frontend/src/shared/api/types.ts` + +--- + +### Task 1: Backend DTO и сервис аналитики + +**Files:** +- Create: `apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts` +- Create: `apps/backend/src/modules/tbank/services/broker-analytics.service.ts` + +#### Шаг 1.1: Создать DTO + +```typescript +import { ApiProperty } from '@nestjs/swagger'; + +export class BrokerAnalyticsDto { + @ApiProperty() + totalDeposits!: number; + + @ApiProperty() + totalWithdrawn!: number; + + @ApiProperty() + netInvested!: number; + + @ApiProperty() + totalDividends!: number; + + @ApiProperty() + totalCoupons!: number; + + @ApiProperty() + totalReceived!: number; + + @ApiProperty({ type: Number, nullable: true }) + totalReturnPercent!: number | null; + + @ApiProperty() + currency!: string; +} +``` + +#### Шаг 1.2: Создать сервис `BrokerAnalyticsService` + +```typescript +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CacheService } from '../../cache/cache.service'; +import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto'; +import { BrokerAccountsService } from './broker-accounts.service'; +import { TBANK_CACHE_KEYS } from '../tbank.config'; + +const DEPOSIT_TYPES = new Set([ + 'OPERATION_TYPE_INPUT', + 'OPERATION_TYPE_INPUT_SWIFT', + 'OPERATION_TYPE_INPUT_ACQUIRING', + 'OPERATION_TYPE_INP_MULTI', + 'OPERATION_TYPE_OVER_PLACEMENT', + 'OPERATION_TYPE_TRANS_IIS_BS', + 'OPERATION_TYPE_TRANS_BS_BS', +]); + +const WITHDRAWAL_TYPES = new Set([ + 'OPERATION_TYPE_OUTPUT', + 'OPERATION_TYPE_OUTPUT_SWIFT', + 'OPERATION_TYPE_OUTPUT_ACQUIRING', + 'OPERATION_TYPE_OUT_MULTI', +]); + +const DIVIDEND_TYPES = new Set(['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_EXT']); + +const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']); + +const ANALYTICS_TYPES = new Set([ + ...DEPOSIT_TYPES, + ...WITHDRAWAL_TYPES, + ...DIVIDEND_TYPES, + ...COUPON_TYPES, +]); + +@Injectable() +export class BrokerAnalyticsService { + constructor( + private readonly prisma: PrismaService, + private readonly accountsService: BrokerAccountsService, + private readonly cacheService: CacheService, + ) {} + + async getAnalytics(accountId: string): Promise<{ + data: BrokerAnalyticsDto; + meta: { fromCache: boolean; cachedAt: string | null }; + }> { + const account = await this.accountsService.findById(accountId); + if (!account) throw new NotFoundException('Broker account not found'); + + return this.cacheService.getOrFetch( + TBANK_CACHE_KEYS.analytics, + [accountId], + () => this.computeAnalytics(accountId), + 'tbankAnalyticsTtl', + ); + } + + private async computeAnalytics(accountId: string): Promise { + const operations = await this.prisma.brokerOperation.findMany({ + where: { + accountId, + type: { in: Array.from(ANALYTICS_TYPES) }, + payment: { not: null }, + }, + select: { type: true, payment: true }, + }); + + let totalDeposits = 0; + let totalWithdrawn = 0; + let totalDividends = 0; + let totalCoupons = 0; + + for (const op of operations) { + const payment = JSON.parse(op.payment!); + const value = payment.value ?? 0; + + if (DEPOSIT_TYPES.has(op.type)) { + totalDeposits += value; + } else if (WITHDRAWAL_TYPES.has(op.type)) { + totalWithdrawn += Math.abs(value); + } else if (DIVIDEND_TYPES.has(op.type)) { + totalDividends += value; + } else if (COUPON_TYPES.has(op.type)) { + totalCoupons += value; + } + } + + const netInvested = totalDeposits - totalWithdrawn; + const totalReceived = totalDividends + totalCoupons; + const totalReturnPercent = + netInvested > 0 ? Math.round((totalReceived / netInvested) * 10000) / 100 : null; + + return { + totalDeposits: Math.round(totalDeposits * 100) / 100, + totalWithdrawn: Math.round(totalWithdrawn * 100) / 100, + netInvested: Math.round(netInvested * 100) / 100, + totalDividends: Math.round(totalDividends * 100) / 100, + totalCoupons: Math.round(totalCoupons * 100) / 100, + totalReceived: Math.round(totalReceived * 100) / 100, + totalReturnPercent, + currency: 'RUB', + }; + } +} +``` + +#### Шаг 1.3: Проверить сборку + +```bash +npm run build -w apps/backend +``` + +#### Шаг 1.4: Закоммитить + +```bash +git add apps/backend/src/modules/tbank/dto/broker-analytics-response.dto.ts +git add apps/backend/src/modules/tbank/services/broker-analytics.service.ts +git commit -m "feat(backend): add broker analytics DTO and service" +``` + +--- + +### Task 2: Backend controller, module registration и конфиг кеша + +**Files:** +- Modify: `apps/backend/src/modules/tbank/tbank.controller.ts` +- Modify: `apps/backend/src/modules/tbank/tbank.module.ts` +- Modify: `apps/backend/src/modules/tbank/tbank.config.ts` +- Modify: `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts` +- Modify: `apps/backend/src/config/configuration.ts` + +#### Шаг 2.1: Добавить cache key + +В `apps/backend/src/modules/tbank/tbank.config.ts`: + +```typescript +export const TBANK_CACHE_KEYS = { + // ... existing keys + analytics: 'tbank:analytics', +} as const; +``` + +#### Шаг 2.2: Добавить TTL config + +В `apps/backend/src/config/configuration.ts`, в секцию `cache`: + +```typescript +tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10), +``` + +#### Шаг 2.3: Зарегистрировать сервис в `TbankModule` + +В `apps/backend/src/modules/tbank/tbank.module.ts`: +- Добавить `BrokerAnalyticsService` в `providers` + +```typescript +import { BrokerAnalyticsService } from './services/broker-analytics.service'; + +@Module({ + providers: [ + // ... existing services + BrokerAnalyticsService, + ], +}) +``` + +#### Шаг 2.4: Добавить envelope DTO + +В `apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts`: + +```typescript +import { BrokerAnalyticsDto } from './broker-analytics-response.dto'; + +export class BrokerAnalyticsEnvelopeDto { + @ApiProperty({ type: BrokerAnalyticsDto }) + data!: BrokerAnalyticsDto; + + @ApiProperty({ type: BrokerResponseMetaDto }) + meta!: BrokerResponseMetaDto; +} +``` + +#### Шаг 2.5: Добавить endpoint в `TbankController` + +```typescript +import { BrokerAnalyticsService } from './services/broker-analytics.service'; +import { BrokerAnalyticsEnvelopeDto } from './dto/broker-envelope.dto'; + +@Get('accounts/:accountId/analytics') +@ApiOperation({ summary: 'Get broker account profitability analytics' }) +@ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto }) +async getAnalytics(@Param('accountId') accountId: string) { + const result = await this.brokerAnalyticsService.getAnalytics(accountId); + return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt); +} +``` + +Добавить `private readonly brokerAnalyticsService: BrokerAnalyticsService` в конструктор. + +#### Шаг 2.6: Проверить сборку + +```bash +npm run build -w apps/backend +``` + +#### Шаг 2.7: Закоммитить + +```bash +git add apps/backend/src/modules/tbank/ +git add apps/backend/src/config/configuration.ts +git commit -m "feat(backend): add broker analytics endpoint with caching" +``` + +--- + +### Task 3: Frontend — shared types barrel, entity API и хук + +**Files:** +- Modify: `apps/frontend/src/shared/api/index.ts` +- Modify: `apps/frontend/src/shared/api/types.ts` (codegen) +- Create: `apps/frontend/src/entities/broker-analytics/api/brokerAnalyticsApi.ts` +- Create: `apps/frontend/src/entities/broker-analytics/model/useBrokerAnalytics.ts` +- Create: `apps/frontend/src/entities/broker-analytics/index.ts` + +#### Шаг 3.1: Добавить тип `BrokerAnalyticsDto` в `types.ts` + +Добавить в `components['schemas']` секцию `shared/api/types.ts`: + +```typescript +BrokerAnalyticsDto: { + totalDeposits: number + totalWithdrawn: number + netInvested: number + totalDividends: number + totalCoupons: number + totalReceived: number + totalReturnPercent: number | null + currency: string +} +``` + +#### Шаг 3.2: Добавить брокерский тип в barrel export + +В `apps/frontend/src/shared/api/index.ts`: + +```typescript +// Broker analytics +export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto'] +``` + +#### Шаг 3.3: Создать API функцию + +```typescript +// apps/frontend/src/entities/broker-analytics/api/brokerAnalyticsApi.ts +import type { ApiResponseMeta, BrokerAnalytics } from '@/shared/api' +import { request } from '@/shared/api/kyClient' + +export function getBrokerAnalytics( + accountId: string, +): Promise<{ data: BrokerAnalytics; meta: ApiResponseMeta }> { + return request( + `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/analytics`, + ) +} +``` + +#### Шаг 3.4: Создать хук + +```typescript +// apps/frontend/src/entities/broker-analytics/model/useBrokerAnalytics.ts +import { useQuery } from '@tanstack/react-query' +import type { BrokerAnalytics } from '@/shared/api' +import { getBrokerAnalytics } from '../api/brokerAnalyticsApi' + +export function useBrokerAnalytics(accountId: string | undefined) { + return useQuery({ + queryKey: ['broker', 'analytics', accountId], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerAnalytics(accountId!)).data, + staleTime: 300_000, + retry: 2, + refetchOnWindowFocus: false, + }) +} +``` + +#### Шаг 3.5: Создать barrel export + +```typescript +// apps/frontend/src/entities/broker-analytics/index.ts +export { getBrokerAnalytics } from './api/brokerAnalyticsApi' +export { useBrokerAnalytics } from './model/useBrokerAnalytics' +``` + +#### Шаг 3.6: Проверить сборку + +```bash +npm run build -w apps/frontend +``` + +#### Шаг 3.7: Закоммитить + +```bash +git add apps/frontend/src/entities/broker-analytics/ +git add apps/frontend/src/shared/api/ +git commit -m "feat(frontend): add broker analytics data layer" +``` + +--- + +### Task 4: Frontend — страница аналитики + +**Files:** +- Create: `apps/frontend/src/pages/broker-analytics/ui/BrokerAnalyticsPage.tsx` +- Create: `apps/frontend/src/pages/broker-analytics/index.ts` + +#### Шаг 4.1: Создать страницу + +```tsx +// apps/frontend/src/pages/broker-analytics/ui/BrokerAnalyticsPage.tsx +import { useBrokerAccountContext } from '@/widgets/broker-account-layout'; +import { useBrokerAnalytics } from '@/entities/broker-analytics'; + +function formatAmount(value: number, currency: string): string { + return `${value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}`; +} + +export function BrokerAnalyticsPage() { + const { accountId } = useBrokerAccountContext(); + const { data, isLoading, isError } = useBrokerAnalytics(accountId); + + if (isLoading) { + return ( +
+
Загрузка...
+
+ ); + } + + if (isError) { + return ( +
+
+ Не удалось загрузить аналитику +
+
+ ); + } + + if (!data || (data.totalDeposits === 0 && data.totalReceived === 0)) { + return ( +
+
+ Нет данных для аналитики +
+
+ ); + } + + return ( +
+
+ {/* Вложено */} +
+

Вложено

+
+ + + + +
+
+ + {/* Получено */} +
+

Получено

+
+ + + + +
+
+
+ + {/* Сводка */} +
+

Сводка

+
+ + + {data.totalReturnPercent !== null && ( + + )} +
+
+
+ ); +} + +function Row({ label, value, bold, positive, negative }: { + label: string; + value: string; + bold?: boolean; + positive?: boolean; + negative?: boolean; +}) { + const color = positive + ? 'var(--color-text-positive, #22c55e)' + : negative + ? 'var(--color-text-negative, #ef4444)' + : 'var(--color-text-primary)'; + return ( +
+ {label} + {value} +
+ ); +} + +function Divider() { + return ( +
+ ); +} + +function SummaryItem({ label, value, positive }: { + label: string; + value: string; + positive?: boolean; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} +``` + +#### Шаг 4.2: Создать barrel + +```typescript +// apps/frontend/src/pages/broker-analytics/index.ts +export { BrokerAnalyticsPage } from './ui/BrokerAnalyticsPage' +``` + +#### Шаг 4.3: Проверить сборку + +```bash +npm run build -w apps/frontend +``` + +#### Шаг 4.4: Закоммитить + +```bash +git add apps/frontend/src/pages/broker-analytics/ +git commit -m "feat(frontend): add broker analytics page" +``` + +--- + +### Task 5: Frontend — роут и таб навигации + +**Files:** +- Modify: `apps/frontend/src/app/routing/routeTree.tsx` +- Modify: `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx` + +#### Шаг 5.1: Добавить роут + +В `apps/frontend/src/app/routing/routeTree.tsx`: + +```typescript +import { BrokerAnalyticsPage } from '@/pages/broker-analytics' + +const brokerAnalyticsRoute = createRoute({ + getParentRoute: () => brokerAccountRoot, + path: '/analytics', + component: BrokerAnalyticsPage, +}) + +// Добавить в brokerAccountRoot.addChildren([...]) +brokerAccountRoot.addChildren([ + brokerAccountIndexRoute, + brokerSharesRoute, + brokerBondsRoute, + brokerOperationsRoute, + brokerEventsRoute, + brokerAnalyticsRoute, // <-- добавить +]) +``` + +#### Шаг 5.2: Добавить таб в навигацию + +В `apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx`: + +```typescript +const links = [ + { to: '', label: 'Обзор' }, + { to: '/shares', label: 'Акции' }, + { to: '/bonds', label: 'Облигации' }, + { to: '/operations', label: 'Операции' }, + { to: '/events', label: 'События' }, + { to: '/analytics', label: 'Аналитика' }, +] +``` + +#### Шаг 5.3: Проверить сборку + +```bash +npm run build -w apps/frontend +``` + +#### Шаг 5.4: Закоммитить + +```bash +git add apps/frontend/src/app/routing/routeTree.tsx +git add apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx +git commit -m "feat(frontend): add analytics route and tab" +``` + +--- + +### Task 6: Проверка линта и тестов + +#### Шаг 6.1: Запустить линт и сборку + +```bash +npm run lint -w apps/backend && npm run build -w apps/backend +npm run lint -w apps/frontend && npm run build -w apps/frontend +``` + +#### Шаг 6.2: Запустить тесты + +```bash +npm test -w apps/backend -- --run +npm test -w apps/frontend -- --run +``` + +#### Шаг 6.3: Если всё ок — закоммитить финальные правки и запушить + +```bash +git add -A +git commit -m "chore: fix lint and tests" +``` diff --git a/docs/features/broker-account-analytics/tasks.md b/docs/features/broker-account-analytics/tasks.md new file mode 100644 index 0000000..864f391 --- /dev/null +++ b/docs/features/broker-account-analytics/tasks.md @@ -0,0 +1,16 @@ +# Аналитика прибыльности брокерского счёта — Tasks + +## Backend + +- [ ] **T1** DTO `BrokerAnalyticsDto` + сервис `BrokerAnalyticsService` с агрегацией и кешем +- [ ] **T2** Controller endpoint, module registration, cache key, config TTL + +## Frontend + +- [ ] **T3** Shared типы, entity API, хук `useBrokerAnalytics` +- [ ] **T4** Страница `BrokerAnalyticsPage` с вёрсткой трёх блоков +- [ ] **T5** Роут `/analytics` + таб в навигации + +## Проверка + +- [ ] **T6** Линт, сборка, тесты -- 2.47.2 From 5184d342629c10c5caa707132fffea381ee9c887 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 24 Jun 2026 09:40:41 +0300 Subject: [PATCH 3/9] fix(backend): handle malformed payment JSON in analytics service --- .../modules/tbank/services/broker-analytics.service.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 e129eb4..54c8ff5 100644 --- a/apps/backend/src/modules/tbank/services/broker-analytics.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-analytics.service.ts @@ -77,8 +77,13 @@ export class BrokerAnalyticsService { let totalCoupons = 0; for (const op of operations) { - const payment = JSON.parse(op.payment!); - const value = payment.value ?? 0; + let value = 0; + try { + const payment = JSON.parse(op.payment!); + value = payment.value ?? 0; + } catch { + continue; + } if (DEPOSIT_TYPES.has(op.type)) { totalDeposits += value; -- 2.47.2 From 5cd56b239a940e39f1d289ffa1fe859c5483dcfb Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 24 Jun 2026 09:41:57 +0300 Subject: [PATCH 4/9] feat: wire up broker analytics endpoint, cache config, and module registration - Add analytics cache key to TBANK_CACHE_KEYS - Add tbankAnalyticsTtl to cache configuration - Register and export BrokerAnalyticsService in TBankModule - Add BrokerAnalyticsEnvelopeDto to broker envelope DTOs - Add GET /accounts/:accountId/analytics endpoint to TBankController - Fix TBankController spec to pass new constructor dependency --- apps/backend/src/config/configuration.ts | 1 + .../src/modules/tbank/dto/broker-envelope.dto.ts | 9 +++++++++ apps/backend/src/modules/tbank/tbank.config.ts | 1 + .../src/modules/tbank/tbank.controller.spec.ts | 8 +++++--- apps/backend/src/modules/tbank/tbank.controller.ts | 11 +++++++++++ apps/backend/src/modules/tbank/tbank.module.ts | 2 ++ 6 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/config/configuration.ts b/apps/backend/src/config/configuration.ts index d51e259..9eaf969 100644 --- a/apps/backend/src/config/configuration.ts +++ b/apps/backend/src/config/configuration.ts @@ -35,6 +35,7 @@ export default registerAs('app', () => ({ tbankOperationsTtl: parseInt(process.env.CACHE_TBANK_OPERATIONS_TTL || '300', 10), tbankPositionsTtl: parseInt(process.env.CACHE_TBANK_POSITIONS_TTL || '60', 10), tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10), + tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10), }, auth: { jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production', 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 8e8da50..50f047a 100644 --- a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts @@ -5,6 +5,7 @@ import { BrokerOperationSyncResponseDto } from './broker-operation-sync-query.dt import { BrokerOperationsPageResponseDto } from './broker-operation-response.dto'; import { BrokerPositionsPageResponseDto } from './broker-positions-page-response.dto'; import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto'; +import { BrokerAnalyticsDto } from './broker-analytics-response.dto'; export class BrokerResponseMetaDto { @ApiProperty({ nullable: true }) @@ -54,6 +55,14 @@ export class BrokerOperationSyncEnvelopeDto { meta!: BrokerResponseMetaDto; } +export class BrokerAnalyticsEnvelopeDto { + @ApiProperty({ type: BrokerAnalyticsDto }) + data!: BrokerAnalyticsDto; + + @ApiProperty({ type: BrokerResponseMetaDto }) + meta!: BrokerResponseMetaDto; +} + export class BrokerEventsEnvelopeDto { @ApiProperty({ type: BrokerEventsDataDto }) data!: BrokerEventsDataDto; diff --git a/apps/backend/src/modules/tbank/tbank.config.ts b/apps/backend/src/modules/tbank/tbank.config.ts index 902fa4f..944712f 100644 --- a/apps/backend/src/modules/tbank/tbank.config.ts +++ b/apps/backend/src/modules/tbank/tbank.config.ts @@ -20,4 +20,5 @@ export const TBANK_CACHE_KEYS = { operations: 'tbank:operations', instrument: 'tbank:instrument', events: 'tbank:events', + analytics: 'tbank:analytics', } as const; diff --git a/apps/backend/src/modules/tbank/tbank.controller.spec.ts b/apps/backend/src/modules/tbank/tbank.controller.spec.ts index c9cc156..8520b10 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.spec.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.spec.ts @@ -2,6 +2,7 @@ import { ROLES_KEY } from '../auth/decorators/roles.decorator'; import { ApiResponse } from '../../common/dto/api-response.dto'; import { TBankController } from './tbank.controller'; import { BrokerAccountsService } from './services/broker-accounts.service'; +import { BrokerAnalyticsService } from './services/broker-analytics.service'; import { BrokerEventsService } from './services/broker-events.service'; import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationsService } from './services/broker-operations.service'; @@ -9,6 +10,7 @@ import { BrokerPortfolioService } from './services/broker-portfolio.service'; describe('TBankController', () => { const accounts = { findAll: vi.fn() } as unknown as BrokerAccountsService; + const analytics = { getAnalytics: vi.fn() } as unknown as BrokerAnalyticsService; const portfolio = { getPortfolio: vi.fn() } as unknown as BrokerPortfolioService; const events = { getEvents: vi.fn() } as unknown as BrokerEventsService; const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService; @@ -37,7 +39,7 @@ describe('TBankController', () => { meta: { fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' }, }); - const controller = new TBankController(accounts, portfolio, events, operations, sync); + const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics); const response = await controller.getAccounts(); expect(response).toBeInstanceOf(ApiResponse); @@ -48,7 +50,7 @@ describe('TBankController', () => { it('exposes a sync trigger for durable operation history', async () => { vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 }); - const controller = new TBankController(accounts, portfolio, events, operations, sync); + const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics); const response = await controller.syncOperations('acc-1', { from: '2026-06-01T00:00:00.000Z', to: '2026-06-17T00:00:00.000Z', @@ -84,7 +86,7 @@ describe('TBankController', () => { meta: { fromCache: false, cachedAt: '2026-06-22T00:00:00.000Z' }, }); - const controller = new TBankController(accounts, portfolio, events, operations, sync); + const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics); const query = { from: '2026-06-22', to: '2026-07-29', types: 'dividend,coupon' }; const response = await controller.getEvents('acc-1', query); diff --git a/apps/backend/src/modules/tbank/tbank.controller.ts b/apps/backend/src/modules/tbank/tbank.controller.ts index 6d52de7..95526a0 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.ts @@ -4,6 +4,7 @@ import { ApiResponse } from '../../common/dto/api-response.dto'; import { Roles } from '../auth/decorators/roles.decorator'; import { BrokerAccountsEnvelopeDto, + BrokerAnalyticsEnvelopeDto, BrokerEventsEnvelopeDto, BrokerOperationSyncEnvelopeDto, BrokerOperationsEnvelopeDto, @@ -18,6 +19,7 @@ import { BrokerAccountsService } from './services/broker-accounts.service'; import { BrokerEventsService } from './services/broker-events.service'; import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; import { BrokerOperationsService } from './services/broker-operations.service'; +import { BrokerAnalyticsService } from './services/broker-analytics.service'; import { BrokerPortfolioService } from './services/broker-portfolio.service'; @ApiTags('Broker') @@ -31,6 +33,7 @@ export class TBankController { private readonly brokerEventsService: BrokerEventsService, private readonly brokerOperationsService: BrokerOperationsService, private readonly brokerOperationSyncService: BrokerOperationSyncService, + private readonly brokerAnalyticsService: BrokerAnalyticsService, ) {} @Get('accounts') @@ -84,6 +87,14 @@ export class TBankController { return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt); } + @Get('accounts/:accountId/analytics') + @ApiOperation({ summary: 'Get broker account profitability analytics' }) + @ApiOkResponse({ type: BrokerAnalyticsEnvelopeDto }) + async getAnalytics(@Param('accountId') accountId: string) { + const result = await this.brokerAnalyticsService.getAnalytics(accountId); + return new ApiResponse(result.data, result.meta.fromCache, result.meta.cachedAt); + } + @Post('accounts/:accountId/operations/sync') @ApiOperation({ summary: 'Synchronize T-Bank broker account operations into local history' }) @ApiOkResponse({ type: BrokerOperationSyncEnvelopeDto }) diff --git a/apps/backend/src/modules/tbank/tbank.module.ts b/apps/backend/src/modules/tbank/tbank.module.ts index 92dd7f7..918bfe2 100644 --- a/apps/backend/src/modules/tbank/tbank.module.ts +++ b/apps/backend/src/modules/tbank/tbank.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { MoexClientModule } from '../moex-client/moex-client.module'; import { TBankController } from './tbank.controller'; import { BrokerAccountsService } from './services/broker-accounts.service'; +import { BrokerAnalyticsService } from './services/broker-analytics.service'; import { BrokerInstrumentsService } from './services/broker-instruments.service'; import { BrokerEventsService } from './services/broker-events.service'; import { BrokerOperationSyncService } from './services/broker-operation-sync.service'; @@ -20,6 +21,7 @@ import { TBankClientService } from './services/tbank-client.service'; BrokerEventsService, BrokerOperationsService, BrokerOperationSyncService, + BrokerAnalyticsService, ], exports: [ TBankClientService, -- 2.47.2 From abf3676ba15da3aa6aba9d6007702699af285d5e Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 24 Jun 2026 09:42:35 +0300 Subject: [PATCH 5/9] fix(backend): export BrokerAnalyticsService from TBankModule --- apps/backend/src/modules/tbank/tbank.module.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backend/src/modules/tbank/tbank.module.ts b/apps/backend/src/modules/tbank/tbank.module.ts index 918bfe2..f30e320 100644 --- a/apps/backend/src/modules/tbank/tbank.module.ts +++ b/apps/backend/src/modules/tbank/tbank.module.ts @@ -26,6 +26,7 @@ import { TBankClientService } from './services/tbank-client.service'; exports: [ TBankClientService, BrokerAccountsService, + BrokerAnalyticsService, BrokerInstrumentsService, BrokerPortfolioService, BrokerEventsService, -- 2.47.2 From 3f0fb42d99f7f12021e4897adfd3a57cde6de89c Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 24 Jun 2026 09:44:35 +0300 Subject: [PATCH 6/9] feat: add BrokerAnalyticsDto type, API function, and hook --- .../broker-analytics/api/brokerAnalyticsApi.ts | 10 ++++++++++ .../src/entities/broker-analytics/index.ts | 2 ++ .../broker-analytics/model/useBrokerAnalytics.ts | 14 ++++++++++++++ apps/frontend/src/shared/api/index.ts | 3 +++ apps/frontend/src/shared/api/types.ts | 10 ++++++++++ 5 files changed, 39 insertions(+) create mode 100644 apps/frontend/src/entities/broker-analytics/api/brokerAnalyticsApi.ts create mode 100644 apps/frontend/src/entities/broker-analytics/index.ts create mode 100644 apps/frontend/src/entities/broker-analytics/model/useBrokerAnalytics.ts diff --git a/apps/frontend/src/entities/broker-analytics/api/brokerAnalyticsApi.ts b/apps/frontend/src/entities/broker-analytics/api/brokerAnalyticsApi.ts new file mode 100644 index 0000000..aeaa747 --- /dev/null +++ b/apps/frontend/src/entities/broker-analytics/api/brokerAnalyticsApi.ts @@ -0,0 +1,10 @@ +import type { ApiResponseMeta, BrokerAnalytics } from '@/shared/api' +import { request } from '@/shared/api/kyClient' + +export function getBrokerAnalytics( + accountId: string, +): Promise<{ data: BrokerAnalytics; meta: ApiResponseMeta }> { + return request( + `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/analytics`, + ) +} diff --git a/apps/frontend/src/entities/broker-analytics/index.ts b/apps/frontend/src/entities/broker-analytics/index.ts new file mode 100644 index 0000000..1869154 --- /dev/null +++ b/apps/frontend/src/entities/broker-analytics/index.ts @@ -0,0 +1,2 @@ +export { getBrokerAnalytics } from './api/brokerAnalyticsApi' +export { useBrokerAnalytics } from './model/useBrokerAnalytics' diff --git a/apps/frontend/src/entities/broker-analytics/model/useBrokerAnalytics.ts b/apps/frontend/src/entities/broker-analytics/model/useBrokerAnalytics.ts new file mode 100644 index 0000000..d499448 --- /dev/null +++ b/apps/frontend/src/entities/broker-analytics/model/useBrokerAnalytics.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query' +import type { BrokerAnalytics } from '@/shared/api' +import { getBrokerAnalytics } from '../api/brokerAnalyticsApi' + +export function useBrokerAnalytics(accountId: string | undefined) { + return useQuery({ + queryKey: ['broker', 'analytics', accountId], + enabled: Boolean(accountId), + queryFn: async () => (await getBrokerAnalytics(accountId!)).data, + staleTime: 300_000, + retry: 2, + refetchOnWindowFocus: false, + }) +} diff --git a/apps/frontend/src/shared/api/index.ts b/apps/frontend/src/shared/api/index.ts index 566da60..f7a43d2 100644 --- a/apps/frontend/src/shared/api/index.ts +++ b/apps/frontend/src/shared/api/index.ts @@ -60,3 +60,6 @@ export type BrokerOperationCategory = export type BrokerEventItem = components['schemas']['BrokerEventItemDto'] export type BrokerEventsData = components['schemas']['BrokerEventsDataDto'] export type BrokerEventsSummary = components['schemas']['BrokerEventsSummaryDto'] + +// Broker analytics +export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto'] diff --git a/apps/frontend/src/shared/api/types.ts b/apps/frontend/src/shared/api/types.ts index 33b54c3..0f22223 100644 --- a/apps/frontend/src/shared/api/types.ts +++ b/apps/frontend/src/shared/api/types.ts @@ -1193,6 +1193,16 @@ export interface components { actualCouponsTotal: number actualPrincipalRepaymentTotal: number } + BrokerAnalyticsDto: { + totalDeposits: number + totalWithdrawn: number + netInvested: number + totalDividends: number + totalCoupons: number + totalReceived: number + totalReturnPercent: number | null + currency: string + } BrokerEventsDataDto: { items: components['schemas']['BrokerEventItemDto'][] summary: components['schemas']['BrokerEventsSummaryDto'] -- 2.47.2 From a1e68fb13a161b1cf4e26d5647295e4f94872fac Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 24 Jun 2026 09:45:59 +0300 Subject: [PATCH 7/9] feat: add BrokerAnalyticsPage component with loading/error/empty states --- .../src/pages/broker-analytics/index.ts | 1 + .../ui/BrokerAnalyticsPage.tsx | 162 ++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 apps/frontend/src/pages/broker-analytics/index.ts create mode 100644 apps/frontend/src/pages/broker-analytics/ui/BrokerAnalyticsPage.tsx diff --git a/apps/frontend/src/pages/broker-analytics/index.ts b/apps/frontend/src/pages/broker-analytics/index.ts new file mode 100644 index 0000000..0872b61 --- /dev/null +++ b/apps/frontend/src/pages/broker-analytics/index.ts @@ -0,0 +1 @@ +export { BrokerAnalyticsPage } from './ui/BrokerAnalyticsPage' diff --git a/apps/frontend/src/pages/broker-analytics/ui/BrokerAnalyticsPage.tsx b/apps/frontend/src/pages/broker-analytics/ui/BrokerAnalyticsPage.tsx new file mode 100644 index 0000000..0350ef3 --- /dev/null +++ b/apps/frontend/src/pages/broker-analytics/ui/BrokerAnalyticsPage.tsx @@ -0,0 +1,162 @@ +import { useBrokerAnalytics } from '@/entities/broker-analytics' +import { useBrokerAccountContext } from '@/widgets/broker-account-layout' + +function formatAmount(value: number, currency: string): string { + return `${value.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${currency}` +} + +export function BrokerAnalyticsPage() { + const { accountId } = useBrokerAccountContext() + const { data, isLoading, isError } = useBrokerAnalytics(accountId) + + if (isLoading) { + return ( +
+
Загрузка...
+
+ ) + } + + if (isError) { + return ( +
+
+ Не удалось загрузить аналитику +
+
+ ) + } + + if (!data || (data.totalDeposits === 0 && data.totalReceived === 0)) { + return ( +
+
+ Нет данных для аналитики +
+
+ ) + } + + return ( +
+
+ {/* Вложено */} +
+

Вложено

+
+ + + + +
+
+ + {/* Получено */} +
+

Получено

+
+ + + + +
+
+
+ + {/* Сводка */} +
+

Сводка

+
+ + + {data.totalReturnPercent !== null && ( + + )} +
+
+
+ ) +} + +function Row({ + label, + value, + bold, + negative, +}: { + label: string + value: string + bold?: boolean + negative?: boolean +}) { + const color = negative ? 'var(--color-text-negative, #ef4444)' : 'var(--color-text-primary)' + return ( +
+ {label} + {value} +
+ ) +} + +function Divider() { + return ( +
+ ) +} + +function SummaryItem({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ) +} -- 2.47.2 From ef4bc48e50420993a28f598e2b430b39dbdc9466 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 24 Jun 2026 09:46:38 +0300 Subject: [PATCH 8/9] feat: add analytics route and tab navigation for broker account --- apps/frontend/src/app/routing/routeTree.tsx | 8 ++++++++ .../broker-account-layout/ui/BrokerAccountLayout.tsx | 1 + 2 files changed, 9 insertions(+) diff --git a/apps/frontend/src/app/routing/routeTree.tsx b/apps/frontend/src/app/routing/routeTree.tsx index 80f0e9d..e284c9d 100644 --- a/apps/frontend/src/app/routing/routeTree.tsx +++ b/apps/frontend/src/app/routing/routeTree.tsx @@ -9,6 +9,7 @@ import { useSessionStore } from '@/entities/session' import { BondPage } from '@/pages/bond' import { BrokerAccountOverviewPage } from '@/pages/broker-account' import { BrokerAccountsPage } from '@/pages/broker-accounts' +import { BrokerAnalyticsPage } from '@/pages/broker-analytics' import { BrokerEventsPage } from '@/pages/broker-events' import { BrokerOperationsPage } from '@/pages/broker-operations' import { BrokerPositionsPage } from '@/pages/broker-positions' @@ -137,6 +138,12 @@ const brokerEventsRoute = createRoute({ component: BrokerEventsPage, }) +const brokerAnalyticsRoute = createRoute({ + getParentRoute: () => brokerAccountRoot, + path: '/analytics', + component: BrokerAnalyticsPage, +}) + const routeTree = rootRoute.addChildren([ indexRoute, stockRoute, @@ -154,6 +161,7 @@ const routeTree = rootRoute.addChildren([ brokerBondsRoute, brokerOperationsRoute, brokerEventsRoute, + brokerAnalyticsRoute, ]), ]) diff --git a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx index d524645..e029550 100644 --- a/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx +++ b/apps/frontend/src/widgets/broker-account-layout/ui/BrokerAccountLayout.tsx @@ -24,6 +24,7 @@ const links = [ { to: '/bonds', label: 'Облигации' }, { to: '/operations', label: 'Операции' }, { to: '/events', label: 'События' }, + { to: '/analytics', label: 'Аналитика' }, ] export interface BrokerAccountContextValue { -- 2.47.2 From 3dfcf5aaa86654893385bd6ae160b3815c98e3ab Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 24 Jun 2026 11:29:09 +0300 Subject: [PATCH 9/9] feat: complete broker account analytics with sync button, tests, and state filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add state filter (EXECUTED/null) to analytics query (spec compliance) - Add service unit tests (11 tests) and controller test - Add sync button to operations page with mutation hook - Regenerate frontend types via codegen - Update tasks.md marking all items complete Backend: 114 tests, Frontend: 116 tests — all pass --- .../services/broker-analytics.service.spec.ts | 232 ++++++++++++++++++ .../services/broker-analytics.service.ts | 4 + .../modules/tbank/tbank.controller.spec.ts | 25 ++ .../api/brokerOperationApi.ts | 17 +- .../src/entities/broker-operation/index.ts | 1 + .../model/useSyncBrokerOperations.ts | 17 ++ .../ui/BrokerOperationsPage.tsx | 39 ++- apps/frontend/src/shared/api/index.ts | 3 + apps/frontend/src/shared/api/types.ts | 56 ++++- .../broker-account-analytics/tasks.md | 12 +- 10 files changed, 388 insertions(+), 18 deletions(-) create mode 100644 apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts create mode 100644 apps/frontend/src/entities/broker-operation/model/useSyncBrokerOperations.ts 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 new file mode 100644 index 0000000..4e3b9d7 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-analytics.service.spec.ts @@ -0,0 +1,232 @@ +import { NotFoundException } from '@nestjs/common'; +import { CacheService } from '../../cache/cache.service'; +import { BrokerAccountsService } from './broker-accounts.service'; +import { BrokerAnalyticsService } from './broker-analytics.service'; +import { PrismaService } from '../../prisma/prisma.service'; + +describe('BrokerAnalyticsService', () => { + const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; + const prisma = { brokerOperation: { findMany: vi.fn() } } as unknown as PrismaService; + const cache = { getOrFetch: vi.fn() } as unknown as CacheService; + + const acc1 = { + id: 'acc-1', + type: 'brokerage' as const, + name: 'Test Broker', + status: 'ACCOUNT_STATUS_OPEN', + openedAt: null, + accessLevel: null, + }; + + function mockCachePassthrough() { + vi.mocked(cache.getOrFetch).mockImplementation( + async (_prefix: string, _parts: string[], fetchFn: () => Promise) => ({ + data: await fetchFn(), + fromCache: false, + cachedAt: null, + }), + ); + } + + function makeOp(type: string, value: number, state?: string | null) { + return { type, payment: JSON.stringify({ value, currency: 'RUB' }), state } as any; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('throws 404 for missing account', async () => { + vi.mocked(accounts.findById).mockResolvedValue(null); + + const service = new BrokerAnalyticsService(prisma, accounts, cache); + await expect(service.getAnalytics('missing')).rejects.toThrow(NotFoundException); + }); + + it('returns zeros for account with no operations', async () => { + vi.mocked(accounts.findById).mockResolvedValue(acc1); + mockCachePassthrough(); + vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([]); + + const service = new BrokerAnalyticsService(prisma, accounts, cache); + const result = await service.getAnalytics('acc-1'); + + expect(result.data).toEqual({ + totalDeposits: 0, + totalWithdrawn: 0, + netInvested: 0, + totalDividends: 0, + totalCoupons: 0, + totalReceived: 0, + totalReturnPercent: null, + currency: 'RUB', + }); + }); + + it('aggregates deposit types correctly', async () => { + vi.mocked(accounts.findById).mockResolvedValue(acc1); + mockCachePassthrough(); + vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ + makeOp('OPERATION_TYPE_INPUT', 1000), + makeOp('OPERATION_TYPE_INPUT_SWIFT', 500), + makeOp('OPERATION_TYPE_INP_MULTI', 200), + makeOp('OPERATION_TYPE_OVER_PLACEMENT', 300), + makeOp('OPERATION_TYPE_TRANS_IIS_BS', 100), + makeOp('OPERATION_TYPE_TRANS_BS_BS', 50), + makeOp('OPERATION_TYPE_INPUT_ACQUIRING', 150), + ]); + + const service = new BrokerAnalyticsService(prisma, accounts, cache); + const result = await service.getAnalytics('acc-1'); + + expect(result.data.totalDeposits).toBe(2300); + expect(result.data.totalWithdrawn).toBe(0); + expect(result.data.netInvested).toBe(2300); + }); + + it('aggregates withdrawal types with absolute value', async () => { + vi.mocked(accounts.findById).mockResolvedValue(acc1); + mockCachePassthrough(); + vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ + makeOp('OPERATION_TYPE_OUTPUT', -500), + makeOp('OPERATION_TYPE_OUTPUT_SWIFT', -200), + makeOp('OPERATION_TYPE_OUTPUT_ACQUIRING', -100), + makeOp('OPERATION_TYPE_OUT_MULTI', -50), + makeOp('OPERATION_TYPE_INPUT', 1000), + ]); + + const service = new BrokerAnalyticsService(prisma, accounts, cache); + const result = await service.getAnalytics('acc-1'); + + expect(result.data.totalDeposits).toBe(1000); + expect(result.data.totalWithdrawn).toBe(850); + expect(result.data.netInvested).toBe(150); + }); + + it('aggregates dividend and coupon types', async () => { + vi.mocked(accounts.findById).mockResolvedValue(acc1); + mockCachePassthrough(); + vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ + makeOp('OPERATION_TYPE_DIVIDEND', 300), + makeOp('OPERATION_TYPE_DIV_EXT', 150), + makeOp('OPERATION_TYPE_COUPON', 75), + makeOp('OPERATION_TYPE_COUPON', 25), + ]); + + const service = new BrokerAnalyticsService(prisma, accounts, cache); + const result = await service.getAnalytics('acc-1'); + + expect(result.data.totalDividends).toBe(450); + expect(result.data.totalCoupons).toBe(100); + expect(result.data.totalReceived).toBe(550); + }); + + it('calculates totalReturnPercent correctly', async () => { + vi.mocked(accounts.findById).mockResolvedValue(acc1); + mockCachePassthrough(); + vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ + makeOp('OPERATION_TYPE_INPUT', 10000), + makeOp('OPERATION_TYPE_DIVIDEND', 500), + makeOp('OPERATION_TYPE_COUPON', 200), + ]); + + const service = new BrokerAnalyticsService(prisma, accounts, cache); + const result = await service.getAnalytics('acc-1'); + + expect(result.data.netInvested).toBe(10000); + expect(result.data.totalReceived).toBe(700); + expect(result.data.totalReturnPercent).toBe(7); + }); + + it('returns null totalReturnPercent when netInvested <= 0', async () => { + vi.mocked(accounts.findById).mockResolvedValue(acc1); + mockCachePassthrough(); + vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ + makeOp('OPERATION_TYPE_OUTPUT', -500), + makeOp('OPERATION_TYPE_DIVIDEND', 100), + ]); + + const service = new BrokerAnalyticsService(prisma, accounts, cache); + const result = await service.getAnalytics('acc-1'); + + expect(result.data.netInvested).toBe(-500); + expect(result.data.totalReturnPercent).toBeNull(); + }); + + it('handles malformed payment JSON gracefully', async () => { + vi.mocked(accounts.findById).mockResolvedValue(acc1); + mockCachePassthrough(); + vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ + { type: 'OPERATION_TYPE_INPUT', payment: 'invalid-json', state: 'OPERATION_STATE_EXECUTED' }, + { type: 'OPERATION_TYPE_INPUT', payment: JSON.stringify({ value: 500, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED' }, + ] as any); + + const service = new BrokerAnalyticsService(prisma, accounts, cache); + const result = await service.getAnalytics('acc-1'); + + expect(result.data.totalDeposits).toBe(500); + }); + + it('ignores non-executed and non-null state operations', async () => { + vi.mocked(accounts.findById).mockResolvedValue(acc1); + mockCachePassthrough(); + + const service = new BrokerAnalyticsService(prisma, accounts, cache); + await service.getAnalytics('acc-1'); + + expect(prisma.brokerOperation.findMany).toHaveBeenCalledWith({ + where: { + accountId: 'acc-1', + type: { in: expect.any(Array) }, + payment: { not: null }, + OR: [ + { state: 'OPERATION_STATE_EXECUTED' }, + { state: null }, + ], + }, + select: { type: true, payment: true }, + }); + }); + + it('rounds all monetary values to 2 decimal places', async () => { + vi.mocked(accounts.findById).mockResolvedValue(acc1); + mockCachePassthrough(); + vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ + makeOp('OPERATION_TYPE_INPUT', 100.336), + makeOp('OPERATION_TYPE_DIVIDEND', 50.789), + ]); + + const service = new BrokerAnalyticsService(prisma, accounts, cache); + const result = await service.getAnalytics('acc-1'); + + expect(result.data.totalDeposits).toBe(100.34); + expect(result.data.totalDividends).toBe(50.79); + expect(result.data.totalReceived).toBe(50.79); + expect(result.data.netInvested).toBe(100.34); + }); + + it('wraps result in ApiResponse envelope through cache', async () => { + vi.mocked(accounts.findById).mockResolvedValue(acc1); + vi.mocked(cache.getOrFetch).mockResolvedValue({ + data: { + totalDeposits: 1000, + totalWithdrawn: 0, + netInvested: 1000, + totalDividends: 0, + totalCoupons: 0, + totalReceived: 0, + totalReturnPercent: null, + currency: 'RUB', + }, + fromCache: true, + cachedAt: '2026-06-24T10:00:00.000Z', + }); + + const service = new BrokerAnalyticsService(prisma, accounts, cache); + const result = await service.getAnalytics('acc-1'); + + expect(result.data.netInvested).toBe(1000); + expect(result.meta.fromCache).toBe(true); + expect(result.meta.cachedAt).toBe('2026-06-24T10:00:00.000Z'); + }); +}); 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 54c8ff5..4380b81 100644 --- a/apps/backend/src/modules/tbank/services/broker-analytics.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-analytics.service.ts @@ -67,6 +67,10 @@ export class BrokerAnalyticsService { accountId, type: { in: Array.from(ANALYTICS_TYPES) }, payment: { not: null }, + OR: [ + { state: 'OPERATION_STATE_EXECUTED' }, + { state: null }, + ], }, select: { type: true, payment: true }, }); diff --git a/apps/backend/src/modules/tbank/tbank.controller.spec.ts b/apps/backend/src/modules/tbank/tbank.controller.spec.ts index 8520b10..8bebdc9 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.spec.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.spec.ts @@ -63,6 +63,31 @@ describe('TBankController', () => { expect(response.data).toEqual({ upserted: 2 }); }); + it('exposes analytics endpoint through controller', async () => { + const analyticsData = { + totalDeposits: 1000, + totalWithdrawn: 200, + netInvested: 800, + totalDividends: 150, + totalCoupons: 50, + totalReceived: 200, + totalReturnPercent: 25, + currency: 'RUB', + }; + vi.mocked(analytics.getAnalytics).mockResolvedValueOnce({ + data: analyticsData, + meta: { fromCache: false, cachedAt: null }, + }); + + const controller = new TBankController(accounts, portfolio, events, operations, sync, analytics); + const response = await controller.getAnalytics('acc-1'); + + expect(analytics.getAnalytics).toHaveBeenCalledWith('acc-1'); + expect(response).toBeInstanceOf(ApiResponse); + expect(response.data).toEqual(analyticsData); + expect(response.meta).toEqual({ fromCache: false, cachedAt: null }); + }); + it('forwards events query and wraps response', async () => { const eventsData = { items: [], diff --git a/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts index e5d5b2f..2db1252 100644 --- a/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts +++ b/apps/frontend/src/entities/broker-operation/api/brokerOperationApi.ts @@ -1,4 +1,8 @@ -import type { ApiResponseMeta, BrokerOperationsPage } from '@/shared/api' +import type { + ApiResponseMeta, + BrokerOperationSyncResponse, + BrokerOperationsPage, +} from '@/shared/api' import { request } from '@/shared/api/kyClient' export type BrokerOperationQuery = { @@ -11,6 +15,17 @@ export type BrokerOperationQuery = { state?: string } +export function syncBrokerOperations( + accountId: string, + query: { from: string; to: string }, +): Promise<{ data: BrokerOperationSyncResponse; meta: ApiResponseMeta }> { + return request( + `/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations/sync`, + { from: query.from, to: query.to }, + { method: 'POST' }, + ) +} + export function getBrokerOperations( accountId: string, query: BrokerOperationQuery = {}, diff --git a/apps/frontend/src/entities/broker-operation/index.ts b/apps/frontend/src/entities/broker-operation/index.ts index 90c1f15..ad585c4 100644 --- a/apps/frontend/src/entities/broker-operation/index.ts +++ b/apps/frontend/src/entities/broker-operation/index.ts @@ -7,3 +7,4 @@ export { isBrokerOperationType, } from './model/operationFilters' export { useBrokerOperations } from './model/useBrokerOperations' +export { useSyncBrokerOperations } from './model/useSyncBrokerOperations' diff --git a/apps/frontend/src/entities/broker-operation/model/useSyncBrokerOperations.ts b/apps/frontend/src/entities/broker-operation/model/useSyncBrokerOperations.ts new file mode 100644 index 0000000..de04e64 --- /dev/null +++ b/apps/frontend/src/entities/broker-operation/model/useSyncBrokerOperations.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { syncBrokerOperations } from '../api/brokerOperationApi' + +export function useSyncBrokerOperations(accountId: string | undefined) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (range: { from: string; to: string }) => { + if (!accountId) throw new Error('Account ID required') + return (await syncBrokerOperations(accountId, range)).data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['broker', 'analytics', accountId] }) + queryClient.invalidateQueries({ queryKey: ['broker', 'events', accountId] }) + }, + }) +} diff --git a/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx b/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx index 0d6e038..52162b6 100644 --- a/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx +++ b/apps/frontend/src/pages/broker-operations/ui/BrokerOperationsPage.tsx @@ -1,18 +1,29 @@ -import { Heading, Text } from '@moex-vibe/design-system' +import { Button, Heading, Text } from '@moex-vibe/design-system' import { Box } from '@mui/material' import { useEffect } from 'react' import { BROKER_OPERATION_TYPE_OPTIONS, isBrokerOperationType, useBrokerOperations, + useSyncBrokerOperations, } from '@/entities/broker-operation' import { useSearchParamsCompat } from '@/shared/lib/router/useSearchParams' import { useCursorPagination } from '@/shared/lib/useCursorPagination' import { useBrokerAccountContext } from '@/widgets/broker-account-layout' import { BrokerOperationsTable } from '@/widgets/broker-operations-table' +function getDefaultSyncRange() { + const now = new Date() + const start = new Date(Date.UTC(now.getUTCFullYear(), 0, 1)) + return { + from: start.toISOString(), + to: now.toISOString(), + } +} + export function BrokerOperationsPage() { const { accountId } = useBrokerAccountContext() + const sync = useSyncBrokerOperations(accountId) const [searchParams, setSearchParams] = useSearchParamsCompat() const urlType = searchParams.get('type') const selectedType = isBrokerOperationType(urlType) ? urlType : '' @@ -56,14 +67,24 @@ export function BrokerOperationsPage() { /> ) + function handleSync() { + const range = getDefaultSyncRange() + sync.mutate(range) + } + return ( - - Операции - + + + Операции + + + Тип операции + {sync.isSuccess && ( + + Синхронизировано: {sync.data?.upserted} операций + + )} + {sync.isError && ( + + Ошибка синхронизации + + )} {history} ) diff --git a/apps/frontend/src/shared/api/index.ts b/apps/frontend/src/shared/api/index.ts index f7a43d2..7d21f41 100644 --- a/apps/frontend/src/shared/api/index.ts +++ b/apps/frontend/src/shared/api/index.ts @@ -63,3 +63,6 @@ export type BrokerEventsSummary = components['schemas']['BrokerEventsSummaryDto' // Broker analytics export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto'] + +// Broker sync +export type BrokerOperationSyncResponse = components['schemas']['BrokerOperationSyncResponseDto'] diff --git a/apps/frontend/src/shared/api/types.ts b/apps/frontend/src/shared/api/types.ts index 0f22223..76b48a8 100644 --- a/apps/frontend/src/shared/api/types.ts +++ b/apps/frontend/src/shared/api/types.ts @@ -468,6 +468,23 @@ export interface paths { patch?: never trace?: never } + '/api/v1/broker/accounts/{accountId}/analytics': { + parameters: { + query?: never + header?: never + path?: never + cookie?: never + } + /** Get broker account profitability analytics */ + get: operations['TBankController_getAnalytics'] + put?: never + post?: never + delete?: never + options?: never + head?: never + patch?: never + trace?: never + } '/api/v1/broker/accounts/{accountId}/operations/sync': { parameters: { query?: never @@ -1193,6 +1210,15 @@ export interface components { actualCouponsTotal: number actualPrincipalRepaymentTotal: number } + BrokerEventsDataDto: { + items: components['schemas']['BrokerEventItemDto'][] + summary: components['schemas']['BrokerEventsSummaryDto'] + asOf: string + } + BrokerEventsEnvelopeDto: { + data: components['schemas']['BrokerEventsDataDto'] + meta: components['schemas']['BrokerResponseMetaDto'] + } BrokerAnalyticsDto: { totalDeposits: number totalWithdrawn: number @@ -1203,13 +1229,8 @@ export interface components { totalReturnPercent: number | null currency: string } - BrokerEventsDataDto: { - items: components['schemas']['BrokerEventItemDto'][] - summary: components['schemas']['BrokerEventsSummaryDto'] - asOf: string - } - BrokerEventsEnvelopeDto: { - data: components['schemas']['BrokerEventsDataDto'] + BrokerAnalyticsEnvelopeDto: { + data: components['schemas']['BrokerAnalyticsDto'] meta: components['schemas']['BrokerResponseMetaDto'] } BrokerOperationSyncResponseDto: { @@ -1977,6 +1998,27 @@ export interface operations { } } } + TBankController_getAnalytics: { + parameters: { + query?: never + header?: never + path: { + accountId: string + } + cookie?: never + } + requestBody?: never + responses: { + 200: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['BrokerAnalyticsEnvelopeDto'] + } + } + } + } TBankController_syncOperations: { parameters: { query: { diff --git a/docs/features/broker-account-analytics/tasks.md b/docs/features/broker-account-analytics/tasks.md index 864f391..8361d07 100644 --- a/docs/features/broker-account-analytics/tasks.md +++ b/docs/features/broker-account-analytics/tasks.md @@ -2,15 +2,15 @@ ## Backend -- [ ] **T1** DTO `BrokerAnalyticsDto` + сервис `BrokerAnalyticsService` с агрегацией и кешем -- [ ] **T2** Controller endpoint, module registration, cache key, config TTL +- [x] **T1** DTO `BrokerAnalyticsDto` + сервис `BrokerAnalyticsService` с агрегацией и кешем +- [x] **T2** Controller endpoint, module registration, cache key, config TTL ## Frontend -- [ ] **T3** Shared типы, entity API, хук `useBrokerAnalytics` -- [ ] **T4** Страница `BrokerAnalyticsPage` с вёрсткой трёх блоков -- [ ] **T5** Роут `/analytics` + таб в навигации +- [x] **T3** Shared типы, entity API, хук `useBrokerAnalytics` +- [x] **T4** Страница `BrokerAnalyticsPage` с вёрсткой трёх блоков +- [x] **T5** Роут `/analytics` + таб в навигации ## Проверка -- [ ] **T6** Линт, сборка, тесты +- [x] **T6** Линт, сборка, тесты -- 2.47.2