import { Injectable } from '@nestjs/common'; import { createHash } from 'node:crypto'; import { add, decimalFromString, divide, formatDecimal, isZero, multiply, } from '#src/common/decimal'; import { investmentResourceMeta } from '../investment-resource.mapper'; 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 unrealizedReturn(positions: PortfolioPositionDto[]): UnrealizedReturnDto { const absoluteReturn = positions.reduce( (sum, position) => position.absoluteReturn ? add(sum, decimalFromString(position.absoluteReturn.value)) : sum, decimalFromString('0'), ); const costBasis = positions .filter((position) => position.absoluteReturn && position.averagePurchasePrice) .reduce( (sum, position) => // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Guarded by the filter above. add(sum, multiply(decimalFromString(position.averagePurchasePrice!.value), decimalFromString(position.quantity))), decimalFromString('0'), ); const partial = positions.some( (position) => !position.absoluteReturn && (position.averagePurchasePrice || position.currentValue), ); // A short position has negative cost basis, so a percent would flip sign or net out. const withShorts = positions.some( (position) => (position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue) && decimalFromString(position.quantity).value < ZERO_QUANTITY, ); return { absolute: formatDecimal(absoluteReturn), costBasis: formatDecimal(costBasis), percent: isZero(costBasis) || withShorts ? null : formatDecimal(divide( multiply(absoluteReturn, decimalFromString('100')), costBasis, PERCENT_DECIMAL_PLACES, )), partial, }; } @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 ? { totals: account.snapshot.totals, cash: account.snapshot.cash, structure: account.snapshot.structure, unrealizedReturn: unrealizedReturn(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, }; } }