diff --git a/src/tbank/all-accounts.spec.ts b/src/tbank/all-accounts.spec.ts index 26ef97c..067f229 100644 --- a/src/tbank/all-accounts.spec.ts +++ b/src/tbank/all-accounts.spec.ts @@ -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' }, diff --git a/src/tbank/portfolio-structure.spec.ts b/src/tbank/portfolio-structure.spec.ts new file mode 100644 index 0000000..0c19bc2 --- /dev/null +++ b/src/tbank/portfolio-structure.spec.ts @@ -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); + }, + ); +}); diff --git a/src/tbank/portfolio-structure.ts b/src/tbank/portfolio-structure.ts new file mode 100644 index 0000000..486f544 --- /dev/null +++ b/src/tbank/portfolio-structure.ts @@ -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[]; + hasNegativeValues: boolean; + } + >(); + const unvalued = new Map(); + 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, + }; + }), + }; +} diff --git a/src/tbank/portfolio.dto.ts b/src/tbank/portfolio.dto.ts index 2f6f10b..9062104 100644 --- a/src/tbank/portfolio.dto.ts +++ b/src/tbank/portfolio.dto.ts @@ -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; } diff --git a/src/tbank/portfolio.service.ts b/src/tbank/portfolio.service.ts index e5b5e1e..43c9e6b 100644 --- a/src/tbank/portfolio.service.ts +++ b/src/tbank/portfolio.service.ts @@ -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), }; } diff --git a/test/portfolio-structure.e2e-spec.ts b/test/portfolio-structure.e2e-spec.ts new file mode 100644 index 0000000..dce742d --- /dev/null +++ b/test/portfolio-structure.e2e-spec.ts @@ -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; + 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 }, + ], + }, + ], + }, + }); + }); +});