refactor: centralize provider projections
This commit is contained in:
@@ -5,10 +5,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { formatDecimal } from '../../../../common/decimal';
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
@@ -26,6 +24,7 @@ import {
|
|||||||
} from '../domain/candle-coverage';
|
} from '../domain/candle-coverage';
|
||||||
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||||
|
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||||
import { timestamp } from './instrument-market';
|
import { timestamp } from './instrument-market';
|
||||||
|
|
||||||
@@ -413,17 +412,7 @@ export class CandleService {
|
|||||||
window.from,
|
window.from,
|
||||||
new Date(Math.min(window.to.valueOf(), now.valueOf())),
|
new Date(Math.min(window.to.valueOf(), now.valueOf())),
|
||||||
);
|
);
|
||||||
const rows = candles.flatMap((candle) => {
|
const rows = candles.map(toSavedCandle);
|
||||||
const time = timestamp(candle.time);
|
|
||||||
if (!time || !candle.close) throw new Error('Invalid candle response');
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
time,
|
|
||||||
close: formatDecimal(decimal(candle.close)),
|
|
||||||
isComplete: candle.isComplete === true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
if (!(await this.repository.stage(attempt, window, rows, now))) {
|
if (!(await this.repository.stage(attempt, window, rows, now))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import {
|
import {
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
divide,
|
divide,
|
||||||
formatDecimal,
|
|
||||||
} from '../../../../common/decimal';
|
} from '../../../../common/decimal';
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +12,6 @@ import {
|
|||||||
} from '../../../../common/time';
|
} from '../../../../common/time';
|
||||||
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
||||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
@@ -37,6 +35,7 @@ import {
|
|||||||
catalogScope,
|
catalogScope,
|
||||||
type CatalogSource,
|
type CatalogSource,
|
||||||
} from '../infrastructure/catalog-discovery.repository';
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
|
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||||
import { catalogSearchLimit } from './catalog-selection';
|
import { catalogSearchLimit } from './catalog-selection';
|
||||||
import { previousClose, timestamp } from './instrument-market';
|
import { previousClose, timestamp } from './instrument-market';
|
||||||
|
|
||||||
@@ -353,17 +352,7 @@ export class CatalogEnrichmentService {
|
|||||||
gap.from,
|
gap.from,
|
||||||
gap.to,
|
gap.to,
|
||||||
);
|
);
|
||||||
const rows = response.map((candle) => {
|
const rows = response.map(toSavedCandle);
|
||||||
const time = timestamp(candle.time);
|
|
||||||
if (!time || !candle.close) {
|
|
||||||
throw new Error('Invalid candle response');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
time,
|
|
||||||
close: formatDecimal(decimal(candle.close)),
|
|
||||||
isComplete: candle.isComplete === true,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
if (!(await this.candles.stage(attempt, gap, rows, now))) {
|
if (!(await this.candles.stage(attempt, gap, rows, now))) {
|
||||||
throw new Error('Cancelled candle comparison');
|
throw new Error('Cancelled candle comparison');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { toSavedCandle } from './tbank-candle.mapper';
|
||||||
|
|
||||||
|
describe('T-Bank candle mapping', () => {
|
||||||
|
it('keeps signed nano precision and requires explicit completeness', () => {
|
||||||
|
const input = { time: { seconds: '1767225600' }, close: { units: '0', nano: -1 } };
|
||||||
|
expect(toSavedCandle(input)).toEqual({
|
||||||
|
time: '2026-01-01T00:00:00.000Z', close: '-0.000000001', isComplete: false,
|
||||||
|
});
|
||||||
|
expect(toSavedCandle({ ...input, isComplete: true }).isComplete).toBe(true);
|
||||||
|
expect(input).toEqual({ time: { seconds: '1767225600' }, close: { units: '0', nano: -1 } });
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
{ close: { units: '1' } },
|
||||||
|
{ time: { seconds: 'invalid' }, close: { units: '1' } },
|
||||||
|
{ time: { seconds: '1767225600' } },
|
||||||
|
])('rejects missing or malformed source fields: %j', (input) => {
|
||||||
|
expect(() => toSavedCandle(input)).toThrow('Invalid candle response');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { TbankCandle } from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import type { SavedCandle } from '../domain/candle-coverage';
|
||||||
|
|
||||||
|
import { formatDecimal } from '../../../../common/decimal';
|
||||||
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
|
import { timestamp } from '../application/instrument-market';
|
||||||
|
|
||||||
|
function toSavedCandle(candle: TbankCandle): SavedCandle {
|
||||||
|
const time = timestamp(candle.time);
|
||||||
|
if (!time || !candle.close) throw new Error('Invalid candle response');
|
||||||
|
return {
|
||||||
|
time,
|
||||||
|
close: formatDecimal(decimal(candle.close)),
|
||||||
|
isComplete: candle.isComplete === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { toSavedCandle };
|
||||||
@@ -129,6 +129,43 @@ describe('persistent portfolio projection', () => {
|
|||||||
]);
|
]);
|
||||||
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
|
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
|
||||||
});
|
});
|
||||||
|
it('keeps net-zero cash groups, missing valuations and incompatible currencies distinct', async () => {
|
||||||
|
repository.reserve.mockResolvedValueOnce({
|
||||||
|
launch: true,
|
||||||
|
work: { id: 'work', attemptId: 'attempt' },
|
||||||
|
});
|
||||||
|
tbank.getPortfolio.mockResolvedValue({ positions: [
|
||||||
|
{ quantity: { units: '1' }, currentPrice: { units: '3' } },
|
||||||
|
{
|
||||||
|
quantity: { units: '1' },
|
||||||
|
currentPrice: { currency: 'USD', units: '3' },
|
||||||
|
averagePositionPrice: { currency: 'RUB', units: '2' },
|
||||||
|
},
|
||||||
|
] });
|
||||||
|
tbank.getPositions.mockResolvedValue({ money: [
|
||||||
|
{ currency: 'rub', units: '2' },
|
||||||
|
{ currency: 'RUB', units: '-2' },
|
||||||
|
{ units: '99' },
|
||||||
|
{ currency: 'EUR', units: '1' },
|
||||||
|
] });
|
||||||
|
await service.request('user', 'private', 'a');
|
||||||
|
for (let index = 0; index < 10 && !repository.publish.mock.calls.length; index++) {
|
||||||
|
await immediate();
|
||||||
|
}
|
||||||
|
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
|
||||||
|
expect(snapshot.cashBalances).toEqual([
|
||||||
|
{ currency: 'RUB', value: '0' },
|
||||||
|
{ currency: 'EUR', value: '1' },
|
||||||
|
]);
|
||||||
|
expect(snapshot.totalsByCurrency).toEqual([
|
||||||
|
{ currency: 'EUR', value: '1' },
|
||||||
|
{ currency: 'USD', value: '3' },
|
||||||
|
]);
|
||||||
|
expect(snapshot.positions[0].currentValue).toBeNull();
|
||||||
|
expect(snapshot.positions[1].absoluteReturn).toBeNull();
|
||||||
|
expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' });
|
||||||
|
});
|
||||||
|
|
||||||
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
||||||
const snapshot: PortfolioDto = {
|
const snapshot: PortfolioDto = {
|
||||||
lastSuccessfulAt: membershipAt.toISOString(),
|
lastSuccessfulAt: membershipAt.toISOString(),
|
||||||
|
|||||||
@@ -5,18 +5,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
|
||||||
add,
|
|
||||||
decimalFromString,
|
|
||||||
formatDecimal,
|
|
||||||
isZero,
|
|
||||||
multiply,
|
|
||||||
subtract,
|
|
||||||
} from '../../../../common/decimal';
|
|
||||||
import { RequestContextService } from '../../../../common/logging/request-context.service';
|
import { RequestContextService } from '../../../../common/logging/request-context.service';
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { MoneyDto } from '../../../../common/money.dto';
|
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
@@ -24,9 +14,9 @@ import {
|
|||||||
metadataKey,
|
metadataKey,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
type TbankInstrument,
|
|
||||||
TbankMembershipService,
|
TbankMembershipService,
|
||||||
TbankMoney,
|
type TbankMoney,
|
||||||
|
type TbankPortfolio,
|
||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { CandleService } from '../../instruments/instruments.api';
|
import { CandleService } from '../../instruments/instruments.api';
|
||||||
@@ -38,25 +28,12 @@ import {
|
|||||||
PortfolioRepository,
|
PortfolioRepository,
|
||||||
portfolioWorkKey,
|
portfolioWorkKey,
|
||||||
} from '../infrastructure/portfolio.repository';
|
} from '../infrastructure/portfolio.repository';
|
||||||
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper';
|
import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
|
||||||
import {
|
import { PortfolioDto } from '../presentation/portfolio.dto';
|
||||||
PortfolioDto,
|
|
||||||
PortfolioPositionDto,
|
|
||||||
} from '../presentation/portfolio.dto';
|
|
||||||
|
|
||||||
const MISSING_DATA_REVISION = 0;
|
const MISSING_DATA_REVISION = 0;
|
||||||
|
|
||||||
const membershipFreshForMs = tbankSourceTtlMs.membership;
|
const membershipFreshForMs = tbankSourceTtlMs.membership;
|
||||||
const types: Record<string, string> = {
|
|
||||||
share: 'STOCK',
|
|
||||||
bond: 'BOND',
|
|
||||||
etf: 'FUND',
|
|
||||||
currency: 'CURRENCY',
|
|
||||||
futures: 'FUTURE',
|
|
||||||
option: 'OPTION',
|
|
||||||
sp: 'STRUCTURED_NOTE',
|
|
||||||
dfa: 'DIGITAL_FINANCIAL_ASSET',
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
@@ -423,109 +400,11 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async normalize(
|
private async normalize(
|
||||||
upstream: {
|
upstream: NonNullable<TbankPortfolio['positions']>,
|
||||||
averagePositionPrice?: TbankMoney;
|
|
||||||
currentPrice?: TbankMoney;
|
|
||||||
figi?: string;
|
|
||||||
instrumentType?: string;
|
|
||||||
instrumentUid?: string;
|
|
||||||
quantity?: { nano?: number | string; units?: number | string };
|
|
||||||
}[],
|
|
||||||
money: TbankMoney[],
|
money: TbankMoney[],
|
||||||
): Promise<PortfolioDto> {
|
): Promise<PortfolioDto> {
|
||||||
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
const savedMetadata = await this.metadata.savedMany(upstream);
|
const savedMetadata = await this.metadata.savedMany(upstream);
|
||||||
const positions = upstream.map((position) =>
|
return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt);
|
||||||
this.position(
|
|
||||||
position,
|
|
||||||
savedMetadata.get(metadataKey(position))?.data ?? null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
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 position(
|
|
||||||
position: Parameters<PortfolioService['normalize']>[0][number],
|
|
||||||
metadata: null | TbankInstrument,
|
|
||||||
): null | PortfolioPositionDto {
|
|
||||||
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;
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,110 @@
|
|||||||
import type { Money } from '../../../../common/money.types';
|
import type { Money } from '../../../../common/money.types';
|
||||||
|
import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfolio.dto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
|
decimalFromString,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
isZero,
|
isZero,
|
||||||
multiply,
|
multiply,
|
||||||
|
subtract,
|
||||||
} from '../../../../common/decimal';
|
} from '../../../../common/decimal';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
import {
|
import {
|
||||||
|
metadataKey,
|
||||||
|
type TbankInstrument,
|
||||||
|
type TbankMoney,
|
||||||
TbankPortfolio,
|
TbankPortfolio,
|
||||||
TbankPositions,
|
TbankPositions,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||||
|
|
||||||
|
const types: Record<string, string> = {
|
||||||
|
share: 'STOCK',
|
||||||
|
bond: 'BOND',
|
||||||
|
etf: 'FUND',
|
||||||
|
currency: 'CURRENCY',
|
||||||
|
futures: 'FUTURE',
|
||||||
|
option: 'OPTION',
|
||||||
|
sp: 'STRUCTURED_NOTE',
|
||||||
|
dfa: 'DIGITAL_FINANCIAL_ASSET',
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapPortfolio(
|
||||||
|
upstream: NonNullable<TbankPortfolio['positions']>,
|
||||||
|
cash: TbankMoney[],
|
||||||
|
metadata: ReadonlyMap<string, { data?: null | TbankInstrument }>,
|
||||||
|
lastSuccessfulAt: string,
|
||||||
|
): PortfolioDto {
|
||||||
|
const positions = upstream
|
||||||
|
.map((position) => mapPosition(position, metadata.get(metadataKey(position))?.data ?? null))
|
||||||
|
.filter((position): position is PortfolioPositionDto => position !== null);
|
||||||
|
const balances = new Map<string, ReturnType<typeof decimal>>();
|
||||||
|
for (const value of cash) {
|
||||||
|
const currency = value.currency?.trim().toUpperCase();
|
||||||
|
const amount = decimal(value);
|
||||||
|
if (!currency || isZero(amount)) continue;
|
||||||
|
balances.set(currency, add(balances.get(currency) ?? decimal({}), amount));
|
||||||
|
}
|
||||||
|
const cashBalances = [...balances].map(([currency, value]) => ({
|
||||||
|
currency,
|
||||||
|
value: formatDecimal(value),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
lastSuccessfulAt,
|
||||||
|
isStale: false,
|
||||||
|
totalsByCurrency: totalsByCurrency(upstream, cash),
|
||||||
|
positions,
|
||||||
|
cashBalances,
|
||||||
|
structure: portfolioStructure(positions, cashBalances),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPosition(
|
||||||
|
position: NonNullable<TbankPortfolio['positions']>[number],
|
||||||
|
metadata: null | TbankInstrument,
|
||||||
|
): null | PortfolioPositionDto {
|
||||||
|
const quantity = decimal(position.quantity ?? {});
|
||||||
|
if (isZero(quantity)) return null;
|
||||||
|
const figi = position.figi?.trim() || null;
|
||||||
|
const instrumentUid = position.instrumentUid?.trim() || null;
|
||||||
|
const currentPrice = money(position.currentPrice);
|
||||||
|
const averagePurchasePrice = money(position.averagePositionPrice);
|
||||||
|
const currentValue = currentPrice && {
|
||||||
|
currency: currentPrice.currency,
|
||||||
|
value: formatDecimal(multiply(quantity, decimalFromString(currentPrice.value))),
|
||||||
|
};
|
||||||
|
const absoluteReturn =
|
||||||
|
currentPrice && averagePurchasePrice && currentPrice.currency === averagePurchasePrice.currency
|
||||||
|
? {
|
||||||
|
currency: currentPrice.currency,
|
||||||
|
value: formatDecimal(multiply(
|
||||||
|
subtract(decimalFromString(currentPrice.value), decimalFromString(averagePurchasePrice.value)),
|
||||||
|
quantity,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value: TbankMoney | undefined): Money | null {
|
||||||
|
const currency = value?.currency?.trim().toUpperCase();
|
||||||
|
return currency && value
|
||||||
|
? { currency, value: formatDecimal(decimal(value)) }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
function totalsByCurrency(
|
function totalsByCurrency(
|
||||||
positions: NonNullable<TbankPortfolio['positions']>,
|
positions: NonNullable<TbankPortfolio['positions']>,
|
||||||
@@ -44,4 +138,4 @@ function totalsByCurrency(
|
|||||||
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
|
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export { totalsByCurrency };
|
export { mapPortfolio, totalsByCurrency };
|
||||||
|
|||||||
Reference in New Issue
Block a user