codex/broker-account-analytics #41
@ -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',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -20,4 +20,5 @@ export const TBANK_CACHE_KEYS = {
|
||||
operations: 'tbank:operations',
|
||||
instrument: 'tbank:instrument',
|
||||
events: 'tbank:events',
|
||||
analytics: 'tbank:analytics',
|
||||
} as const;
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
]),
|
||||
])
|
||||
|
||||
|
||||
@ -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`,
|
||||
)
|
||||
}
|
||||
2
apps/frontend/src/entities/broker-analytics/index.ts
Normal file
2
apps/frontend/src/entities/broker-analytics/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { getBrokerAnalytics } from './api/brokerAnalyticsApi'
|
||||
export { useBrokerAnalytics } from './model/useBrokerAnalytics'
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
@ -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 = {},
|
||||
|
||||
@ -7,3 +7,4 @@ export {
|
||||
isBrokerOperationType,
|
||||
} from './model/operationFilters'
|
||||
export { useBrokerOperations } from './model/useBrokerOperations'
|
||||
export { useSyncBrokerOperations } from './model/useSyncBrokerOperations'
|
||||
|
||||
@ -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] })
|
||||
},
|
||||
})
|
||||
}
|
||||
1
apps/frontend/src/pages/broker-analytics/index.ts
Normal file
1
apps/frontend/src/pages/broker-analytics/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { BrokerAnalyticsPage } from './ui/BrokerAnalyticsPage'
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 }}
|
||||
>
|
||||
<Heading level={2} id="broker-operations-heading">
|
||||
Операции
|
||||
</Heading>
|
||||
<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>
|
||||
)
|
||||
|
||||
@ -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']
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -24,6 +24,7 @@ const links = [
|
||||
{ to: '/bonds', label: 'Облигации' },
|
||||
{ to: '/operations', label: 'Операции' },
|
||||
{ to: '/events', label: 'События' },
|
||||
{ to: '/analytics', label: 'Аналитика' },
|
||||
]
|
||||
|
||||
export interface BrokerAccountContextValue {
|
||||
|
||||
652
docs/features/broker-account-analytics/plan.md
Normal file
652
docs/features/broker-account-analytics/plan.md
Normal 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"
|
||||
```
|
||||
112
docs/features/broker-account-analytics/spec.md
Normal file
112
docs/features/broker-account-analytics/spec.md
Normal 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 минут.
|
||||
- Пустое состояние отображается при отсутствии данных.
|
||||
- Ошибка загрузки не ломает навигацию.
|
||||
|
||||
## Вне области фичи
|
||||
|
||||
- учёт налогов и комиссий;
|
||||
- нереализованная прибыль/убыток по текущим позициям;
|
||||
- ручные портфели;
|
||||
- мультивалютность;
|
||||
- графики и визуализация динамики;
|
||||
- экспорт данных.
|
||||
16
docs/features/broker-account-analytics/tasks.md
Normal file
16
docs/features/broker-account-analytics/tasks.md
Normal 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** Линт, сборка, тесты
|
||||
Loading…
x
Reference in New Issue
Block a user