codex/broker-account-analytics #41

Merged
ksv741 merged 9 commits from codex/broker-account-analytics into main 2026-06-24 12:09:38 +03:00
25 changed files with 1538 additions and 8 deletions

View File

@ -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',

View File

@ -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;
}

View File

@ -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;

View File

@ -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<unknown>) => ({
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');
});
});

View File

@ -0,0 +1,119 @@
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<BrokerAnalyticsDto> {
const operations = await this.prisma.brokerOperation.findMany({
where: {
accountId,
type: { in: Array.from(ANALYTICS_TYPES) },
payment: { not: null },
OR: [
{ state: 'OPERATION_STATE_EXECUTED' },
{ state: null },
],
},
select: { type: true, payment: true },
});
let totalDeposits = 0;
let totalWithdrawn = 0;
let totalDividends = 0;
let totalCoupons = 0;
for (const op of operations) {
let value = 0;
try {
const payment = JSON.parse(op.payment!);
value = payment.value ?? 0;
} catch {
continue;
}
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',
};
}
}

View File

@ -20,4 +20,5 @@ export const TBANK_CACHE_KEYS = {
operations: 'tbank:operations',
instrument: 'tbank:instrument',
events: 'tbank:events',
analytics: 'tbank:analytics',
} as const;

View File

@ -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',
@ -61,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: [],
@ -84,7 +111,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);

View File

@ -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 })

View File

@ -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,10 +21,12 @@ import { TBankClientService } from './services/tbank-client.service';
BrokerEventsService,
BrokerOperationsService,
BrokerOperationSyncService,
BrokerAnalyticsService,
],
exports: [
TBankClientService,
BrokerAccountsService,
BrokerAnalyticsService,
BrokerInstrumentsService,
BrokerPortfolioService,
BrokerEventsService,

View File

@ -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,
]),
])

View File

@ -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<BrokerAnalytics>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/analytics`,
)
}

View File

@ -0,0 +1,2 @@
export { getBrokerAnalytics } from './api/brokerAnalyticsApi'
export { useBrokerAnalytics } from './model/useBrokerAnalytics'

View File

@ -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<BrokerAnalytics>({
queryKey: ['broker', 'analytics', accountId],
enabled: Boolean(accountId),
queryFn: async () => (await getBrokerAnalytics(accountId!)).data,
staleTime: 300_000,
retry: 2,
refetchOnWindowFocus: false,
})
}

View File

@ -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<BrokerOperationSyncResponse>(
`/api/v1/broker/accounts/${encodeURIComponent(accountId)}/operations/sync`,
{ from: query.from, to: query.to },
{ method: 'POST' },
)
}
export function getBrokerOperations(
accountId: string,
query: BrokerOperationQuery = {},

View File

@ -7,3 +7,4 @@ export {
isBrokerOperationType,
} from './model/operationFilters'
export { useBrokerOperations } from './model/useBrokerOperations'
export { useSyncBrokerOperations } from './model/useSyncBrokerOperations'

View File

@ -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] })
},
})
}

View File

@ -0,0 +1 @@
export { BrokerAnalyticsPage } from './ui/BrokerAnalyticsPage'

View File

@ -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 (
<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)} />
<Row label="Купоны" value={formatAmount(data.totalCoupons, data.currency)} />
<Divider />
<Row label="Итого" value={formatAmount(data.totalReceived, data.currency)} bold />
</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)}%`} />
)}
</div>
</div>
</section>
)
}
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 (
<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 }: { label: string; value: string }) {
return (
<div>
<div style={{ color: 'var(--color-text-secondary)', fontSize: '13px', marginBottom: '4px' }}>
{label}
</div>
<div
style={{
fontSize: '20px',
fontWeight: 600,
color: 'var(--color-text-primary)',
}}
>
{value}
</div>
</div>
)
}

View File

@ -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 (
<Box component="section" aria-labelledby="broker-operations-heading">
<Box
sx={{ display: 'flex', alignItems: 'end', justifyContent: 'space-between', gap: 2, mb: 2 }}
>
<Box sx={{ display: 'flex', alignItems: 'end', gap: 2 }}>
<Heading level={2} id="broker-operations-heading">
Операции
</Heading>
<Button variant="secondary" size="small" loading={sync.isPending} onClick={handleSync}>
Синхронизировать
</Button>
</Box>
<Box component="label" sx={{ display: 'grid', gap: 0.5, color: 'text.secondary' }}>
<Text variant="label">Тип операции</Text>
<select value={selectedType} onChange={handleTypeChange}>
@ -76,6 +97,16 @@ export function BrokerOperationsPage() {
</select>
</Box>
</Box>
{sync.isSuccess && (
<Text component="p" tone="positive" style={{ marginBottom: '12px' }}>
Синхронизировано: {sync.data?.upserted} операций
</Text>
)}
{sync.isError && (
<Text component="p" tone="negative" style={{ marginBottom: '12px' }}>
Ошибка синхронизации
</Text>
)}
{history}
</Box>
)

View File

@ -60,3 +60,9 @@ 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']
// Broker sync
export type BrokerOperationSyncResponse = components['schemas']['BrokerOperationSyncResponseDto']

View File

@ -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
@ -1202,6 +1219,20 @@ export interface components {
data: components['schemas']['BrokerEventsDataDto']
meta: components['schemas']['BrokerResponseMetaDto']
}
BrokerAnalyticsDto: {
totalDeposits: number
totalWithdrawn: number
netInvested: number
totalDividends: number
totalCoupons: number
totalReceived: number
totalReturnPercent: number | null
currency: string
}
BrokerAnalyticsEnvelopeDto: {
data: components['schemas']['BrokerAnalyticsDto']
meta: components['schemas']['BrokerResponseMetaDto']
}
BrokerOperationSyncResponseDto: {
/** @example 42 */
upserted: number
@ -1967,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: {

View File

@ -24,6 +24,7 @@ const links = [
{ to: '/bonds', label: 'Облигации' },
{ to: '/operations', label: 'Операции' },
{ to: '/events', label: 'События' },
{ to: '/analytics', label: 'Аналитика' },
]
export interface BrokerAccountContextValue {

View File

@ -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<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: Проверить сборку
```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<BrokerAnalytics>(
`/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<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
```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 (
<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
```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"
```

View File

@ -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 минут.
- Пустое состояние отображается при отсутствии данных.
- Ошибка загрузки не ломает навигацию.
## Вне области фичи
- учёт налогов и комиссий;
- нереализованная прибыль/убыток по текущим позициям;
- ручные портфели;
- мультивалютность;
- графики и визуализация динамики;
- экспорт данных.

View File

@ -0,0 +1,16 @@
# Аналитика прибыльности брокерского счёта — Tasks
## Backend
- [x] **T1** DTO `BrokerAnalyticsDto` + сервис `BrokerAnalyticsService` с агрегацией и кешем
- [x] **T2** Controller endpoint, module registration, cache key, config TTL
## Frontend
- [x] **T3** Shared типы, entity API, хук `useBrokerAnalytics`
- [x] **T4** Страница `BrokerAnalyticsPage` с вёрсткой трёх блоков
- [x] **T5** Роут `/analytics` + таб в навигации
## Проверка
- [x] **T6** Линт, сборка, тесты