From 2462f2122c0fe589df8c5174aad9ce2f650b620a Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 19 Jun 2026 06:08:17 +0300 Subject: [PATCH] feat: add broker account display models --- .../src/pages/broker/brokerAllocation.test.ts | 115 ++++++++++++++++++ .../src/pages/broker/brokerAllocation.ts | 57 +++++++++ .../src/pages/broker/brokerDisplay.test.ts | 29 +++++ .../src/pages/broker/brokerDisplay.ts | 10 ++ 4 files changed, 211 insertions(+) create mode 100644 apps/frontend/src/pages/broker/brokerAllocation.test.ts create mode 100644 apps/frontend/src/pages/broker/brokerAllocation.ts diff --git a/apps/frontend/src/pages/broker/brokerAllocation.test.ts b/apps/frontend/src/pages/broker/brokerAllocation.test.ts new file mode 100644 index 0000000..a8d3f26 --- /dev/null +++ b/apps/frontend/src/pages/broker/brokerAllocation.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import type { BrokerMoney, BrokerPortfolio } from '../../api/responses'; +import { buildBrokerAllocation } from './brokerAllocation'; + +function money(value: number): BrokerMoney { + return { + currency: 'RUB', + units: String(Math.trunc(value)), + nano: 0, + value, + }; +} + +function portfolio( + values: Partial>, +): BrokerPortfolio { + const total = (key: keyof typeof values): BrokerMoney | null => { + const value = values[key]; + return value == null ? null : money(value); + }; + + return { + account: { + id: 'acc-1', + type: 'brokerage', + name: 'Основной', + status: 'open', + openedAt: '2024-01-01T00:00:00.000Z', + accessLevel: 'full_access', + }, + positionCounts: { + shares: 1, + bonds: 1, + etf: 1, + other: 0, + }, + totals: { + shares: total('shares'), + bonds: total('bonds'), + etf: total('etf'), + currencies: total('currencies'), + futures: null, + options: null, + structuredProducts: null, + dfa: null, + portfolio: total('portfolio'), + }, + yields: { + expectedPercent: null, + daily: null, + dailyPercent: null, + }, + cash: [], + blockedCash: [], + asOf: '2025-01-01T00:00:00.000Z', + }; +} + +describe('buildBrokerAllocation', () => { + it('builds allocation sectors in display order', () => { + expect( + buildBrokerAllocation( + portfolio({ shares: 400, bonds: 300, etf: 100, currencies: 150, portfolio: 1000 }), + ), + ).toEqual({ + total: 1000, + sectors: [ + { key: 'shares', label: 'Акции', value: 400, percent: 40, color: '#4969f5' }, + { key: 'bonds', label: 'Облигации', value: 300, percent: 30, color: '#e5a33c' }, + { key: 'etf', label: 'ETF/фонды', value: 100, percent: 10, color: '#62b889' }, + { key: 'cash', label: 'Деньги', value: 150, percent: 15, color: '#7b63cf' }, + { key: 'other', label: 'Прочие', value: 50, percent: 5, color: '#aeb6c5' }, + ], + negative: [], + }); + }); + + it('omits zero-value sectors', () => { + const result = buildBrokerAllocation( + portfolio({ shares: 600, bonds: 0, etf: null, currencies: 400, portfolio: 1000 }), + ); + + expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'cash']); + expect(result.negative).toEqual([]); + }); + + it('reports a negative residual outside the sectors', () => { + const result = buildBrokerAllocation( + portfolio({ shares: 700, bonds: 300, etf: 100, currencies: 50, portfolio: 1000 }), + ); + + expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds', 'etf', 'cash']); + expect(result.negative).toEqual([ + { key: 'other', label: 'Прочие', value: -150, color: '#aeb6c5' }, + ]); + }); + + it('returns no allocation for missing or nonpositive portfolio totals', () => { + expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: null }))).toEqual({ + total: 0, + sectors: [], + negative: [], + }); + expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: 0 }))).toEqual({ + total: 0, + sectors: [], + negative: [], + }); + expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: -10 }))).toEqual({ + total: -10, + sectors: [], + negative: [], + }); + }); +}); diff --git a/apps/frontend/src/pages/broker/brokerAllocation.ts b/apps/frontend/src/pages/broker/brokerAllocation.ts new file mode 100644 index 0000000..4037b31 --- /dev/null +++ b/apps/frontend/src/pages/broker/brokerAllocation.ts @@ -0,0 +1,57 @@ +import type { BrokerPortfolio } from '../../api/responses'; + +export type BrokerAllocationKey = 'shares' | 'bonds' | 'etf' | 'cash' | 'other'; + +export interface BrokerAllocationItem { + key: BrokerAllocationKey; + label: string; + value: number; + percent: number; + color: string; +} + +type BrokerNegativeAllocationItem = Omit; + +const ALLOCATION_CONFIG: Array> = [ + { key: 'shares', label: 'Акции', color: '#4969f5' }, + { key: 'bonds', label: 'Облигации', color: '#e5a33c' }, + { key: 'etf', label: 'ETF/фонды', color: '#62b889' }, + { key: 'cash', label: 'Деньги', color: '#7b63cf' }, + { key: 'other', label: 'Прочие', color: '#aeb6c5' }, +]; + +export function buildBrokerAllocation(portfolio: BrokerPortfolio): { + total: number; + sectors: BrokerAllocationItem[]; + negative: BrokerNegativeAllocationItem[]; +} { + const total = portfolio.totals.portfolio?.value ?? 0; + if (total <= 0) return { total, sectors: [], negative: [] }; + + const shares = portfolio.totals.shares?.value ?? 0; + const bonds = portfolio.totals.bonds?.value ?? 0; + const etf = portfolio.totals.etf?.value ?? 0; + const cash = portfolio.totals.currencies?.value ?? 0; + const values: Record = { + shares, + bonds, + etf, + cash, + other: total - shares - bonds - etf - cash, + }; + + const sectors: BrokerAllocationItem[] = []; + const negative: BrokerNegativeAllocationItem[] = []; + + for (const item of ALLOCATION_CONFIG) { + const value = values[item.key]; + + if (value > 0) { + sectors.push({ ...item, value, percent: (value / total) * 100 }); + } else if (value < 0) { + negative.push({ ...item, value }); + } + } + + return { total, sectors, negative }; +} diff --git a/apps/frontend/src/pages/broker/brokerDisplay.test.ts b/apps/frontend/src/pages/broker/brokerDisplay.test.ts index 8713790..bea9079 100644 --- a/apps/frontend/src/pages/broker/brokerDisplay.test.ts +++ b/apps/frontend/src/pages/broker/brokerDisplay.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest'; import type { BrokerOperation, BrokerPosition } from '../../api/responses'; import { + BROKER_OPERATION_TYPE_OPTIONS, getBrokerInstrumentPath, getBrokerOperationImpact, getBrokerOperationTypeLabel, getBrokerPositionGroup, + isBrokerOperationType, } from './brokerDisplay'; function position(input: Partial): BrokerPosition { @@ -37,6 +39,7 @@ function operation(input: Partial): BrokerOperation { type: 'OPERATION_TYPE_UNSPECIFIED', category: 'other', description: null, + name: null, state: null, instrumentUid: null, figi: null, @@ -116,6 +119,32 @@ describe('broker display helpers', () => { ).toBe('Custom'); }); + it('exposes independently selectable known operation types', () => { + expect(BROKER_OPERATION_TYPE_OPTIONS).toEqual( + expect.arrayContaining([ + { value: 'OPERATION_TYPE_COUPON', label: 'Выплата купона' }, + { value: 'OPERATION_TYPE_TAX', label: 'Налог' }, + { value: 'OPERATION_TYPE_BOND_TAX', label: 'Налог по облигациям' }, + { value: 'OPERATION_TYPE_DIVIDEND_TAX', label: 'Налог на дивиденды' }, + ]), + ); + }); + + it('keeps operation type option values unique and labels in Russian order', () => { + const values = BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value); + const labels = BROKER_OPERATION_TYPE_OPTIONS.map(({ label }) => label); + + expect(new Set(values).size).toBe(values.length); + expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru'))); + }); + + it('validates only exact known operation type values', () => { + expect(isBrokerOperationType('OPERATION_TYPE_COUPON')).toBe(true); + expect(isBrokerOperationType('operation_type_coupon')).toBe(false); + expect(isBrokerOperationType('OPERATION_TYPE_UNKNOWN')).toBe(false); + expect(isBrokerOperationType(null)).toBe(false); + }); + it('classifies operations by portfolio impact', () => { expect( getBrokerOperationImpact( diff --git a/apps/frontend/src/pages/broker/brokerDisplay.ts b/apps/frontend/src/pages/broker/brokerDisplay.ts index e3191e9..47cb7e5 100644 --- a/apps/frontend/src/pages/broker/brokerDisplay.ts +++ b/apps/frontend/src/pages/broker/brokerDisplay.ts @@ -94,6 +94,16 @@ const OPERATION_TYPE_LABELS: Record = { OPERATION_TYPE_OUTPUT_SECURITIES: 'Списание бумаг', }; +export const BROKER_OPERATION_TYPE_OPTIONS = Object.entries(OPERATION_TYPE_LABELS) + .map(([value, label]) => ({ value, label })) + .sort((left, right) => left.label.localeCompare(right.label, 'ru')); + +const BROKER_OPERATION_TYPES = new Set(BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value)); + +export function isBrokerOperationType(value: string | null): value is string { + return value !== null && BROKER_OPERATION_TYPES.has(value); +} + export function getBrokerPositionGroup( position: Pick, ): BrokerPositionGroup {