feat(portfolio): expose asset allocation by currency
This commit is contained in:
@@ -107,6 +107,19 @@ describe('All accounts', () => {
|
|||||||
money: [{ currency: id === 'a' ? 'RUB' : 'USD', nano: 200000000 }],
|
money: [{ currency: id === 'a' ? 'RUB' : 'USD', nano: 200000000 }],
|
||||||
}));
|
}));
|
||||||
const result = await portfolio.getAll('user');
|
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([
|
expect(result.totalsByCurrency).toEqual([
|
||||||
{ currency: 'RUB', value: '0.4' },
|
{ currency: 'RUB', value: '0.4' },
|
||||||
{ currency: 'USD', value: '0.2' },
|
{ currency: 'USD', value: '0.2' },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -56,6 +56,58 @@ export class PortfolioPositionDto {
|
|||||||
logoName: string | null;
|
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 {
|
export class PortfolioDto {
|
||||||
@ApiProperty({ format: 'date-time' })
|
@ApiProperty({ format: 'date-time' })
|
||||||
lastSuccessfulAt: string;
|
lastSuccessfulAt: string;
|
||||||
@@ -71,4 +123,7 @@ export class PortfolioDto {
|
|||||||
|
|
||||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||||
cashBalances: MoneyDto[];
|
cashBalances: MoneyDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: PortfolioStructureDto })
|
||||||
|
structure: PortfolioStructureDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
subtract,
|
subtract,
|
||||||
} from './decimal';
|
} from './decimal';
|
||||||
import { totalsByCurrency, sumMoneyByCurrency } from './portfolio-totals';
|
import { totalsByCurrency, sumMoneyByCurrency } from './portfolio-totals';
|
||||||
|
import { portfolioStructure } from './portfolio-structure';
|
||||||
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
@@ -79,6 +80,10 @@ export class PortfolioService {
|
|||||||
portfolios.flatMap((portfolio) => portfolio.cashBalances),
|
portfolios.flatMap((portfolio) => portfolio.cashBalances),
|
||||||
),
|
),
|
||||||
positions: portfolios.flatMap((portfolio) => portfolio.positions),
|
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,
|
currency,
|
||||||
value: formatDecimal(value),
|
value: formatDecimal(value),
|
||||||
}));
|
}));
|
||||||
|
const normalizedPositions = positions.filter(
|
||||||
|
(position): position is PortfolioPositionDto => position !== null,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
lastSuccessfulAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
lastSuccessfulAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||||
isStale: false,
|
isStale: false,
|
||||||
totalsByCurrency: totalsByCurrency(upstream, money),
|
totalsByCurrency: totalsByCurrency(upstream, money),
|
||||||
positions: positions.filter(
|
positions: normalizedPositions,
|
||||||
(position): position is PortfolioPositionDto => position !== null,
|
|
||||||
),
|
|
||||||
cashBalances,
|
cashBalances,
|
||||||
|
structure: portfolioStructure(normalizedPositions, cashBalances),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import type { Server } from 'node:http';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
import { configureApp } from '../src/main';
|
||||||
|
import { TbankAccessService } from '../src/tbank/tbank-access.service';
|
||||||
|
import { TbankClient } from '../src/tbank/tbank.client';
|
||||||
|
import { PortfolioService } from '../src/tbank/portfolio.service';
|
||||||
|
|
||||||
|
describe('Portfolio structure HTTP contract', () => {
|
||||||
|
let app: INestApplication<Server>;
|
||||||
|
const context = {
|
||||||
|
token: 'fixture',
|
||||||
|
generation: 'fixture',
|
||||||
|
accounts: [
|
||||||
|
{ id: 'a', name: 'A' },
|
||||||
|
{ id: 'b', name: 'B' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const tbank = {
|
||||||
|
getPortfolio: jest.fn(),
|
||||||
|
getPositions: jest.fn(),
|
||||||
|
getInstrumentByFigi: jest.fn().mockResolvedValue(null),
|
||||||
|
getInstrumentByUid: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
const access = {
|
||||||
|
withConnection: (_user: string, action: (value: unknown) => unknown) =>
|
||||||
|
action(context),
|
||||||
|
withSupportedAccounts: (
|
||||||
|
_user: string,
|
||||||
|
action: (value: unknown) => unknown,
|
||||||
|
) => action(context),
|
||||||
|
withAccountForConnection: (
|
||||||
|
_context: unknown,
|
||||||
|
_id: string,
|
||||||
|
action: () => unknown,
|
||||||
|
) => action(),
|
||||||
|
};
|
||||||
|
const token = new JwtService().sign(
|
||||||
|
{ sub: 'structure-user', type: 'access' },
|
||||||
|
{ secret: process.env.JWT_SECRET },
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||||
|
.overrideProvider(TbankAccessService)
|
||||||
|
.useValue(access)
|
||||||
|
.overrideProvider(TbankClient)
|
||||||
|
.useValue(tbank)
|
||||||
|
.compile();
|
||||||
|
app = module.createNestApplication();
|
||||||
|
configureApp(app);
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
beforeEach(() => {
|
||||||
|
app.get(PortfolioService).invalidate('structure-user');
|
||||||
|
tbank.getPortfolio.mockResolvedValue({
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
instrumentType: 'share',
|
||||||
|
quantity: { units: 2 },
|
||||||
|
currentPrice: { currency: 'RUB', units: 30 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
tbank.getPositions.mockResolvedValue({
|
||||||
|
money: [{ currency: 'RUB', units: 40 }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterAll(() => app.close());
|
||||||
|
|
||||||
|
it.each(['a', 'all'])(
|
||||||
|
'serializes exact structure including cash for %s',
|
||||||
|
async (id) => {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get(`/api/v1/brokerage-accounts/${id}/portfolio`)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
structure: {
|
||||||
|
unvaluedCategories: [],
|
||||||
|
currencies: [
|
||||||
|
{
|
||||||
|
currency: 'RUB',
|
||||||
|
totalValue: id === 'all' ? '200' : '100',
|
||||||
|
hasNegativeValues: false,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
type: 'STOCK',
|
||||||
|
value: id === 'all' ? '120' : '60',
|
||||||
|
sharePercent: '60',
|
||||||
|
},
|
||||||
|
{ type: 'BOND', value: '0', sharePercent: '0' },
|
||||||
|
{ type: 'OTHER', value: '0', sharePercent: '0' },
|
||||||
|
{
|
||||||
|
type: 'CASH',
|
||||||
|
value: id === 'all' ? '80' : '40',
|
||||||
|
sharePercent: '40',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('retains unknown valuations on the wire', async () => {
|
||||||
|
tbank.getPortfolio.mockResolvedValue({
|
||||||
|
positions: [{ instrumentType: 'bond', quantity: { units: 1 } }],
|
||||||
|
});
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/brokerage-accounts/a/portfolio')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
structure: {
|
||||||
|
unvaluedCategories: [{ type: 'BOND', count: 1 }],
|
||||||
|
currencies: [
|
||||||
|
{
|
||||||
|
categories: [
|
||||||
|
{ sharePercent: null },
|
||||||
|
{ sharePercent: null },
|
||||||
|
{ sharePercent: null },
|
||||||
|
{ value: '40', sharePercent: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a negative cash constituent visible to aggregate eligibility after netting', async () => {
|
||||||
|
tbank.getPositions.mockImplementation((_token: string, id: string) => ({
|
||||||
|
money: [{ currency: 'RUB', units: id === 'a' ? -10 : 40 }],
|
||||||
|
}));
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.get('/api/v1/brokerage-accounts/all/portfolio')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(response.body).toMatchObject({
|
||||||
|
structure: {
|
||||||
|
currencies: [
|
||||||
|
{
|
||||||
|
hasNegativeValues: true,
|
||||||
|
totalValue: '150',
|
||||||
|
categories: [
|
||||||
|
{ value: '120', sharePercent: null },
|
||||||
|
{ sharePercent: null },
|
||||||
|
{ sharePercent: null },
|
||||||
|
{ value: '30', sharePercent: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user