codex/tbank-broker-portfolios-design #15
@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
import { BrokerAccountResponseDto } from './broker-account-response.dto';
|
||||||
|
import { BrokerPortfolioResponseDto } from './broker-portfolio-response.dto';
|
||||||
|
|
||||||
export class BrokerResponseMetaDto {
|
export class BrokerResponseMetaDto {
|
||||||
@ApiProperty({ nullable: true })
|
@ApiProperty({ nullable: true })
|
||||||
@ -16,3 +17,11 @@ export class BrokerAccountsEnvelopeDto {
|
|||||||
@ApiProperty({ type: BrokerResponseMetaDto })
|
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||||
meta!: BrokerResponseMetaDto;
|
meta!: BrokerResponseMetaDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BrokerPortfolioEnvelopeDto {
|
||||||
|
@ApiProperty({ type: BrokerPortfolioResponseDto })
|
||||||
|
data!: BrokerPortfolioResponseDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: BrokerResponseMetaDto })
|
||||||
|
meta!: BrokerResponseMetaDto;
|
||||||
|
}
|
||||||
|
|||||||
15
apps/backend/src/modules/tbank/dto/broker-money.dto.ts
Normal file
15
apps/backend/src/modules/tbank/dto/broker-money.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
84
apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts
Normal file
84
apps/backend/src/modules/tbank/mappers/portfolio.mapper.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 { 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 { BrokerAccountsService } from './services/broker-accounts.service';
|
||||||
|
import { BrokerPortfolioService } from './services/broker-portfolio.service';
|
||||||
|
|
||||||
@ApiTags('Broker')
|
@ApiTags('Broker')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller('broker')
|
@Controller('broker')
|
||||||
export class TBankController {
|
export class TBankController {
|
||||||
constructor(private readonly brokerAccountsService: BrokerAccountsService) {}
|
constructor(
|
||||||
|
private readonly brokerAccountsService: BrokerAccountsService,
|
||||||
|
private readonly brokerPortfolioService: BrokerPortfolioService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('accounts')
|
@Get('accounts')
|
||||||
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
|
@ApiOperation({ summary: 'Get open T-Bank brokerage and IIS accounts' })
|
||||||
@ -15,4 +19,11 @@ export class TBankController {
|
|||||||
async getAccounts() {
|
async getAccounts() {
|
||||||
return this.brokerAccountsService.findAll();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,23 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TBankController } from './tbank.controller';
|
import { TBankController } from './tbank.controller';
|
||||||
import { BrokerAccountsService } from './services/broker-accounts.service';
|
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';
|
import { TBankClientService } from './services/tbank-client.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [TBankController],
|
controllers: [TBankController],
|
||||||
providers: [TBankClientService, BrokerAccountsService],
|
providers: [
|
||||||
exports: [TBankClientService, BrokerAccountsService],
|
TBankClientService,
|
||||||
|
BrokerAccountsService,
|
||||||
|
BrokerInstrumentsService,
|
||||||
|
BrokerPortfolioService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
TBankClientService,
|
||||||
|
BrokerAccountsService,
|
||||||
|
BrokerInstrumentsService,
|
||||||
|
BrokerPortfolioService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class TBankModule {}
|
export class TBankModule {}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user