feat: expose tbank broker portfolio

This commit is contained in:
Sergey Krylov 2026-06-16 22:43:40 +03:00
parent ddeab01b9e
commit bd6b2589c0
10 changed files with 489 additions and 5 deletions

View File

@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
export class BrokerResponseMetaDto {
@ApiProperty({ nullable: true })
@ -16,3 +17,11 @@ export class BrokerAccountsEnvelopeDto {
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}
export class BrokerPortfolioEnvelopeDto {
@ApiProperty({ type: BrokerPortfolioResponseDto })
data!: BrokerPortfolioResponseDto;
@ApiProperty({ type: BrokerResponseMetaDto })
meta!: BrokerResponseMetaDto;
}

View File

@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
export class BrokerMoneyDto {
@ApiProperty()
currency!: string;
@ApiProperty()
units!: string;
@ApiProperty()
nano!: number;
@ApiProperty()
value!: number;
}

View File

@ -0,0 +1,110 @@
import { ApiProperty } from '@nestjs/swagger';
import { BrokerAccountResponseDto } from './broker-account-response.dto';
import { BrokerMoneyDto } from './broker-money.dto';
export class BrokerPositionResponseDto {
@ApiProperty({ nullable: true })
figi!: string | null;
@ApiProperty({ nullable: true })
instrumentUid!: string | null;
@ApiProperty({ nullable: true })
positionUid!: string | null;
@ApiProperty({ nullable: true })
ticker!: string | null;
@ApiProperty({ nullable: true })
classCode!: string | null;
@ApiProperty({ nullable: true })
instrumentType!: string | null;
@ApiProperty({ nullable: true })
name!: string | null;
@ApiProperty({ nullable: true })
quantity!: number | null;
@ApiProperty({ nullable: true })
blockedLots!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currentPrice!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currentValue!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
averagePositionPrice!: BrokerMoneyDto | null;
@ApiProperty({ nullable: true })
expectedYieldPercent!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
dailyYield!: BrokerMoneyDto | null;
}
export class BrokerPortfolioTotalsDto {
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
shares!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
bonds!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
etf!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
currencies!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
futures!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
options!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
structuredProducts!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
dfa!: BrokerMoneyDto | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
portfolio!: BrokerMoneyDto | null;
}
export class BrokerPortfolioYieldsDto {
@ApiProperty({ nullable: true })
expectedPercent!: number | null;
@ApiProperty({ type: BrokerMoneyDto, nullable: true })
daily!: BrokerMoneyDto | null;
@ApiProperty({ nullable: true })
dailyPercent!: number | null;
}
export class BrokerPortfolioResponseDto {
@ApiProperty({ type: BrokerAccountResponseDto })
account!: BrokerAccountResponseDto;
@ApiProperty({ type: BrokerPortfolioTotalsDto })
totals!: BrokerPortfolioTotalsDto;
@ApiProperty({ type: BrokerPortfolioYieldsDto })
yields!: BrokerPortfolioYieldsDto;
@ApiProperty({ type: [BrokerMoneyDto] })
cash!: BrokerMoneyDto[];
@ApiProperty({ type: [BrokerMoneyDto] })
blockedCash!: BrokerMoneyDto[];
@ApiProperty({ type: [BrokerPositionResponseDto] })
positions!: BrokerPositionResponseDto[];
@ApiProperty()
asOf!: string;
}

View File

@ -0,0 +1,54 @@
import { mapBrokerPortfolio } from './portfolio.mapper';
import type { BrokerAccount } from '../types/broker.types';
describe('portfolio.mapper', () => {
const account: BrokerAccount = {
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
};
it('combines portfolio totals, cash, and enriched positions', () => {
const result = mapBrokerPortfolio({
account,
portfolio: {
accountId: 'acc-1',
totalAmountShares: { currency: 'rub', units: '1000', nano: 0 },
totalAmountPortfolio: { currency: 'rub', units: '1500', nano: 0 },
expectedYield: { units: '10', nano: 500000000 },
positions: [
{
figi: 'BBG004730N88',
instrumentUid: 'uid-1',
ticker: 'SBER',
classCode: 'TQBR',
instrumentType: 'share',
quantity: { units: '10', nano: 0 },
currentPrice: { currency: 'rub', units: '250', nano: 0 },
averagePositionPrice: { currency: 'rub', units: '200', nano: 0 },
},
],
},
positions: {
money: [{ currency: 'rub', units: '500', nano: 0 }],
blocked: [{ currency: 'rub', units: '10', nano: 0 }],
securities: [],
},
instruments: new Map([['uid-1', { name: 'Sberbank', ticker: 'SBER' }]]),
});
expect(result.account.id).toBe('acc-1');
expect(result.totals.shares?.value).toBe(1000);
expect(result.cash[0].value).toBe(500);
expect(result.blockedCash[0].value).toBe(10);
expect(result.positions[0]).toMatchObject({
ticker: 'SBER',
name: 'Sberbank',
quantity: 10,
currentValue: { value: 2500 },
});
});
});

View File

@ -0,0 +1,84 @@
import type {
BrokerAccount,
BrokerMoney,
BrokerPortfolio,
BrokerPosition,
} from '../types/broker.types';
import type {
TBankInstrument,
TBankPortfolioResponse,
TBankPositionsResponse,
} from '../types/tbank-proto.types';
import { mapMoneyValue, mapQuotationToNumber } from './money.mapper';
type MapBrokerPortfolioInput = {
account: BrokerAccount;
portfolio: TBankPortfolioResponse;
positions: TBankPositionsResponse;
instruments: Map<string, Partial<TBankInstrument>>;
};
function isBrokerMoney(value: BrokerMoney | null): value is BrokerMoney {
return value !== null;
}
export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio {
const mappedPositions = (input.portfolio.positions ?? []).map<BrokerPosition>((position) => {
const quantity = mapQuotationToNumber(position.quantity);
const currentPrice = mapMoneyValue(position.currentPrice);
const currentValue =
currentPrice && quantity !== null
? {
...currentPrice,
units: String(Math.trunc(currentPrice.value * quantity)),
nano: 0,
value: Number((currentPrice.value * quantity).toFixed(9)),
}
: null;
const instrument =
(position.instrumentUid && input.instruments.get(position.instrumentUid)) ||
(position.positionUid && input.instruments.get(position.positionUid)) ||
undefined;
return {
figi: position.figi ?? null,
instrumentUid: position.instrumentUid ?? null,
positionUid: position.positionUid ?? null,
ticker: position.ticker || instrument?.ticker || null,
classCode: position.classCode || instrument?.classCode || null,
instrumentType: position.instrumentType || instrument?.instrumentType || null,
name: instrument?.name ?? null,
quantity,
blockedLots: mapQuotationToNumber(position.blockedLots),
currentPrice,
currentValue,
averagePositionPrice: mapMoneyValue(position.averagePositionPrice),
expectedYieldPercent: mapQuotationToNumber(position.expectedYield),
dailyYield: mapMoneyValue(position.dailyYield),
};
});
return {
account: input.account,
totals: {
shares: mapMoneyValue(input.portfolio.totalAmountShares),
bonds: mapMoneyValue(input.portfolio.totalAmountBonds),
etf: mapMoneyValue(input.portfolio.totalAmountEtf),
currencies: mapMoneyValue(input.portfolio.totalAmountCurrencies),
futures: mapMoneyValue(input.portfolio.totalAmountFutures),
options: mapMoneyValue(input.portfolio.totalAmountOptions),
structuredProducts: mapMoneyValue(input.portfolio.totalAmountSp),
dfa: mapMoneyValue(input.portfolio.totalAmountDfa),
portfolio: mapMoneyValue(input.portfolio.totalAmountPortfolio),
},
yields: {
expectedPercent: mapQuotationToNumber(input.portfolio.expectedYield),
daily: mapMoneyValue(input.portfolio.dailyYield),
dailyPercent: mapQuotationToNumber(input.portfolio.dailyYieldRelative),
},
cash: (input.positions.money ?? []).map(mapMoneyValue).filter(isBrokerMoney),
blockedCash: (input.positions.blocked ?? []).map(mapMoneyValue).filter(isBrokerMoney),
positions: mappedPositions,
asOf: new Date().toISOString(),
};
}

View File

@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { TBankInstrument, TBankInstrumentResponse } from '../types/tbank-proto.types';
import { TBankClientService } from './tbank-client.service';
@Injectable()
export class BrokerInstrumentsService {
constructor(
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async findByInstrumentUid(instrumentUid: string): Promise<TBankInstrument | null> {
const result = await this.cacheService.getOrFetch(
TBANK_CACHE_KEYS.instrument,
[instrumentUid],
() => this.fetchByUid(instrumentUid),
'tbankInstrumentTtl',
);
return result.data;
}
private async fetchByUid(instrumentUid: string): Promise<TBankInstrument | null> {
const instrumentsClient = this.tbankClient.getServiceClient('InstrumentsService') as any;
const response = await this.tbankClient.callUnary<
{ idType: string; id: string },
TBankInstrumentResponse
>(
'InstrumentsService/GetInstrumentBy',
instrumentsClient.getInstrumentBy.bind(instrumentsClient),
{ idType: 'INSTRUMENT_ID_TYPE_UID', id: instrumentUid },
);
return response.instrument ?? null;
}
}

View File

@ -0,0 +1,70 @@
import { NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service';
import { BrokerPortfolioService } from './broker-portfolio.service';
import { TBankClientService } from './tbank-client.service';
describe('BrokerPortfolioService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService;
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
beforeEach(() => {
vi.clearAllMocks();
});
it('throws 404 for excluded or missing account', async () => {
vi.mocked(accounts.findById).mockResolvedValue(null);
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
await expect(service.getPortfolio('missing')).rejects.toThrow(NotFoundException);
});
it('fetches portfolio and positions through cache', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
getPositions: vi.fn(),
} as any);
vi.mocked(client.callUnary)
.mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [],
})
.mockResolvedValueOnce({
accountId: 'acc-1',
money: [{ currency: 'rub', units: '1000', nano: 0 }],
blocked: [],
securities: [],
});
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPortfolio('acc-1');
expect(result.data.account.id).toBe('acc-1');
expect(result.data.cash[0].value).toBe(1000);
expect(cache.getOrFetch).toHaveBeenCalledWith(
'tbank:portfolio',
['acc-1'],
expect.any(Function),
'tbankPortfolioTtl',
);
});
});

