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.
This commit is contained in:
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
add,
|
||||
decimalFromString,
|
||||
divide,
|
||||
formatDecimal,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
} from '#src/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 {
|
||||
@@ -22,61 +22,42 @@ import {
|
||||
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] : []),
|
||||
function unrealizedReturn(positions: PortfolioPositionDto[]): UnrealizedReturnDto {
|
||||
const absoluteReturn = positions.reduce(
|
||||
(sum, position) => position.absoluteReturn
|
||||
? add(sum, decimalFromString(position.absoluteReturn.value))
|
||||
: sum,
|
||||
decimalFromString('0'),
|
||||
);
|
||||
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] : [];
|
||||
}),
|
||||
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 = new Set(
|
||||
investments.flatMap((position) => {
|
||||
const money = position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue;
|
||||
return money && decimalFromString(position.quantity).value < ZERO_QUANTITY ? [money.currency] : [];
|
||||
}),
|
||||
const withShorts = positions.some(
|
||||
(position) =>
|
||||
(position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue) &&
|
||||
decimalFromString(position.quantity).value < ZERO_QUANTITY,
|
||||
);
|
||||
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),
|
||||
};
|
||||
});
|
||||
return {
|
||||
absolute: formatDecimal(absoluteReturn),
|
||||
costBasis: formatDecimal(costBasis),
|
||||
percent: isZero(costBasis) || withShorts
|
||||
? null
|
||||
: formatDecimal(divide(
|
||||
multiply(absoluteReturn, decimalFromString('100')),
|
||||
costBasis,
|
||||
PERCENT_DECIMAL_PLACES,
|
||||
)),
|
||||
partial,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -98,10 +79,10 @@ export class BrokerageAccountsService {
|
||||
openedOn: account.openedOn,
|
||||
portfolioSummary: account.snapshot
|
||||
? {
|
||||
totalsByCurrency: account.snapshot.totalsByCurrency,
|
||||
cashBalances: account.snapshot.cashBalances,
|
||||
totals: account.snapshot.totals,
|
||||
cash: account.snapshot.cash,
|
||||
structure: account.snapshot.structure,
|
||||
unrealizedReturnByCurrency: unrealizedReturns(account.snapshot.positions),
|
||||
unrealizedReturn: unrealizedReturn(account.snapshot.positions),
|
||||
stockPositionCount: account.snapshot.positions.filter(
|
||||
(position) => position.type === 'STOCK',
|
||||
).length,
|
||||
|
||||
Reference in New Issue
Block a user