refactor: organize backend by business responsibility
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({
|
||||
TbankAccessService: class TbankAccessService {},
|
||||
}));
|
||||
|
||||
import { InstrumentMetadataService } from '../../../../integrations/tbank/instrument-metadata.service';
|
||||
import type {
|
||||
TbankAccountContext,
|
||||
TbankConnectionContext,
|
||||
} from '../../../../integrations/tbank/tbank-access.service';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
describe('PortfolioService', () => {
|
||||
const access = {
|
||||
withConnection: jest.fn(),
|
||||
withAccountForConnection: jest.fn(),
|
||||
};
|
||||
const tbank = {
|
||||
getPortfolio: jest.fn(),
|
||||
getPositions: jest.fn(),
|
||||
getInstrumentByFigi: jest.fn(),
|
||||
getInstrumentByUid: jest.fn(),
|
||||
};
|
||||
const requestContext = { set: jest.fn() };
|
||||
const logger = { event: jest.fn() };
|
||||
const metadata = new InstrumentMetadataService(
|
||||
tbank as never,
|
||||
logger as never,
|
||||
);
|
||||
const service = new PortfolioService(
|
||||
access as never,
|
||||
tbank as never,
|
||||
requestContext as never,
|
||||
metadata,
|
||||
);
|
||||
let generation: string;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service.invalidate('user');
|
||||
generation = 'generation-1';
|
||||
access.withConnection.mockImplementation(
|
||||
(
|
||||
_userId: string,
|
||||
handler: (context: TbankConnectionContext) => Promise<unknown>,
|
||||
) => handler({ token: 'token', generation }),
|
||||
);
|
||||
access.withAccountForConnection.mockImplementation(
|
||||
(
|
||||
connection: TbankConnectionContext,
|
||||
_accountId: string,
|
||||
handler: (context: TbankAccountContext) => Promise<unknown>,
|
||||
) =>
|
||||
handler({
|
||||
...connection,
|
||||
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
|
||||
}),
|
||||
);
|
||||
tbank.getPortfolio.mockResolvedValue({ positions: [] });
|
||||
tbank.getPositions.mockResolvedValue({ money: [] });
|
||||
});
|
||||
|
||||
it('resolves account-scoped access before loading upstream portfolio', async () => {
|
||||
await service.get('user', 'account');
|
||||
await service.get('user', 'account');
|
||||
|
||||
expect(access.withConnection).toHaveBeenCalledTimes(2);
|
||||
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.getPositions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => {
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'ONE',
|
||||
instrumentType: 'share',
|
||||
quantity: { units: 2 },
|
||||
currentPrice: { currency: 'rub', units: 3 },
|
||||
averagePositionPrice: { currency: 'RUB', units: 2 },
|
||||
},
|
||||
{
|
||||
figi: 'TWO',
|
||||
instrumentType: 'new-kind',
|
||||
quantity: { nano: -500000000 },
|
||||
currentPrice: { currency: 'USD', units: 4 },
|
||||
},
|
||||
{ figi: 'ZERO', quantity: {} },
|
||||
],
|
||||
});
|
||||
tbank.getPositions.mockResolvedValue({
|
||||
money: [
|
||||
{ currency: 'RUB', units: 1 },
|
||||
{ currency: 'rub', nano: 500000000 },
|
||||
{ currency: 'USD' },
|
||||
],
|
||||
});
|
||||
tbank.getInstrumentByFigi
|
||||
.mockResolvedValueOnce({
|
||||
ticker: 'ONE',
|
||||
name: 'Первая позиция',
|
||||
isin: 'RU000A0JX0J2',
|
||||
brand: { logoName: 'RU000A0JX0J2x160.png' },
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('unavailable'));
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
positions: [
|
||||
{
|
||||
figi: 'ONE',
|
||||
displayName: 'Первая позиция',
|
||||
type: 'STOCK',
|
||||
quantity: '2',
|
||||
currentValue: { currency: 'RUB', value: '6' },
|
||||
absoluteReturn: { currency: 'RUB', value: '2' },
|
||||
isin: 'RU000A0JX0J2',
|
||||
ticker: 'ONE',
|
||||
logoName: 'RU000A0JX0J2x160.png',
|
||||
},
|
||||
{
|
||||
figi: 'TWO',
|
||||
displayName: 'TWO',
|
||||
type: 'OTHER',
|
||||
quantity: '-0.5',
|
||||
currentValue: { currency: 'USD', value: '-2' },
|
||||
isin: null,
|
||||
ticker: null,
|
||||
logoName: null,
|
||||
},
|
||||
],
|
||||
cashBalances: [{ currency: 'RUB', value: '1.5' }],
|
||||
totalsByCurrency: [
|
||||
{ currency: 'RUB', value: '7.5' },
|
||||
{ currency: 'USD', value: '-2' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes zero and currency-less values while keeping an empty portfolio available', async () => {
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
quantity: { units: 1 },
|
||||
currentPrice: { currency: 'RUB' },
|
||||
},
|
||||
{
|
||||
quantity: { units: 1 },
|
||||
currentPrice: { units: 10 },
|
||||
},
|
||||
],
|
||||
});
|
||||
tbank.getPositions.mockResolvedValue({
|
||||
money: [{ currency: 'USD' }, { units: 2 }],
|
||||
});
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
totalsByCurrency: [],
|
||||
cashBalances: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses instrument UID before FIGI when resolving a name', async () => {
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: [
|
||||
{
|
||||
figi: 'FIGI',
|
||||
instrumentUid: 'UID',
|
||||
instrumentType: 'share',
|
||||
quantity: { units: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
tbank.getInstrumentByUid.mockResolvedValue({ name: 'Имя по UID' });
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
positions: [{ displayName: 'Имя по UID', logoName: null }],
|
||||
});
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads a large mixed portfolio without overflowing metadata requests or losing names', async () => {
|
||||
const positions = Array.from({ length: 60 }, (_, index) => ({
|
||||
figi: `BATCH-${index}`,
|
||||
instrumentType: index % 2 ? 'bond' : 'share',
|
||||
quantity: { units: 2 },
|
||||
currentPrice: { currency: 'RUB', units: 10 },
|
||||
}));
|
||||
tbank.getPortfolio.mockResolvedValue({ positions });
|
||||
let active = 0;
|
||||
tbank.getInstrumentByFigi.mockImplementation(
|
||||
async (_token: string, figi: string) => {
|
||||
active += 1;
|
||||
try {
|
||||
if (active > 50)
|
||||
throw new ServiceUnavailableException({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
await Promise.resolve();
|
||||
return {
|
||||
name: `Название ${figi}`,
|
||||
ticker: figi,
|
||||
isin: `ISIN-${figi}`,
|
||||
brand: { logoName: `${figi}.png` },
|
||||
};
|
||||
} finally {
|
||||
active -= 1;
|
||||
}
|
||||
},
|
||||
);
|
||||
const result = await service.get('user', 'account');
|
||||
expect(result.positions).toMatchObject(
|
||||
positions.map((position, index) => ({
|
||||
displayName: `Название BATCH-${index}`,
|
||||
type: position.instrumentType === 'share' ? 'STOCK' : 'BOND',
|
||||
ticker: `BATCH-${index}`,
|
||||
isin: `ISIN-BATCH-${index}`,
|
||||
logoName: `BATCH-${index}.png`,
|
||||
quantity: '2',
|
||||
currentValue: { currency: 'RUB', value: '20' },
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
it('bounds total metadata waiting and timestamps the financial response before enrichment', async () => {
|
||||
jest.useFakeTimers({ now: new Date('2026-09-12T12:00:00Z') });
|
||||
try {
|
||||
tbank.getPortfolio.mockResolvedValue({
|
||||
positions: Array.from({ length: 60 }, (_, index) => ({
|
||||
figi: `BUDGET-${index}`,
|
||||
instrumentUid: `budget-uid-${index}`,
|
||||
instrumentType: index % 2 ? 'bond' : 'share',
|
||||
quantity: { units: 2 },
|
||||
currentPrice: { currency: 'RUB', units: 10 },
|
||||
})),
|
||||
});
|
||||
tbank.getInstrumentByUid.mockImplementation(() => new Promise(() => {}));
|
||||
const pending = service.get('user', 'account');
|
||||
await jest.advanceTimersByTimeAsync(0);
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
|
||||
await jest.advanceTimersByTimeAsync(30_000);
|
||||
const result = await pending;
|
||||
expect(result.lastSuccessfulAt).toBe('2026-09-12T12:00:00Z');
|
||||
expect(result.positions).toHaveLength(60);
|
||||
expect(result.positions[59]).toMatchObject({
|
||||
displayName: 'BUDGET-59',
|
||||
currentValue: { currency: 'RUB', value: '20' },
|
||||
});
|
||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
|
||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => {
|
||||
await service.get('user', 'account');
|
||||
await service.get('user', 'account');
|
||||
expect(access.withConnection).toHaveBeenCalledTimes(2);
|
||||
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1);
|
||||
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1);
|
||||
service.invalidate('user');
|
||||
await service.get('user', 'account');
|
||||
expect(tbank.getPortfolio).toHaveBeenCalledTimes(2);
|
||||
const snapshots = (
|
||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||
).snapshots;
|
||||
const snapshot = snapshots.get('user:account');
|
||||
assert.ok(snapshot);
|
||||
snapshot.storedAt = Date.now() - 30_001;
|
||||
tbank.getPortfolio.mockRejectedValueOnce(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
isStale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mask an invalid token with a snapshot', async () => {
|
||||
await service.get('user', 'account');
|
||||
const snapshots = (
|
||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||
).snapshots;
|
||||
const snapshot = snapshots.get('user:account');
|
||||
assert.ok(snapshot);
|
||||
snapshot.storedAt = Date.now() - 30_001;
|
||||
access.withAccountForConnection.mockRejectedValueOnce(
|
||||
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
|
||||
);
|
||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||
expect.any(BadRequestException),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reuse a snapshot or fallback across connection generations', async () => {
|
||||
await service.get('user', 'account');
|
||||
const snapshots = (
|
||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||
).snapshots;
|
||||
const snapshot = snapshots.get('user:account');
|
||||
assert.ok(snapshot);
|
||||
snapshot.storedAt = Date.now() - 30_001;
|
||||
generation = 'generation-2';
|
||||
tbank.getPortfolio.mockRejectedValueOnce(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
|
||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||
expect.any(ServiceUnavailableException),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps current-generation fallback when account resolution is temporarily unavailable', async () => {
|
||||
await service.get('user', 'account');
|
||||
const snapshots = (
|
||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
||||
).snapshots;
|
||||
const snapshot = snapshots.get('user:account');
|
||||
assert.ok(snapshot);
|
||||
snapshot.storedAt = Date.now() - 30_001;
|
||||
access.withAccountForConnection.mockRejectedValueOnce(
|
||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||
);
|
||||
|
||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||
isStale: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not return a snapshot when the current connection is absent', async () => {
|
||||
await service.get('user', 'account');
|
||||
access.withConnection.mockRejectedValueOnce(
|
||||
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
|
||||
);
|
||||
|
||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
||||
expect.any(ConflictException),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not expose a late previous-generation portfolio snapshot', async () => {
|
||||
let finishPrevious: ((value: { positions: never[] }) => void) | undefined;
|
||||
const previous = new Promise<{ positions: never[] }>((resolve) => {
|
||||
finishPrevious = resolve;
|
||||
});
|
||||
tbank.getPortfolio
|
||||
.mockReturnValueOnce(previous)
|
||||
.mockResolvedValueOnce({ positions: [] })
|
||||
.mockResolvedValueOnce({ positions: [] });
|
||||
tbank.getPositions
|
||||
.mockResolvedValueOnce({ money: [{ currency: 'RUB', units: 1 }] })
|
||||
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 2 }] })
|
||||
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 3 }] });
|
||||
|
||||
const previousRequest = service.get('user', 'account');
|
||||
generation = 'generation-2';
|
||||
const current = await service.get('user', 'account');
|
||||
finishPrevious?.({ positions: [] });
|
||||
await previousRequest;
|
||||
const afterLateCompletion = await service.get('user', 'account');
|
||||
|
||||
expect(current.cashBalances).toEqual([{ currency: 'USD', value: '2' }]);
|
||||
expect(afterLateCompletion.cashBalances).toEqual([
|
||||
{ currency: 'USD', value: '3' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import {
|
||||
add,
|
||||
formatDecimal,
|
||||
isZero,
|
||||
multiply,
|
||||
subtract,
|
||||
} from '../../../../common/decimal';
|
||||
import { RequestContextService } from '../../../../common/logging/request-context.service';
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
import {
|
||||
InstrumentMetadataService,
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
TbankConnectionContext,
|
||||
TbankMoney,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper';
|
||||
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
|
||||
import {
|
||||
PortfolioDto,
|
||||
PortfolioPositionDto,
|
||||
} from '../presentation/portfolio.dto';
|
||||
|
||||
const freshForMs = 30_000;
|
||||
const fallbackForMs = 300_000;
|
||||
const positionsBatchSize = 5;
|
||||
const metadataTimeoutMs = 30_000;
|
||||
const types: Record<string, string> = {
|
||||
share: 'STOCK',
|
||||
bond: 'BOND',
|
||||
etf: 'FUND',
|
||||
currency: 'CURRENCY',
|
||||
futures: 'FUTURE',
|
||||
option: 'OPTION',
|
||||
sp: 'STRUCTURED_NOTE',
|
||||
dfa: 'DIGITAL_FINANCIAL_ASSET',
|
||||
};
|
||||
|
||||
type CachedSnapshot = {
|
||||
generation: string;
|
||||
snapshot: PortfolioDto;
|
||||
storedAt: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioService {
|
||||
private readonly snapshots = new Map<string, CachedSnapshot>();
|
||||
|
||||
constructor(
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly requestContext: RequestContextService,
|
||||
private readonly metadata: InstrumentMetadataService,
|
||||
) {}
|
||||
|
||||
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
||||
return this.access.withConnection(userId, (connection) =>
|
||||
this.snapshot(userId, accountId, connection),
|
||||
);
|
||||
}
|
||||
|
||||
async getAll(userId: string): Promise<PortfolioDto> {
|
||||
return this.access.withSupportedAccounts(userId, async (context) => {
|
||||
const portfolios: PortfolioDto[] = [];
|
||||
for (const account of context.accounts) {
|
||||
const portfolio = await this.snapshot(userId, account.id, context);
|
||||
portfolios.push({
|
||||
...portfolio,
|
||||
positions: portfolio.positions.map((position) => ({
|
||||
...position,
|
||||
account: { id: account.id, name: account.name },
|
||||
})),
|
||||
});
|
||||
}
|
||||
return {
|
||||
lastSuccessfulAt:
|
||||
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
|
||||
new Date().toISOString(),
|
||||
isStale: portfolios.some((portfolio) => portfolio.isStale),
|
||||
totalsByCurrency: sumMoneyByCurrency(
|
||||
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
|
||||
),
|
||||
cashBalances: sumMoneyByCurrency(
|
||||
portfolios.flatMap((portfolio) => portfolio.cashBalances),
|
||||
),
|
||||
positions: portfolios.flatMap((portfolio) => portfolio.positions),
|
||||
structure: portfolioStructure(
|
||||
portfolios.flatMap((portfolio) => portfolio.positions),
|
||||
portfolios.flatMap((portfolio) => portfolio.cashBalances),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async snapshot(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
{ token, generation }: TbankConnectionContext,
|
||||
): Promise<PortfolioDto> {
|
||||
const key = `${userId}:${accountId}`;
|
||||
const cached = this.snapshots.get(key);
|
||||
if (
|
||||
cached?.generation === generation &&
|
||||
Date.now() - cached.storedAt <= freshForMs
|
||||
)
|
||||
return cached.snapshot;
|
||||
try {
|
||||
const snapshot = await this.access.withAccountForConnection(
|
||||
{ token, generation },
|
||||
accountId,
|
||||
async () => {
|
||||
const [portfolio, positions] = await Promise.all([
|
||||
this.tbank.getPortfolio(token, accountId),
|
||||
this.tbank.getPositions(token, accountId),
|
||||
]);
|
||||
return this.normalize(
|
||||
token,
|
||||
portfolio.positions ?? [],
|
||||
positions.money ?? [],
|
||||
);
|
||||
},
|
||||
);
|
||||
this.snapshots.set(key, {
|
||||
generation,
|
||||
snapshot,
|
||||
storedAt: Date.parse(snapshot.lastSuccessfulAt),
|
||||
});
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ServiceUnavailableException &&
|
||||
cached?.generation === generation &&
|
||||
Date.now() - cached.storedAt <= fallbackForMs
|
||||
) {
|
||||
return { ...cached.snapshot, isStale: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(userId: string): void {
|
||||
for (const key of this.snapshots.keys())
|
||||
if (key.startsWith(`${userId}:`)) this.snapshots.delete(key);
|
||||
}
|
||||
|
||||
private async normalize(
|
||||
token: string,
|
||||
upstream: {
|
||||
figi?: string;
|
||||
instrumentUid?: string;
|
||||
instrumentType?: string;
|
||||
quantity?: { units?: string | number; nano?: string | number };
|
||||
currentPrice?: TbankMoney;
|
||||
averagePositionPrice?: TbankMoney;
|
||||
}[],
|
||||
money: TbankMoney[],
|
||||
): Promise<PortfolioDto> {
|
||||
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), metadataTimeoutMs);
|
||||
const positions: (PortfolioPositionDto | null)[] = [];
|
||||
try {
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < upstream.length;
|
||||
offset += positionsBatchSize
|
||||
) {
|
||||
positions.push(
|
||||
...(await Promise.all(
|
||||
upstream
|
||||
.slice(offset, offset + positionsBatchSize)
|
||||
.map((position) =>
|
||||
this.position(token, position, controller.signal),
|
||||
),
|
||||
)),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const cash = new Map<string, ReturnType<typeof decimal>>();
|
||||
for (const value of money) {
|
||||
const currency = value.currency?.trim().toUpperCase();
|
||||
const amount = decimal(value);
|
||||
if (!currency || isZero(amount)) continue;
|
||||
cash.set(currency, add(cash.get(currency) ?? decimal({}), amount));
|
||||
}
|
||||
const cashBalances = [...cash].map(([currency, value]) => ({
|
||||
currency,
|
||||
value: formatDecimal(value),
|
||||
}));
|
||||
const normalizedPositions = positions.filter(
|
||||
(position): position is PortfolioPositionDto => position !== null,
|
||||
);
|
||||
return {
|
||||
lastSuccessfulAt,
|
||||
isStale: false,
|
||||
totalsByCurrency: totalsByCurrency(upstream, money),
|
||||
positions: normalizedPositions,
|
||||
cashBalances,
|
||||
structure: portfolioStructure(normalizedPositions, cashBalances),
|
||||
};
|
||||
}
|
||||
|
||||
private async position(
|
||||
token: string,
|
||||
position: Parameters<PortfolioService['normalize']>[1][number],
|
||||
signal: AbortSignal,
|
||||
): Promise<PortfolioPositionDto | null> {
|
||||
const quantity = decimal(position.quantity ?? {});
|
||||
if (isZero(quantity)) return null;
|
||||
const figi = position.figi?.trim() || null;
|
||||
const instrumentUid = position.instrumentUid?.trim() || null;
|
||||
const currentPrice = this.money(position.currentPrice);
|
||||
const averagePurchasePrice = this.money(position.averagePositionPrice);
|
||||
const currentValue = currentPrice && this.value(quantity, currentPrice);
|
||||
const absoluteReturn =
|
||||
currentPrice &&
|
||||
averagePurchasePrice &&
|
||||
currentPrice.currency === averagePurchasePrice.currency
|
||||
? {
|
||||
currency: currentPrice.currency,
|
||||
value: formatDecimal(
|
||||
multiply(
|
||||
subtract(
|
||||
decimalFromString(currentPrice.value),
|
||||
decimalFromString(averagePurchasePrice.value),
|
||||
),
|
||||
quantity,
|
||||
),
|
||||
),
|
||||
}
|
||||
: null;
|
||||
const metadata = await this.metadata.resolve(
|
||||
token,
|
||||
instrumentUid,
|
||||
figi,
|
||||
signal,
|
||||
);
|
||||
return {
|
||||
figi,
|
||||
instrumentUid,
|
||||
displayName:
|
||||
metadata?.name?.trim() ||
|
||||
metadata?.ticker?.trim() ||
|
||||
figi ||
|
||||
'Unknown instrument',
|
||||
type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER',
|
||||
quantity: formatDecimal(quantity),
|
||||
currentValue,
|
||||
averagePurchasePrice,
|
||||
absoluteReturn,
|
||||
isin: metadata?.isin?.trim() || null,
|
||||
ticker: metadata?.ticker?.trim() || null,
|
||||
logoName: metadata?.brand?.logoName?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
private value(
|
||||
quantity: ReturnType<typeof decimal>,
|
||||
price: MoneyDto,
|
||||
): MoneyDto {
|
||||
return {
|
||||
currency: price.currency,
|
||||
value: formatDecimal(multiply(quantity, decimalFromString(price.value))),
|
||||
};
|
||||
}
|
||||
|
||||
private money(value: TbankMoney | undefined): MoneyDto | null {
|
||||
const currency = value?.currency?.trim().toUpperCase();
|
||||
return currency && value
|
||||
? { currency, value: formatDecimal(decimal(value)) }
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
function decimalFromString(value: string) {
|
||||
const negative = value.startsWith('-');
|
||||
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
|
||||
return {
|
||||
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
|
||||
scale: fraction.length,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user