19 KiB
Аналитика прибыльности брокерского счёта — 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— addGET /analyticsendpoint - 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
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
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<BrokerAnalyticsDto> {
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: Проверить сборку
npm run build -w apps/backend
Шаг 1.4: Закоммитить
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:
export const TBANK_CACHE_KEYS = {
// ... existing keys
analytics: 'tbank:analytics',
} as const;
Шаг 2.2: Добавить TTL config
В apps/backend/src/config/configuration.ts, в секцию cache:
tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10),
Шаг 2.3: Зарегистрировать сервис в TbankModule
В apps/backend/src/modules/tbank/tbank.module.ts:
- Добавить
BrokerAnalyticsServiceвproviders
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:
import { BrokerAnalyticsDto } from './broker-analytics-response.dto';
export class BrokerAnalyticsEnvelopeDto {
@ApiProperty({ type: BrokerAnalyticsDto })
data!: BrokerAnalyticsDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}
Шаг 2.5: Добавить endpoint в TbankController
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: Проверить сборку
npm run build -w apps/backend
Шаг 2.7: Закоммитить
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:
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:
// Broker analytics
export type BrokerAnalytics = components['schemas']['BrokerAnalyticsDto']
Шаг 3.3: Создать API функцию
// 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<BrokerAnalytics>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/analytics`,
)
}
Шаг 3.4: Создать хук
// 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<BrokerAnalytics>({
queryKey: ['broker', 'analytics', accountId],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerAnalytics(accountId!)).data,
staleTime: 300_000,
retry: 2,
refetchOnWindowFocus: false,
})
}
Шаг 3.5: Создать barrel export
// apps/frontend/src/entities/broker-analytics/index.ts
export { getBrokerAnalytics } from './api/brokerAnalyticsApi'
export { useBrokerAnalytics } from './model/useBrokerAnalytics'
Шаг 3.6: Проверить сборку
npm run build -w apps/frontend
Шаг 3.7: Закоммитить
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: Создать страницу
// 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 (
<section>
<div style={{ padding: '24px', color: 'var(--color-text-secondary)' }}>Загрузка...</div>
</section>
);
}
if (isError) {
return (
<section>
<div style={{ padding: '24px', color: 'var(--color-text-negative)' }}>
Не удалось загрузить аналитику
</div>
</section>
);
}
if (!data || (data.totalDeposits === 0 && data.totalReceived === 0)) {
return (
<section>
<div style={{ padding: '24px', color: 'var(--color-text-secondary)' }}>
Нет данных для аналитики
</div>
</section>
);
}
return (
<section>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '16px',
marginBottom: '24px',
}}>
{/* Вложено */}
<div style={{
background: 'var(--color-surface-card)',
borderRadius: '12px',
padding: '20px',
}}>
<h3 style={{ margin: '0 0 16px', fontSize: '16px', fontWeight: 600 }}>Вложено</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<Row label="Пополнения" value={formatAmount(data.totalDeposits, data.currency)} />
<Row label="Выводы" value={`−${formatAmount(data.totalWithdrawn, data.currency)}`} negative />
<Divider />
<Row label="Нетто" value={formatAmount(data.netInvested, data.currency)} bold />
</div>
</div>
{/* Получено */}
<div style={{
background: 'var(--color-surface-card)',
borderRadius: '12px',
padding: '20px',
}}>
<h3 style={{ margin: '0 0 16px', fontSize: '16px', fontWeight: 600 }}>Получено</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<Row label="Дивиденды" value={formatAmount(data.totalDividends, data.currency)} positive />
<Row label="Купоны" value={formatAmount(data.totalCoupons, data.currency)} positive />
<Divider />
<Row label="Итого" value={formatAmount(data.totalReceived, data.currency)} bold positive />
</div>
</div>
</div>
{/* Сводка */}
<div style={{
background: 'var(--color-surface-card)',
borderRadius: '12px',
padding: '20px',
}}>
<h3 style={{ margin: '0 0 16px', fontSize: '16px', fontWeight: 600 }}>Сводка</h3>
<div style={{ display: 'flex', gap: '48px' }}>
<SummaryItem label="Вложено нетто" value={formatAmount(data.netInvested, data.currency)} />
<SummaryItem label="Получено" value={formatAmount(data.totalReceived, data.currency)} />
{data.totalReturnPercent !== null && (
<SummaryItem
label="Доходность"
value={`${data.totalReturnPercent.toFixed(2)}%`}
positive
/>
)}
</div>
</div>
</section>
);
}
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 (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: 'var(--color-text-secondary)', fontSize: '14px' }}>{label}</span>
<span style={{ fontWeight: bold ? 600 : 400, color, fontSize: '15px' }}>{value}</span>
</div>
);
}
function Divider() {
return (
<div style={{
height: '1px',
background: 'var(--color-border)',
margin: '4px 0',
}} />
);
}
function SummaryItem({ label, value, positive }: {
label: string;
value: string;
positive?: boolean;
}) {
return (
<div>
<div style={{ color: 'var(--color-text-secondary)', fontSize: '13px', marginBottom: '4px' }}>
{label}
</div>
<div style={{
fontSize: '20px',
fontWeight: 600,
color: positive ? 'var(--color-text-positive, #22c55e)' : 'var(--color-text-primary)',
}}>
{value}
</div>
</div>
);
}
Шаг 4.2: Создать barrel
// apps/frontend/src/pages/broker-analytics/index.ts
export { BrokerAnalyticsPage } from './ui/BrokerAnalyticsPage'
Шаг 4.3: Проверить сборку
npm run build -w apps/frontend
Шаг 4.4: Закоммитить
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:
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:
const links = [
{ to: '', label: 'Обзор' },
{ to: '/shares', label: 'Акции' },
{ to: '/bonds', label: 'Облигации' },
{ to: '/operations', label: 'Операции' },
{ to: '/events', label: 'События' },
{ to: '/analytics', label: 'Аналитика' },
]
Шаг 5.3: Проверить сборку
npm run build -w apps/frontend
Шаг 5.4: Закоммитить
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: Запустить линт и сборку
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: Запустить тесты
npm test -w apps/backend -- --run
npm test -w apps/frontend -- --run
Шаг 6.3: Если всё ок — закоммитить финальные правки и запушить
git add -A
git commit -m "chore: fix lint and tests"