View File

@ -0,0 +1,81 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CacheService } from '../../cache/cache.service';
import { mapBrokerPortfolio } from '../mappers/portfolio.mapper';
import { TBANK_CACHE_KEYS } from '../tbank.config';
import type { BrokerPortfolio } from '../types/broker.types';
import type {
TBankInstrument,
TBankPortfolioResponse,
TBankPositionsResponse,
} from '../types/tbank-proto.types';
import { BrokerAccountsService } from './broker-accounts.service';
import { BrokerInstrumentsService } from './broker-instruments.service';
import { TBankClientService } from './tbank-client.service';
@Injectable()
export class BrokerPortfolioService {
constructor(
private readonly accountsService: BrokerAccountsService,
private readonly instrumentsService: BrokerInstrumentsService,
private readonly tbankClient: TBankClientService,
private readonly cacheService: CacheService,
) {}
async getPortfolio(accountId: string): Promise<{
data: BrokerPortfolio;
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.portfolio,
[accountId],
async () => {
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const [portfolio, positions] = await Promise.all([
this.tbankClient.callUnary<
{ accountId: string; currency: string },
TBankPortfolioResponse
>(
'OperationsService/GetPortfolio',
operationsClient.getPortfolio.bind(operationsClient),
{ accountId, currency: 'RUB' },
),
this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
'OperationsService/GetPositions',
operationsClient.getPositions.bind(operationsClient),
{ accountId },
),
]);
const instrumentMap = await this.buildInstrumentMap(portfolio);
return mapBrokerPortfolio({ account, portfolio, positions, instruments: instrumentMap });
},
'tbankPortfolioTtl',
);
return {
data: result.data,
meta: { fromCache: result.fromCache, cachedAt: result.cachedAt },
};
}
private async buildInstrumentMap(
portfolio: TBankPortfolioResponse,
): Promise<Map<string, Partial<TBankInstrument>>> {
const ids = Array.from(
new Set(
(portfolio.positions ?? []).map((position) => position.instrumentUid).filter(Boolean),
),
) as string[];
const entries = await Promise.all(
ids.map(async (id) => [id, await this.instrumentsService.findByInstrumentUid(id)] as const),
);
return new Map(
entries.filter((entry): entry is readonly [string, TBankInstrument] => entry[1] !== null),
);
}
}

