Files
backend/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts
T

114 lines
3.5 KiB
TypeScript

import { PortfolioPositionDto } from '../presentation/portfolio.dto';
import { portfolioStructure } from './portfolio-structure';
const position = (
type: string,
value: null | string,
currency = 'RUB',
): PortfolioPositionDto => ({
type,
currentValue: value === null ? null : { currency, value },
quantity: '1',
displayName: 'Fixture',
figi: null,
isin: null,
ticker: null,
logoName: null,
absoluteReturn: null,
averagePurchasePrice: null,
});
describe('portfolio structure', () => {
it('counts cash once and skips CURRENCY positions that mirror it', () => {
const result = portfolioStructure(
[
position('STOCK', '60'),
position('BOND', '20'),
position('CURRENCY', '10'),
],
[{ currency: 'RUB', value: '10' }],
);
expect(result.currencies[0]).toEqual({
currency: 'RUB',
totalValue: '90',
hasNegativeValues: false,
categories: [
{ type: 'STOCK', value: '60', sharePercent: '66.666667' },
{ type: 'BOND', value: '20', sharePercent: '22.222222' },
{ type: 'OTHER', value: '0', sharePercent: '0' },
{ type: 'CASH', value: '10', sharePercent: '11.111111' },
],
});
});
it('sums beyond Number precision and separates currencies without FX', () => {
const result = portfolioStructure(
[
position('STOCK', '9007199254740993.000000001'),
position('STOCK', '0.000000009'),
position('BOND', '2', 'USD'),
],
[{ currency: 'USD', value: '1' }],
);
expect(result.currencies[0].totalValue).toBe('9007199254740993.00000001');
expect(result.currencies[1].totalValue).toBe('3');
expect(result.currencies[1].categories[1].sharePercent).toBe('66.666667');
expect(result.currencies[1].categories[3].sharePercent).toBe('33.333333');
});
it('represents cash-only, empty and zero-valued portfolios', () => {
expect(portfolioStructure([], []).currencies).toEqual([]);
expect(
portfolioStructure([], [{ currency: 'EUR', value: '10' }]).currencies[0]
.categories[3].sharePercent,
).toBe('100');
expect(
portfolioStructure(
[position('STOCK', '0')],
[],
).currencies[0].categories.every((item) => item.sharePercent === null),
).toBe(true);
});
it('retains unvalued categories without assigning zero or guessing a currency', () => {
const result = portfolioStructure(
[position('BOND', null), position('BOND', null), position('STOCK', '50')],
[],
);
expect(result.unvaluedCategories).toEqual([{ type: 'BOND', count: 2 }]);
expect(result.currencies[0].totalValue).toBe('50');
expect(
result.currencies[0].categories.every(
(item) => item.sharePercent === null,
),
).toBe(true);
expect(
portfolioStructure([position('OTHER', null)], []).currencies,
).toEqual([]);
});
it.each([
[[position('STOCK', '-20'), position('STOCK', '100')], []],
[
[],
[
{ currency: 'RUB', value: '-20' },
{ currency: 'RUB', value: '100' },
],
],
[[position('BOND', '-20')], []],
[[position('BOND', '-20'), position('BOND', '20')], []],
])(
'preserves negative constituents even when their category nets positive or zero',
(positions, cash) => {
const result = portfolioStructure(positions, cash);
expect(result.currencies[0].hasNegativeValues).toBe(true);
expect(
result.currencies[0].categories.every(
(item) => item.sharePercent === null,
),
).toBe(true);
},
);
});