import { Injectable } from '@nestjs/common'; import { createHash } from 'node:crypto'; import { decimalFromString, divide, formatDecimal, isZero, multiply, } from '../../../common/decimal'; import { investmentResourceMeta } from '../investment-resource.mapper'; import { sumMoneyByCurrency } from '../portfolio/domain/portfolio-totals'; import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api'; import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.api'; import { BrokerageAccountDto, BrokerageAccountsResourceDto, UnrealizedReturnDto, } from './brokerage-account.dto'; const PERCENT_DECIMAL_PLACES = 2; const ZERO_QUANTITY = 0n; function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnDto[] { // Cash-like CURRENCY positions must not duplicate the separately returned cash balances. const investments = positions.filter((position) => position.type !== 'CURRENCY'); const absolute = sumMoneyByCurrency( investments.flatMap((position) => position.absoluteReturn ? [position.absoluteReturn] : []), ); const costs = sumMoneyByCurrency( investments.flatMap((position) => position.absoluteReturn && position.averagePurchasePrice ? [{ currency: position.absoluteReturn.currency, value: formatDecimal(multiply( decimalFromString(position.averagePurchasePrice.value), decimalFromString(position.quantity), )), }] : [], ), ); const partial = new Set( investments.flatMap((position) => { const money = position.absoluteReturn ? null : position.averagePurchasePrice ?? position.currentValue; return money ? [money.currency] : []; }), ); // A short position has negative cost basis, so a percent would flip sign or net out. const withShorts = new Set( investments.flatMap((position) => { const money = position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue; return money && decimalFromString(position.quantity).value < ZERO_QUANTITY ? [money.currency] : []; }), ); const currencies = new Set([...absolute, ...costs].map((money) => money.currency)); return [...new Set([...currencies, ...partial])] .sort((left, right) => left.localeCompare(right)) .map((currency) => { const absoluteReturn = absolute.find((money) => money.currency === currency)?.value ?? '0'; const costBasis = costs.find((money) => money.currency === currency)?.value ?? '0'; const cost = decimalFromString(costBasis); return { currency, absolute: absoluteReturn, costBasis, percent: isZero(cost) || withShorts.has(currency) ? null : formatDecimal(divide( multiply(decimalFromString(absoluteReturn), decimalFromString('100')), cost, PERCENT_DECIMAL_PLACES, )), partial: partial.has(currency), }; }); } @Injectable() export class BrokerageAccountsService { constructor( private readonly portfolio: PortfolioService, private readonly sync: TbankSyncRepository, ) {} async list(userId: string): Promise { const saved = await this.portfolio.saved(userId, 'all'); const data: BrokerageAccountDto[] | null = saved.membership.completeness === 'incomplete' ? null : saved.accounts.map((account) => ({ id: account.id, name: account.name, type: account.type as BrokerageAccountDto['type'], openedOn: account.openedOn, portfolioSummary: account.snapshot ? { totalsByCurrency: account.snapshot.totalsByCurrency, cashBalances: account.snapshot.cashBalances, structure: account.snapshot.structure, unrealizedReturnByCurrency: unrealizedReturns(account.snapshot.positions), stockPositionCount: account.snapshot.positions.filter( (position) => position.type === 'STOCK', ).length, bondPositionCount: account.snapshot.positions.filter( (position) => position.type === 'BOND', ).length, fundPositionCount: account.snapshot.positions.filter( (position) => position.type === 'FUND', ).length, // The snapshot mapper already drops zero-quantity positions. totalPositionCount: account.snapshot.positions.length, } : null, })); // The read-only result of the last hourly walk; never starts one. const hourly = await this.sync.hourly(userId); return { data, meta: investmentResourceMeta({ resourceKey: 'accounts', dataRevision: createHash('sha256') .update( JSON.stringify([ saved.membership.dataRevision, saved.accounts.map((account) => [ account.id, account.meta.dataRevision, ]), ]), ) .digest('base64url'), availability: data === null ? 'missing' : 'complete', fetchedAt: saved.membership.lastSuccessfulAt ? new Date(saved.membership.lastSuccessfulAt) : null, freshForMs: 3_600_000, work: null, lastAttempt: hourlyAttempt(hourly, ['membership', 'portfolio']), dependencies: saved.accounts.map((account) => account.meta), }), sync: hourly ? { state: hourly.state, startedAt: hourly.startedAt.toISOString(), finishedAt: hourly.finishedAt?.toISOString() ?? null, problems: hourly.sections.map((section) => ({ ...section, accountId: section.accountId ?? null, })), } : null, }; } }