diff --git a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts index 113db63..19223f8 100644 --- a/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts +++ b/apps/backend/src/modules/tbank/dto/broker-envelope.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-money.dto.ts b/apps/backend/src/modules/tbank/dto/broker-money.dto.ts new file mode 100644 index 0000000..6e68bb4 --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-money.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class BrokerMoneyDto { + @ApiProperty() + currency!: string; + + @ApiProperty() + units!: string; + + @ApiProperty() + nano!: number; + + @ApiProperty() + value!: number; +} diff --git a/apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts b/apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts new file mode 100644 index 0000000..e86f56c --- /dev/null +++ b/apps/backend/src/modules/tbank/dto/broker-portfolio-response.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts new file mode 100644 index 0000000..945b08e --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.spec.ts @@ -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 }, + }); + }); +}); diff --git a/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts new file mode 100644 index 0000000..cce8974 --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts @@ -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>; +}; + +function isBrokerMoney(value: BrokerMoney | null): value is BrokerMoney { + return value !== null; +} + +export function mapBrokerPortfolio(input: MapBrokerPortfolioInput): BrokerPortfolio { + const mappedPositions = (input.portfolio.positions ?? []).map((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(), + }; +} diff --git a/apps/backend/src/modules/tbank/services/broker-instruments.service.ts b/apps/backend/src/modules/tbank/services/broker-instruments.service.ts new file mode 100644 index 0000000..8762c2b --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-instruments.service.ts @@ -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 { + const result = await this.cacheService.getOrFetch( + TBANK_CACHE_KEYS.instrument, + [instrumentUid], + () => this.fetchByUid(instrumentUid), + 'tbankInstrumentTtl', + ); + + return result.data; + } + + private async fetchByUid(instrumentUid: string): Promise { + 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; + } +} diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts new file mode 100644 index 0000000..dc3b2d5 --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts @@ -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) => ({ + 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', + ); + }); +}); diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts new file mode 100644 index 0000000..a4ded6f --- /dev/null +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts @@ -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>> { + 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), + ); + } +} diff --git a/apps/backend/src/modules/tbank/tbank.controller.ts b/apps/backend/src/modules/tbank/tbank.controller.ts index f611f63..d3f8e48 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.ts @@ -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); + } } diff --git a/apps/backend/src/modules/tbank/tbank.module.ts b/apps/backend/src/modules/tbank/tbank.module.ts index abab066..9abf288 100644 --- a/apps/backend/src/modules/tbank/tbank.module.ts +++ b/apps/backend/src/modules/tbank/tbank.module.ts @@ -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 {}