refactor(tbank): rewrite BrokerAnalyticsService to use T-Bank API directly

Replace Prisma-based analytics with direct T-Bank API calls:
- GetPortfolio for expectedYield (real portfolio return)
- GetOperationsByCursor with pagination for full operation history
- Remove incorrect totalReturnPercent formula, use T-Bank expectedYield instead
This commit is contained in:
Sergey Krylov 2026-06-27 20:39:32 +03:00
parent 8b24d8b82b
commit 3281866c43
3 changed files with 196 additions and 159 deletions

View File

@ -2,11 +2,15 @@ import { CacheService } from '../../cache/cache.service';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerAnalyticsService } from './broker-analytics.service'; import { BrokerAnalyticsService } from './broker-analytics.service';
import { PrismaService } from '../../prisma/prisma.service'; import { TBankClientService } from './tbank-client.service';
import type { TBankOperationsByCursorResponse, TBankPortfolioResponse, TBankOperationItem } from '../types/tbank-proto.types';
describe('BrokerAnalyticsService', () => { describe('BrokerAnalyticsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService; const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const prisma = { brokerOperation: { findMany: vi.fn() } } as unknown as PrismaService; const tbankClient = {
getOperationsClient: vi.fn(),
callUnary: vi.fn(),
} as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService; const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
const acc1 = { const acc1 = {
@ -18,6 +22,8 @@ describe('BrokerAnalyticsService', () => {
accessLevel: null, accessLevel: null,
}; };
const mockClient = { getPortfolio: vi.fn(), getOperationsByCursor: vi.fn() };
function mockCachePassthrough() { function mockCachePassthrough() {
vi.mocked(cache.getOrFetch).mockImplementation( vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({ async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
@ -28,27 +34,55 @@ describe('BrokerAnalyticsService', () => {
); );
} }
function makeOp(type: string, value: number, state?: string | null) { function mockPortfolio(expectedYield?: { units?: number | string; nano?: number }): TBankPortfolioResponse {
return { type, payment: JSON.stringify({ value, currency: 'RUB' }), state } as any; const response: TBankPortfolioResponse = { accountId: 'acc-1' };
if (expectedYield) response.expectedYield = expectedYield;
return response;
}
function makeItem(type: string, value: number, state = 'OPERATION_STATE_EXECUTED'): TBankOperationItem {
return {
type,
payment: { currency: 'RUB', units: Math.floor(Math.abs(value)), nano: Math.round((Math.abs(value) % 1) * 1e9) },
state,
id: `${type}-${value}`,
cursor: '',
brokerAccountId: 'acc-1',
};
}
function mockOpsResponse(items: TBankOperationItem[], hasNext = false, nextCursor = ''): TBankOperationsByCursorResponse {
return { items, hasNext, nextCursor };
}
function setupMocks(ops: TBankOperationItem[], portfolio?: TBankPortfolioResponse) {
vi.mocked(tbankClient.callUnary).mockImplementation(
async (label: string) => {
if (label.includes('GetPortfolio')) return (portfolio ?? mockPortfolio()) as any;
if (label.includes('GetOperationsByCursor')) return mockOpsResponse(ops) as any;
throw new Error(`Unexpected call: ${label}`);
},
);
} }
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(tbankClient.getOperationsClient).mockReturnValue(mockClient as any);
}); });
it('throws 404 for missing account', async () => { it('throws 404 for missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null); vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerAnalyticsService(prisma, accounts, cache); const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
await expect(service.getAnalytics('missing')).rejects.toThrow(EntityNotFoundException); await expect(service.getAnalytics('missing')).rejects.toThrow(EntityNotFoundException);
}); });
it('returns zeros for account with no operations', async () => { it('returns zeros for account with no operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([]); setupMocks([]);
const service = new BrokerAnalyticsService(prisma, accounts, cache); const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data).toEqual({ expect(result.data).toEqual({
@ -68,17 +102,17 @@ describe('BrokerAnalyticsService', () => {
it('aggregates deposit types correctly', async () => { it('aggregates deposit types correctly', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ setupMocks([
makeOp('OPERATION_TYPE_INPUT', 1000), makeItem('OPERATION_TYPE_INPUT', 1000),
makeOp('OPERATION_TYPE_INPUT_SWIFT', 500), makeItem('OPERATION_TYPE_INPUT_SWIFT', 500),
makeOp('OPERATION_TYPE_INP_MULTI', 200), makeItem('OPERATION_TYPE_INP_MULTI', 200),
makeOp('OPERATION_TYPE_OVER_PLACEMENT', 300), makeItem('OPERATION_TYPE_OVER_PLACEMENT', 300),
makeOp('OPERATION_TYPE_TRANS_IIS_BS', 100), makeItem('OPERATION_TYPE_TRANS_IIS_BS', 100),
makeOp('OPERATION_TYPE_TRANS_BS_BS', 50), makeItem('OPERATION_TYPE_TRANS_BS_BS', 50),
makeOp('OPERATION_TYPE_INPUT_ACQUIRING', 150), makeItem('OPERATION_TYPE_INPUT_ACQUIRING', 150),
]); ]);
const service = new BrokerAnalyticsService(prisma, accounts, cache); const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(2300); expect(result.data.totalDeposits).toBe(2300);
@ -89,15 +123,15 @@ describe('BrokerAnalyticsService', () => {
it('aggregates withdrawal types with absolute value', async () => { it('aggregates withdrawal types with absolute value', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ setupMocks([
makeOp('OPERATION_TYPE_OUTPUT', -500), makeItem('OPERATION_TYPE_OUTPUT', -500),
makeOp('OPERATION_TYPE_OUTPUT_SWIFT', -200), makeItem('OPERATION_TYPE_OUTPUT_SWIFT', -200),
makeOp('OPERATION_TYPE_OUTPUT_ACQUIRING', -100), makeItem('OPERATION_TYPE_OUTPUT_ACQUIRING', -100),
makeOp('OPERATION_TYPE_OUT_MULTI', -50), makeItem('OPERATION_TYPE_OUT_MULTI', -50),
makeOp('OPERATION_TYPE_INPUT', 1000), makeItem('OPERATION_TYPE_INPUT', 1000),
]); ]);
const service = new BrokerAnalyticsService(prisma, accounts, cache); const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(1000); expect(result.data.totalDeposits).toBe(1000);
@ -108,14 +142,14 @@ describe('BrokerAnalyticsService', () => {
it('aggregates dividend and coupon types', async () => { it('aggregates dividend and coupon types', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ setupMocks([
makeOp('OPERATION_TYPE_DIVIDEND', 300), makeItem('OPERATION_TYPE_DIVIDEND', 300),
makeOp('OPERATION_TYPE_DIV_EXT', 150), makeItem('OPERATION_TYPE_DIV_EXT', 150),
makeOp('OPERATION_TYPE_COUPON', 75), makeItem('OPERATION_TYPE_COUPON', 75),
makeOp('OPERATION_TYPE_COUPON', 25), makeItem('OPERATION_TYPE_COUPON', 25),
]); ]);
const service = new BrokerAnalyticsService(prisma, accounts, cache); const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data.totalDividends).toBe(450); expect(result.data.totalDividends).toBe(450);
@ -123,92 +157,47 @@ describe('BrokerAnalyticsService', () => {
expect(result.data.totalReceived).toBe(550); expect(result.data.totalReceived).toBe(550);
}); });
it('calculates totalReturnPercent correctly', async () => { it('uses expectedYield from portfolio for totalReturnPercent', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ setupMocks(
makeOp('OPERATION_TYPE_INPUT', 10000), [
makeOp('OPERATION_TYPE_DIVIDEND', 500), makeItem('OPERATION_TYPE_INPUT', 10000),
makeOp('OPERATION_TYPE_COUPON', 200), makeItem('OPERATION_TYPE_DIVIDEND', 500),
]); makeItem('OPERATION_TYPE_COUPON', 200),
],
mockPortfolio({ units: 7, nano: 0 }),
);
const service = new BrokerAnalyticsService(prisma, accounts, cache); const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1'); 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); expect(result.data.totalReturnPercent).toBe(7);
}); });
it('returns null totalReturnPercent when netInvested <= 0', async () => { it('returns null totalReturnPercent when portfolio has no expectedYield', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ setupMocks([makeItem('OPERATION_TYPE_OUTPUT', -500)]);
makeOp('OPERATION_TYPE_OUTPUT', -500),
makeOp('OPERATION_TYPE_DIVIDEND', 100),
]);
const service = new BrokerAnalyticsService(prisma, accounts, cache); const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data.netInvested).toBe(-500);
expect(result.data.totalReturnPercent).toBeNull(); expect(result.data.totalReturnPercent).toBeNull();
}); });
it('handles malformed payment JSON gracefully', async () => { it('aggregates fee and tax categories from operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ setupMocks([
{ type: 'OPERATION_TYPE_INPUT', payment: 'invalid-json', state: 'OPERATION_STATE_EXECUTED' }, makeItem('OPERATION_TYPE_SERVICE_FEE', -100),
{ type: 'OPERATION_TYPE_INPUT', payment: JSON.stringify({ value: 500, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED' }, makeItem('OPERATION_TYPE_BROKER_FEE', -50),
] as any); makeItem('OPERATION_TYPE_TAX', -200),
makeItem('OPERATION_TYPE_DIVIDEND_TAX', -30),
makeItem('OPERATION_TYPE_INPUT', 1000),
]);
const service = new BrokerAnalyticsService(prisma, accounts, cache); const service = new BrokerAnalyticsService(accounts, tbankClient, 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',
payment: { not: null },
OR: [
{ state: 'OPERATION_STATE_EXECUTED' },
{ state: null },
],
AND: [
{
OR: [
{ type: { in: expect.any(Array) } },
{ category: { in: ['fee', 'tax'] } },
],
},
],
},
select: { type: true, payment: true, category: true },
});
});
it('aggregates fee and tax categories from executed operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([
{ type: 'OPERATION_TYPE_SERVICE_FEE', payment: JSON.stringify({ value: -100, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED', category: 'fee' },
{ type: 'OPERATION_TYPE_BROKER_FEE', payment: JSON.stringify({ value: -50, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED', category: 'fee' },
{ type: 'OPERATION_TYPE_TAX', payment: JSON.stringify({ value: -200, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED', category: 'tax' },
{ type: 'OPERATION_TYPE_DIVIDEND_TAX', payment: JSON.stringify({ value: -30, currency: 'RUB' }), state: 'OPERATION_STATE_EXECUTED', category: 'tax' },
makeOp('OPERATION_TYPE_INPUT', 1000),
] as any);
const service = new BrokerAnalyticsService(prisma, accounts, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(1000); expect(result.data.totalDeposits).toBe(1000);
@ -217,33 +206,15 @@ describe('BrokerAnalyticsService', () => {
expect(result.data.netInvested).toBe(1000); expect(result.data.netInvested).toBe(1000);
}); });
it('includes fee/tax category operations in the query alongside ANALYTICS_TYPES', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
const service = new BrokerAnalyticsService(prisma, accounts, cache);
await service.getAnalytics('acc-1');
const whereArg = vi.mocked(prisma.brokerOperation.findMany).mock.calls[0][0]!.where as any;
expect(whereArg.accountId).toBe('acc-1');
expect(whereArg.payment).toEqual({ not: null });
const orConditions = whereArg.AND[0].OR;
expect(orConditions).toEqual(
expect.arrayContaining([
{ category: { in: ['fee', 'tax'] } },
]),
);
});
it('rounds all monetary values to 2 decimal places', async () => { it('rounds all monetary values to 2 decimal places', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1); vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough(); mockCachePassthrough();
vi.mocked(prisma.brokerOperation.findMany).mockResolvedValue([ setupMocks([
makeOp('OPERATION_TYPE_INPUT', 100.336), makeItem('OPERATION_TYPE_INPUT', 100.336),
makeOp('OPERATION_TYPE_DIVIDEND', 50.789), makeItem('OPERATION_TYPE_DIVIDEND', 50.789),
]); ]);
const service = new BrokerAnalyticsService(prisma, accounts, cache); const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(100.34); expect(result.data.totalDeposits).toBe(100.34);
@ -271,11 +242,39 @@ describe('BrokerAnalyticsService', () => {
cachedAt: '2026-06-24T10:00:00.000Z', cachedAt: '2026-06-24T10:00:00.000Z',
}); });
const service = new BrokerAnalyticsService(prisma, accounts, cache); const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1'); const result = await service.getAnalytics('acc-1');
expect(result.data.netInvested).toBe(1000); expect(result.data.netInvested).toBe(1000);
expect(result.fromCache).toBe(true); expect(result.fromCache).toBe(true);
expect(result.cachedAt).toBe('2026-06-24T10:00:00.000Z'); expect(result.cachedAt).toBe('2026-06-24T10:00:00.000Z');
}); });
it('paginates through multiple pages of operations', async () => {
vi.mocked(accounts.findById).mockResolvedValue(acc1);
mockCachePassthrough();
let callCount = 0;
vi.mocked(tbankClient.callUnary).mockImplementation(
async (label: string) => {
if (label.includes('GetPortfolio')) return mockPortfolio({ units: 5, nano: 0 }) as any;
if (label.includes('GetOperationsByCursor')) {
callCount++;
if (callCount === 1) {
return mockOpsResponse([makeItem('OPERATION_TYPE_INPUT', 1000)], true, 'cursor-1') as any;
}
return mockOpsResponse([makeItem('OPERATION_TYPE_DIVIDEND', 500)], false, '') as any;
}
throw new Error(`Unexpected call: ${label}`);
},
);
const service = new BrokerAnalyticsService(accounts, tbankClient, cache);
const result = await service.getAnalytics('acc-1');
expect(result.data.totalDeposits).toBe(1000);
expect(result.data.totalDividends).toBe(500);
expect(result.data.totalReceived).toBe(500);
expect(callCount).toBe(2);
});
}); });

View File

@ -1,11 +1,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CacheService } from '../../cache/cache.service'; import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config'; import { TBANK_CACHE_KEYS } from '../tbank.config';
import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto'; import { BrokerAnalyticsDto } from '../dto/broker-analytics-response.dto';
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception'; import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto'; import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
import { BrokerAccountsService } from './broker-accounts.service'; import { BrokerAccountsService } from './broker-accounts.service';
import { TBankClientService, type TBankPortfolioRequest } from './tbank-client.service';
import type { BrokerOperation } from '../types/broker.types';
import { mapOperationsPage } from '../mappers/operation.mapper';
import type { TBankOperationsByCursorResponse, TBankPortfolioResponse } from '../types/tbank-proto.types';
const DEPOSIT_TYPES = new Set([ const DEPOSIT_TYPES = new Set([
'OPERATION_TYPE_INPUT', 'OPERATION_TYPE_INPUT',
@ -28,18 +31,15 @@ const DIVIDEND_TYPES = new Set(['OPERATION_TYPE_DIVIDEND', 'OPERATION_TYPE_DIV_E
const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']); const COUPON_TYPES = new Set(['OPERATION_TYPE_COUPON']);
const ANALYTICS_TYPES = new Set([ const MAX_FETCH_PAGES = 50;
...DEPOSIT_TYPES,
...WITHDRAWAL_TYPES,
...DIVIDEND_TYPES,
...COUPON_TYPES,
]);
@Injectable() @Injectable()
export class BrokerAnalyticsService { export class BrokerAnalyticsService {
private readonly logger = new Logger(BrokerAnalyticsService.name);
constructor( constructor(
private readonly prisma: PrismaService,
private readonly accountsService: BrokerAccountsService, private readonly accountsService: BrokerAccountsService,
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
) {} ) {}
@ -58,25 +58,10 @@ export class BrokerAnalyticsService {
} }
private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> { private async computeAnalytics(accountId: string): Promise<BrokerAnalyticsDto> {
const operations = await this.prisma.brokerOperation.findMany({ const [portfolio, allOperations] = await Promise.all([
where: { this.fetchPortfolio(accountId),
accountId, this.fetchAllOperations(accountId),
payment: { not: null }, ]);
OR: [
{ state: 'OPERATION_STATE_EXECUTED' },
{ state: null },
],
AND: [
{
OR: [
{ type: { in: Array.from(ANALYTICS_TYPES) } },
{ category: { in: ['fee', 'tax'] } },
],
},
],
},
select: { type: true, payment: true, category: true },
});
let totalDeposits = 0; let totalDeposits = 0;
let totalWithdrawn = 0; let totalWithdrawn = 0;
@ -85,14 +70,8 @@ export class BrokerAnalyticsService {
let totalFees = 0; let totalFees = 0;
let totalTaxesPaid = 0; let totalTaxesPaid = 0;
for (const op of operations) { for (const op of allOperations) {
let value = 0; const value = op.payment?.value ?? 0;
try {
const payment = JSON.parse(op.payment!);
value = payment.value ?? 0;
} catch {
continue;
}
if (op.category === 'fee') { if (op.category === 'fee') {
totalFees += Math.abs(value); totalFees += Math.abs(value);
@ -111,8 +90,11 @@ export class BrokerAnalyticsService {
const netInvested = totalDeposits - totalWithdrawn; const netInvested = totalDeposits - totalWithdrawn;
const totalReceived = totalDividends + totalCoupons; const totalReceived = totalDividends + totalCoupons;
const totalReturnPercent =
netInvested > 0 ? Math.round((totalReceived / netInvested) * 10000) / 100 : null; const portfolioYield = portfolio.expectedYield;
const expectedYieldPercent = portfolioYield
? Math.round((Number(portfolioYield.units ?? 0) + (portfolioYield.nano ?? 0) / 1e9) * 100) / 100
: null;
return { return {
totalDeposits: Math.round(totalDeposits * 100) / 100, totalDeposits: Math.round(totalDeposits * 100) / 100,
@ -123,8 +105,64 @@ export class BrokerAnalyticsService {
totalReceived: Math.round(totalReceived * 100) / 100, totalReceived: Math.round(totalReceived * 100) / 100,
totalFees: Math.round(totalFees * 100) / 100, totalFees: Math.round(totalFees * 100) / 100,
totalTaxesPaid: Math.round(totalTaxesPaid * 100) / 100, totalTaxesPaid: Math.round(totalTaxesPaid * 100) / 100,
totalReturnPercent, totalReturnPercent: expectedYieldPercent,
currency: 'RUB', currency: 'RUB',
}; };
} }
private async fetchPortfolio(accountId: string): Promise<TBankPortfolioResponse> {
const operationsClient = this.tbankClient.getOperationsClient();
const response = await this.tbankClient.callUnary<
TBankPortfolioRequest,
TBankPortfolioResponse
>(
'OperationsService/GetPortfolio',
operationsClient.getPortfolio.bind(operationsClient),
{ accountId, currency: 'RUB' },
);
return response;
}
private async fetchAllOperations(accountId: string): Promise<BrokerOperation[]> {
const allOps: BrokerOperation[] = [];
let cursor: string | undefined;
let pageCount = 0;
do {
if (pageCount >= MAX_FETCH_PAGES) {
this.logger.warn(`Reached max fetch pages (${MAX_FETCH_PAGES}) for account ${accountId}`);
break;
}
const request: Record<string, unknown> = {
accountId,
state: 'OPERATION_STATE_EXECUTED',
limit: 1000,
withoutCommissions: false,
withoutTrades: false,
withoutOvernights: false,
};
if (cursor) request.cursor = cursor;
const operationsClient = this.tbankClient.getOperationsClient();
const response = await this.tbankClient.callUnary<
Record<string, unknown>,
TBankOperationsByCursorResponse
>(
'OperationsService/GetOperationsByCursor',
operationsClient.getOperationsByCursor.bind(operationsClient),
request,
);
const page = mapOperationsPage(accountId, response);
allOps.push(...page.items);
pageCount++;
cursor = page.hasNext ? (page.nextCursor ?? undefined) : undefined;
} while (cursor);
return allOps;
}
} }

View File

@ -34,7 +34,7 @@ type GrpcUnary<TRequest, TResponse> = (
type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client; type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client;
type TBankAccountsRequest = { status: string }; type TBankAccountsRequest = { status: string };
type TBankPortfolioRequest = { accountId: string; currency: string }; export type TBankPortfolioRequest = { accountId: string; currency: string };
type TBankPositionsRequest = { accountId: string }; type TBankPositionsRequest = { accountId: string };
type TBankInstrumentRequest = { idType: string; id: string }; type TBankInstrumentRequest = { idType: string; id: string };