refactor!: support RUB only (WealthLedger/main#55) (#204)
BREAKING CHANGE: per-currency API fields replaced with single RUB Money.
This commit was merged in pull request #204.
This commit is contained in:
@@ -7,9 +7,6 @@ import { PortfolioStructureDto } from '../portfolio/portfolio.api';
|
||||
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
|
||||
|
||||
export class UnrealizedReturnDto {
|
||||
@ApiProperty()
|
||||
declare currency: string;
|
||||
|
||||
@ApiProperty({ example: '12.345678901' })
|
||||
declare absolute: string;
|
||||
|
||||
@@ -24,17 +21,17 @@ export class UnrealizedReturnDto {
|
||||
}
|
||||
|
||||
export class PortfolioSummaryDto {
|
||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||
declare totalsByCurrency: MoneyDto[];
|
||||
@ApiProperty({ type: MoneyDto })
|
||||
declare totals: MoneyDto;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||
declare cashBalances: MoneyDto[];
|
||||
@ApiProperty({ type: MoneyDto })
|
||||
declare cash: MoneyDto;
|
||||
|
||||
@ApiProperty({ type: PortfolioStructureDto })
|
||||
declare structure: PortfolioStructureDto;
|
||||
|
||||
@ApiProperty({ type: UnrealizedReturnDto, isArray: true })
|
||||
declare unrealizedReturnByCurrency: UnrealizedReturnDto[];
|
||||
@ApiProperty({ type: UnrealizedReturnDto })
|
||||
declare unrealizedReturn: UnrealizedReturnDto;
|
||||
|
||||
@ApiProperty({
|
||||
type: 'integer',
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('saved brokerage account projection', () => {
|
||||
});
|
||||
|
||||
it('keeps exact saved totals and independent missing summaries', async () => {
|
||||
const structure = { currencies: [], unvaluedCategories: [] };
|
||||
const structure = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] };
|
||||
portfolio.saved.mockResolvedValue({
|
||||
membership: meta,
|
||||
accounts: [
|
||||
@@ -44,11 +44,9 @@ describe('saved brokerage account projection', () => {
|
||||
openedOn: null,
|
||||
meta,
|
||||
snapshot: {
|
||||
cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
|
||||
cash: { currency: 'RUB', value: '10.000000001' },
|
||||
structure,
|
||||
totalsByCurrency: [
|
||||
{ currency: 'RUB', value: '100000000.000000001' },
|
||||
],
|
||||
totals: { currency: 'RUB', value: '100000000.000000001' },
|
||||
positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }],
|
||||
},
|
||||
},
|
||||
@@ -64,14 +62,14 @@ describe('saved brokerage account projection', () => {
|
||||
});
|
||||
const response = await service.list('owner');
|
||||
expect(response.data?.[0].portfolioSummary).toEqual({
|
||||
cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
|
||||
cash: { currency: 'RUB', value: '10.000000001' },
|
||||
structure,
|
||||
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
|
||||
totals: { currency: 'RUB', value: '100000000.000000001' },
|
||||
stockPositionCount: 1,
|
||||
bondPositionCount: 1,
|
||||
fundPositionCount: 0,
|
||||
totalPositionCount: 2,
|
||||
unrealizedReturnByCurrency: [],
|
||||
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false },
|
||||
});
|
||||
expect(response.data?.[1].portfolioSummary).toBeNull();
|
||||
expect(response.meta.availability).toBe('available');
|
||||
@@ -79,74 +77,65 @@ describe('saved brokerage account projection', () => {
|
||||
});
|
||||
|
||||
it('returns an empty summary from an empty saved portfolio', async () => {
|
||||
const structure = { currencies: [], unvaluedPositions: [], unknownValues: [] };
|
||||
const structure = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] };
|
||||
portfolio.saved.mockResolvedValueOnce({
|
||||
membership: meta,
|
||||
accounts: [{
|
||||
id: 'empty', name: 'Empty', type: 'BROKERAGE', openedOn: null, meta,
|
||||
snapshot: { cashBalances: [], structure, totalsByCurrency: [], positions: [] },
|
||||
snapshot: { cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' }, positions: [] },
|
||||
}],
|
||||
});
|
||||
|
||||
expect((await service.list('owner')).data?.[0].portfolioSummary).toEqual({
|
||||
cashBalances: [], structure, totalsByCurrency: [],
|
||||
cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' },
|
||||
stockPositionCount: 0, bondPositionCount: 0,
|
||||
fundPositionCount: 0, totalPositionCount: 0,
|
||||
unrealizedReturnByCurrency: [],
|
||||
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates exact returns by currency, partial data, and zero cost basis', async () => {
|
||||
it('calculates exact returns, partial data, and zero cost basis', async () => {
|
||||
portfolio.saved.mockResolvedValueOnce({
|
||||
membership: meta,
|
||||
accounts: [{
|
||||
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
||||
snapshot: {
|
||||
cashBalances: [], structure: {}, totalsByCurrency: [
|
||||
{ currency: 'RUB', value: '100' }, { currency: 'USD', value: '20' },
|
||||
],
|
||||
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '100' },
|
||||
positions: [
|
||||
{ type: 'STOCK', quantity: '2', averagePurchasePrice: { currency: 'RUB', value: '10.000000001' }, absoluteReturn: { currency: 'RUB', value: '0.000000003' } },
|
||||
{ type: 'BOND', quantity: '0.5', averagePurchasePrice: { currency: 'RUB', value: '20' }, absoluteReturn: { currency: 'RUB', value: '1.25' } },
|
||||
{ type: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'USD', value: '2.5' }, absoluteReturn: null },
|
||||
{ type: 'STOCK', quantity: '4', averagePurchasePrice: { currency: 'USD', value: '0' }, absoluteReturn: { currency: 'USD', value: '2.5' } },
|
||||
{ type: 'CURRENCY', quantity: '100', averagePurchasePrice: { currency: 'EUR', value: '1' }, absoluteReturn: { currency: 'EUR', value: '5' } },
|
||||
{ type: 'BOND', quantity: '1', currentValue: { currency: 'CNY', value: '7' }, averagePurchasePrice: null, absoluteReturn: null },
|
||||
{ type: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'RUB', value: '2.5' }, absoluteReturn: null },
|
||||
{ type: 'BOND', quantity: '1', currentValue: { currency: 'RUB', value: '7' }, averagePurchasePrice: null, absoluteReturn: null },
|
||||
],
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({
|
||||
stockPositionCount: 2, bondPositionCount: 2,
|
||||
fundPositionCount: 1, totalPositionCount: 6,
|
||||
unrealizedReturnByCurrency: [
|
||||
{ currency: 'CNY', absolute: '0', costBasis: '0', percent: null, partial: true },
|
||||
{ currency: 'RUB', absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: false },
|
||||
{ currency: 'USD', absolute: '2.5', costBasis: '0', percent: null, partial: true },
|
||||
],
|
||||
stockPositionCount: 1, bondPositionCount: 2,
|
||||
fundPositionCount: 1, totalPositionCount: 4,
|
||||
unrealizedReturn: { absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('omits return percent for a currency with short positions', async () => {
|
||||
it('omits return percent for short positions', async () => {
|
||||
portfolio.saved.mockResolvedValueOnce({
|
||||
membership: meta,
|
||||
accounts: [{
|
||||
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
||||
snapshot: {
|
||||
cashBalances: [], structure: {}, totalsByCurrency: [],
|
||||
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '0' },
|
||||
positions: [
|
||||
{ type: 'STOCK', quantity: '-2', averagePurchasePrice: { currency: 'RUB', value: '100' }, absoluteReturn: { currency: 'RUB', value: '-20' } },
|
||||
{ type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'USD', value: '10' }, absoluteReturn: { currency: 'USD', value: '1' } },
|
||||
{ type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'RUB', value: '10' }, absoluteReturn: { currency: 'RUB', value: '1' } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturnByCurrency).toEqual([
|
||||
{ currency: 'RUB', absolute: '-20', costBasis: '-200', percent: null, partial: false },
|
||||
{ currency: 'USD', absolute: '1', costBasis: '10', percent: '10', partial: false },
|
||||
]);
|
||||
expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturn).toEqual(
|
||||
{ absolute: '-19', costBasis: '-190', percent: null, partial: false },
|
||||
);
|
||||
});
|
||||
|
||||
it('observes the latest hourly walk without starting another one', async () => {
|
||||
|
||||
@@ -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