Files
backend/src/tbank/portfolio.service.ts
T

284 lines
8.9 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Environment } from '../config/env.validation';
import { RequestContextService } from '../common/logging/request-context.service';
import { StructuredLogger } from '../common/logging/structured-logger.service';
import { PrismaService } from '../prisma/prisma.service';
import {
add,
decimal,
formatDecimal,
isZero,
multiply,
subtract,
} from './decimal';
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
import { TbankClient, TbankMoney } from './tbank.client';
import { TbankCrypto } from './tbank.crypto';
const freshForMs = 30_000;
const fallbackForMs = 300_000;
const metadataForMs = 86_400_000;
const supportedAccounts = new Set([
'ACCOUNT_TYPE_TINKOFF',
'ACCOUNT_TYPE_TINKOFF_IIS',
'ACCOUNT_TYPE_INVEST_BOX',
'ACCOUNT_TYPE_INVEST_FUND',
]);
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 };
type CachedMetadata = {
value: { ticker?: string; name?: string; isin?: string } | null;
storedAt: number;
};
@Injectable()
export class PortfolioService {
private readonly crypto: TbankCrypto;
private readonly snapshots = new Map<string, CachedSnapshot>();
private readonly metadata = new Map<string, CachedMetadata>();
constructor(
private readonly prisma: PrismaService,
config: ConfigService<Environment, true>,
private readonly tbank: TbankClient,
private readonly requestContext: RequestContextService,
private readonly logger: StructuredLogger,
) {
this.crypto = TbankCrypto.fromBase64(
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
);
}
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 connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
});
if (!connection)
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
const token = this.crypto.decrypt(connection.encryptedToken);
const accounts = await this.tbank.getOpenAccounts(token);
if (
!accounts.some(
(account) =>
account.id === accountId && supportedAccounts.has(account.type),
)
) {
throw new NotFoundException();
}
const [portfolio, positions] = await Promise.all([
this.tbank.getPortfolio(token, accountId),
this.tbank.getPositions(token, accountId),
]);
const snapshot = await this.normalize(
token,
portfolio.positions ?? [],
positions.money ?? [],
);
this.snapshots.set(key, { snapshot, storedAt: Date.now() });
return snapshot;
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
}
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),
}));
const totals = new Map(cash);
for (const position of positions) {
if (!position?.currentValue) continue;
const amount = decimalFromString(position.currentValue.value);
totals.set(
position.currentValue.currency,
add(totals.get(position.currentValue.currency) ?? decimal({}), amount),
);
}
return {
lastSuccessfulAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
isStale: false,
totalsByCurrency: [...totals].map(([currency, value]) => ({
currency,
value: formatDecimal(value),
})),
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.instrument(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,
};
}
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;
}
private async instrument(
token: string,
instrumentUid: string | null,
figi: string | null,
) {
const lookup = async (
key: string,
fetch: () => Promise<{ ticker?: string; name?: string; isin?: string } | null>,
) => {
const cached = this.metadata.get(key);
if (cached && Date.now() - cached.storedAt <= metadataForMs)
return cached.value;
try {
const value = await fetch();
this.metadata.set(key, { value, storedAt: Date.now() });
this.logger.event('debug', 'portfolio.instrument-metadata.resolved', {
found: Boolean(value?.name?.trim() || value?.ticker?.trim()),
});
return value;
} catch (error) {
this.logger.event('warn', 'portfolio.instrument-metadata.failed', {
code:
error instanceof Error && 'response' in error
? ((error as { response?: { code?: string } }).response?.code ??
'UNKNOWN')
: 'UNKNOWN',
});
return undefined;
}
};
if (instrumentUid) {
const byUid = await lookup(`uid:${instrumentUid}`, () =>
this.tbank.getInstrumentByUid(token, instrumentUid),
);
if (byUid) return byUid;
}
if (!figi) return null;
return lookup(`figi:${figi}`, () =>
this.tbank.getInstrumentByFigi(token, figi),
);
}
}
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,
};
}