161 lines
5.9 KiB
TypeScript
161 lines
5.9 KiB
TypeScript
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<BrokerageAccountsResourceDto> {
|
|
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,
|
|
};
|
|
}
|
|
}
|