View File

@ -1,13 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { Controller, Get, Param } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BrokerAccountsEnvelopeDto } from './dto/broker-envelope.dto';
import { BrokerAccountsEnvelopeDto, BrokerPortfolioEnvelopeDto } from './dto/broker-envelope.dto';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
@ApiTags('Broker')
@ApiBearerAuth()
@Controller('broker')
export class TBankController {
constructor(private readonly brokerAccountsService: BrokerAccountsService) {}
constructor(
private readonly brokerAccountsService: BrokerAccountsService,
private readonly brokerPortfolioService: BrokerPortfolioService,
) {}
@Get('accounts')
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
@ -15,4 +19,11 @@ export class TBankController {
async getAccounts() {
return this.brokerAccountsService.findAll();
}
@Get('accounts/:accountId/portfolio')
@ApiOperation({ summary: 'Get T-Bank broker account portfolio with cash and positions' })
@ApiOkResponse({ type: BrokerPortfolioEnvelopeDto })
async getPortfolio(@Param('accountId') accountId: string) {
return this.brokerPortfolioService.getPortfolio(accountId);
}
}

View File

@ -1,11 +1,23 @@
import { Module } from '@nestjs/common';
import { TBankController } from './tbank.controller';
import { BrokerAccountsService } from './services/broker-accounts.service';
import { BrokerInstrumentsService } from './services/broker-instruments.service';
import { BrokerPortfolioService } from './services/broker-portfolio.service';
import { TBankClientService } from './services/tbank-client.service';
@Module({
controllers: [TBankController],
providers: [TBankClientService, BrokerAccountsService],
exports: [TBankClientService, BrokerAccountsService],
providers: [
TBankClientService,
BrokerAccountsService,
BrokerInstrumentsService,
BrokerPortfolioService,
],
exports: [
TBankClientService,
BrokerAccountsService,
BrokerInstrumentsService,
BrokerPortfolioService,
],
})
export class TBankModule {}