feat(portfolio): expose asset allocation by currency

This commit is contained in:
2026-09-09 21:30:11 +03:00
parent eaf2f67b34
commit 05747dc375
6 changed files with 430 additions and 3 deletions
+13
View File
@@ -107,6 +107,19 @@ describe('All accounts', () => {
money: [{ currency: id === 'a' ? 'RUB' : 'USD', nano: 200000000 }],
}));
const result = await portfolio.getAll('user');
expect(result.structure.currencies).toMatchObject([
{
currency: 'RUB',
totalValue: '0.4',
categories: [
{ type: 'STOCK', value: '0.2', sharePercent: '50' },
{ type: 'BOND', value: '0' },
{ type: 'OTHER', value: '0' },
{ type: 'CASH', value: '0.2', sharePercent: '50' },
],
},
{ currency: 'USD', totalValue: '0.2' },
]);
expect(result.totalsByCurrency).toEqual([
{ currency: 'RUB', value: '0.4' },
{ currency: 'USD', value: '0.2' },
+113
View File
@@ -0,0 +1,113 @@
import { portfolioStructure } from './portfolio-structure';
import { PortfolioPositionDto } from './portfolio.dto';
const position = (
type: string,
value: string | null,
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('includes cash once and keeps currency instruments among other assets', () => {
const result = portfolioStructure(
[
position('STOCK', '60'),
position('BOND', '20'),
position('CURRENCY', '10'),
],
[{ currency: 'RUB', value: '10' }],
);
expect(result.currencies[0]).toEqual({
currency: 'RUB',
totalValue: '100',
hasNegativeValues: false,
categories: [
{ type: 'STOCK', value: '60', sharePercent: '60' },
{ type: 'BOND', value: '20', sharePercent: '20' },
{ type: 'OTHER', value: '10', sharePercent: '10' },
{ type: 'CASH', value: '10', sharePercent: '10' },
],
});
});
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);
},
);
});
+79
View File
@@ -0,0 +1,79 @@
import { add, decimalFromString, formatDecimal } from './decimal';
import {
MoneyDto,
PortfolioPositionDto,
PortfolioStructureDto,
PortfolioStructureCategoryDto,
} from './portfolio.dto';
const categoryTypes = ['STOCK', 'BOND', 'OTHER', 'CASH'] as const;
// Build from positions and cash, never from totals (which already include cash).
export function portfolioStructure(
positions: PortfolioPositionDto[],
cash: MoneyDto[],
): PortfolioStructureDto {
const currencies = new Map<
string,
{
values: ReturnType<typeof decimalFromString>[];
hasNegativeValues: boolean;
}
>();
const unvalued = new Map<string, number>();
const include = (type: string, money: MoneyDto) => {
const group = currencies.get(money.currency) ?? {
values: categoryTypes.map(() => decimalFromString('0')),
hasNegativeValues: false,
};
const index = categoryTypes.findIndex((category) => category === type);
const value = decimalFromString(money.value);
group.values[index] = add(group.values[index], value);
group.hasNegativeValues ||= value.value < 0n;
currencies.set(money.currency, group);
};
for (const position of positions) {
const type = ['STOCK', 'BOND'].includes(position.type)
? position.type
: 'OTHER';
if (position.currentValue) include(type, position.currentValue);
else unvalued.set(type, (unvalued.get(type) ?? 0) + 1);
}
for (const amount of cash) include('CASH', amount);
return {
unvaluedCategories: [...unvalued].map(([type, count]) => ({ type, count })),
currencies: [...currencies]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, group]) => {
const total = group.values.reduce(add, decimalFromString('0'));
const canCalculateShares =
!unvalued.size && !group.hasNegativeValues && total.value > 0n;
const categories: PortfolioStructureCategoryDto[] = group.values.map(
(value, index) => ({
type: categoryTypes[index],
value: formatDecimal(value),
// Rounded to six decimal places using integer arithmetic. Only the
// resulting dimensionless percentage is passed to chart geometry.
sharePercent: canCalculateShares
? formatDecimal({
value:
(value.value *
10n ** BigInt(total.scale - value.scale) *
100_000_000n +
total.value / 2n) /
total.value,
scale: 6,
})
: null,
}),
);
return {
currency,
totalValue: formatDecimal(total),
hasNegativeValues: group.hasNegativeValues,
categories,
};
}),
};
}
+55
View File
@@ -56,6 +56,58 @@ export class PortfolioPositionDto {
logoName: string | null;
}
export class PortfolioStructureCategoryDto {
@ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER', 'CASH'] })
type: string;
@ApiProperty({ description: 'Exact known value as a decimal string.' })
value: string;
@ApiProperty({
type: String,
nullable: true,
description:
'Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions.',
})
sharePercent: string | null;
}
export class PortfolioStructureCurrencyDto {
@ApiProperty()
currency: string;
@ApiProperty()
totalValue: string;
@ApiProperty({
description: 'Any negative constituent, even if netted positive.',
})
hasNegativeValues: boolean;
@ApiProperty({ type: [PortfolioStructureCategoryDto] })
categories: PortfolioStructureCategoryDto[];
}
export class PortfolioUnvaluedCategoryDto {
@ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER'] })
type: string;
@ApiProperty({ minimum: 1 })
count: number;
}
export class PortfolioStructureDto {
@ApiProperty({ type: [PortfolioStructureCurrencyDto] })
currencies: PortfolioStructureCurrencyDto[];
@ApiProperty({
type: [PortfolioUnvaluedCategoryDto],
description:
'Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.',
})
unvaluedCategories: PortfolioUnvaluedCategoryDto[];
}
export class PortfolioDto {
@ApiProperty({ format: 'date-time' })
lastSuccessfulAt: string;
@@ -71,4 +123,7 @@ export class PortfolioDto {
@ApiProperty({ type: MoneyDto, isArray: true })
cashBalances: MoneyDto[];
@ApiProperty({ type: PortfolioStructureDto })
structure: PortfolioStructureDto;
}
+10 -3
View File
@@ -9,6 +9,7 @@ import {
subtract,
} from './decimal';
import { totalsByCurrency, sumMoneyByCurrency } from './portfolio-totals';
import { portfolioStructure } from './portfolio-structure';
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
import {
TbankAccessService,
@@ -79,6 +80,10 @@ export class PortfolioService {
portfolios.flatMap((portfolio) => portfolio.cashBalances),
),
positions: portfolios.flatMap((portfolio) => portfolio.positions),
structure: portfolioStructure(
portfolios.flatMap((portfolio) => portfolio.positions),
portfolios.flatMap((portfolio) => portfolio.cashBalances),
),
};
});
}
@@ -160,14 +165,16 @@ export class PortfolioService {
currency,
value: formatDecimal(value),
}));
const normalizedPositions = positions.filter(
(position): position is PortfolioPositionDto => position !== null,
);
return {
lastSuccessfulAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
isStale: false,
totalsByCurrency: totalsByCurrency(upstream, money),
positions: positions.filter(
(position): position is PortfolioPositionDto => position !== null,
),
positions: normalizedPositions,
cashBalances,
structure: portfolioStructure(normalizedPositions, cashBalances),
};
}