194 lines
6.1 KiB
TypeScript
194 lines
6.1 KiB
TypeScript
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
|
import { RequestContextService } from '../common/logging/request-context.service';
|
|
import {
|
|
add,
|
|
decimal,
|
|
formatDecimal,
|
|
isZero,
|
|
multiply,
|
|
subtract,
|
|
} from './decimal';
|
|
import { totalsByCurrency } from './portfolio-totals';
|
|
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
|
import { TbankAccessService } from './tbank-access.service';
|
|
import { InstrumentMetadataService } from './instrument-metadata.service';
|
|
import { TbankClient, TbankMoney } from './tbank.client';
|
|
|
|
const freshForMs = 30_000;
|
|
const fallbackForMs = 300_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 = { 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 });
|
|
const key = `${userId}:${accountId}`;
|
|
const cached = this.snapshots.get(key);
|
|
if (cached && Date.now() - cached.storedAt <= freshForMs)
|
|
return cached.snapshot;
|
|
try {
|
|
const snapshot = await this.access.withAccount(
|
|
userId,
|
|
accountId,
|
|
async ({ token }) => {
|
|
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, { snapshot, storedAt: Date.now() });
|
|
return snapshot;
|
|
} catch (error) {
|
|
if (
|
|
error instanceof ServiceUnavailableException &&
|
|
cached &&
|
|
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 positions = await Promise.all(
|
|
upstream.map((position) => this.position(token, position)),
|
|
);
|
|
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),
|
|
}));
|
|
return {
|
|
lastSuccessfulAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
isStale: false,
|
|
totalsByCurrency: totalsByCurrency(upstream, money),
|
|
positions: positions.filter(
|
|
(position): position is PortfolioPositionDto => position !== null,
|
|
),
|
|
cashBalances,
|
|
};
|
|
}
|
|
|
|
private async position(
|
|
token: string,
|
|
position: Parameters<PortfolioService['normalize']>[1][number],
|
|
): 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(
|
|
decimal(position.currentPrice!),
|
|
decimal(position.averagePositionPrice!),
|
|
),
|
|
quantity,
|
|
),
|
|
),
|
|
}
|
|
: null;
|
|
const metadata = await this.metadata.resolve(token, instrumentUid, figi);
|
|
return {
|
|
figi,
|
|
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,
|
|
};
|
|
}
|