Files
backend/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts
T
ksv741 3b55b611b5 refactor!: support RUB only (WealthLedger/main#55)
Filter non-RUB data at ingest, collapse per-currency collections into single RUB Money, drop currency from TbankPortfolioValuePoint, remove NON_RUB_ACTIVITY and screener currency filter.

BREAKING CHANGE: per-currency API fields replaced with single RUB Money; stock screener currency param removed.
2026-11-11 08:22:16 +03:00

143 lines
5.0 KiB
TypeScript

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<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
? {
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,
};
}
}