fix: count brokerage cash once and skip short-position return percent (WealthLedger/backend#140)
This commit is contained in:
@@ -128,6 +128,27 @@ describe('saved brokerage account projection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('omits return percent for a currency with short positions', async () => {
|
||||||
|
portfolio.saved.mockResolvedValueOnce({
|
||||||
|
membership: meta,
|
||||||
|
accounts: [{
|
||||||
|
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
||||||
|
snapshot: {
|
||||||
|
cashBalances: [], structure: {}, totalsByCurrency: [],
|
||||||
|
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' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('observes the latest hourly walk without starting another one', async () => {
|
it('observes the latest hourly walk without starting another one', async () => {
|
||||||
portfolio.saved.mockResolvedValue({ membership: meta, accounts: [] });
|
portfolio.saved.mockResolvedValue({ membership: meta, accounts: [] });
|
||||||
sync.hourly.mockResolvedValue({
|
sync.hourly.mockResolvedValue({
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from './brokerage-account.dto';
|
} from './brokerage-account.dto';
|
||||||
|
|
||||||
const PERCENT_DECIMAL_PLACES = 2;
|
const PERCENT_DECIMAL_PLACES = 2;
|
||||||
|
const ZERO_QUANTITY = 0n;
|
||||||
|
|
||||||
function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnDto[] {
|
function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnDto[] {
|
||||||
// Cash-like CURRENCY positions must not duplicate the separately returned cash balances.
|
// Cash-like CURRENCY positions must not duplicate the separately returned cash balances.
|
||||||
@@ -47,6 +48,13 @@ function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnD
|
|||||||
return money ? [money.currency] : [];
|
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));
|
const currencies = new Set([...absolute, ...costs].map((money) => money.currency));
|
||||||
return [...new Set([...currencies, ...partial])]
|
return [...new Set([...currencies, ...partial])]
|
||||||
.sort((left, right) => left.localeCompare(right))
|
.sort((left, right) => left.localeCompare(right))
|
||||||
@@ -58,7 +66,7 @@ function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnD
|
|||||||
currency,
|
currency,
|
||||||
absolute: absoluteReturn,
|
absolute: absoluteReturn,
|
||||||
costBasis,
|
costBasis,
|
||||||
percent: isZero(cost)
|
percent: isZero(cost) || withShorts.has(currency)
|
||||||
? null
|
? null
|
||||||
: formatDecimal(divide(
|
: formatDecimal(divide(
|
||||||
multiply(decimalFromString(absoluteReturn), decimalFromString('100')),
|
multiply(decimalFromString(absoluteReturn), decimalFromString('100')),
|
||||||
|
|||||||
@@ -159,6 +159,31 @@ describe('persistent portfolio projection', () => {
|
|||||||
expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' });
|
expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts cash once when T-Bank also returns it as a CURRENCY position', async () => {
|
||||||
|
repository.reserve.mockResolvedValueOnce({
|
||||||
|
launch: true,
|
||||||
|
work: { id: 'work', attemptId: 'attempt' },
|
||||||
|
});
|
||||||
|
tbank.getPortfolio.mockResolvedValue({ positions: [
|
||||||
|
{ figi: 'FUND', instrumentType: 'etf', quantity: { units: '1' }, currentPrice: { currency: 'RUB', units: '120', nano: 240000000 } },
|
||||||
|
{ figi: 'RUB000UTSTOM', instrumentType: 'currency', quantity: { units: '1', nano: 250000000 }, currentPrice: { currency: 'RUB', units: '1' } },
|
||||||
|
] });
|
||||||
|
tbank.getPositions.mockResolvedValue({ money: [{ currency: 'RUB', units: '1', nano: 250000000 }] });
|
||||||
|
repository.read.mockResolvedValue({
|
||||||
|
membershipAt,
|
||||||
|
membershipRevision: 1,
|
||||||
|
accounts: [{ accountId: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, portfolio: null }],
|
||||||
|
work: [],
|
||||||
|
});
|
||||||
|
const handle = await service.request('user', 'private', 'a');
|
||||||
|
assert(handle);
|
||||||
|
await handle.done;
|
||||||
|
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
|
||||||
|
expect(snapshot.totalsByCurrency).toEqual([{ currency: 'RUB', value: '121.49' }]);
|
||||||
|
expect(snapshot.positions.map((position) => position.type)).toEqual(['FUND', 'CURRENCY']);
|
||||||
|
expect(snapshot.structure.currencies[0].totalValue).toBe('121.49');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
||||||
const snapshot: PortfolioDto = {
|
const snapshot: PortfolioDto = {
|
||||||
lastSuccessfulAt: membershipAt.toISOString(),
|
lastSuccessfulAt: membershipAt.toISOString(),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const position = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('portfolio structure', () => {
|
describe('portfolio structure', () => {
|
||||||
it('includes cash once and keeps currency instruments among other assets', () => {
|
it('counts cash once and skips CURRENCY positions that mirror it', () => {
|
||||||
const result = portfolioStructure(
|
const result = portfolioStructure(
|
||||||
[
|
[
|
||||||
position('STOCK', '60'),
|
position('STOCK', '60'),
|
||||||
@@ -30,13 +30,13 @@ describe('portfolio structure', () => {
|
|||||||
);
|
);
|
||||||
expect(result.currencies[0]).toEqual({
|
expect(result.currencies[0]).toEqual({
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
totalValue: '100',
|
totalValue: '90',
|
||||||
hasNegativeValues: false,
|
hasNegativeValues: false,
|
||||||
categories: [
|
categories: [
|
||||||
{ type: 'STOCK', value: '60', sharePercent: '60' },
|
{ type: 'STOCK', value: '60', sharePercent: '66.666667' },
|
||||||
{ type: 'BOND', value: '20', sharePercent: '20' },
|
{ type: 'BOND', value: '20', sharePercent: '22.222222' },
|
||||||
{ type: 'OTHER', value: '10', sharePercent: '10' },
|
{ type: 'OTHER', value: '0', sharePercent: '0' },
|
||||||
{ type: 'CASH', value: '10', sharePercent: '10' },
|
{ type: 'CASH', value: '10', sharePercent: '11.111111' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export function portfolioStructure(
|
|||||||
currencies.set(money.currency, group);
|
currencies.set(money.currency, group);
|
||||||
};
|
};
|
||||||
for (const position of positions) {
|
for (const position of positions) {
|
||||||
|
// CURRENCY positions mirror cash, which is counted once as CASH below.
|
||||||
|
if (position.type === 'CURRENCY') continue;
|
||||||
const type = ['BOND', 'STOCK'].includes(position.type)
|
const type = ['BOND', 'STOCK'].includes(position.type)
|
||||||
? position.type
|
? position.type
|
||||||
: 'OTHER';
|
: 'OTHER';
|
||||||
|
|||||||
@@ -126,7 +126,8 @@ function totalsByCurrency(
|
|||||||
for (const value of money) addTotal(value.currency, decimal(value));
|
for (const value of money) addTotal(value.currency, decimal(value));
|
||||||
for (const position of positions) {
|
for (const position of positions) {
|
||||||
const price = position.currentPrice;
|
const price = position.currentPrice;
|
||||||
if (!price) continue;
|
// CURRENCY positions mirror the cash balances already added above.
|
||||||
|
if (!price || types[position.instrumentType?.toLowerCase() ?? ''] === 'CURRENCY') continue;
|
||||||
addTotal(
|
addTotal(
|
||||||
price.currency,
|
price.currency,
|
||||||
multiply(decimal(position.quantity ?? {}), decimal(price)),
|
multiply(decimal(position.quantity ?? {}), decimal(price)),
|
||||||
|
|||||||
Reference in New Issue
Block a user