From 6d2df6a12bb802956d3290516f0d4594f8b1c3fb Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 19 Jun 2026 06:13:23 +0300 Subject: [PATCH] fix: stabilize broker display models --- .../src/pages/broker/brokerAllocation.test.ts | 16 ++++++++++++++++ .../src/pages/broker/brokerAllocation.ts | 12 +++++++++++- .../src/pages/broker/brokerDisplay.test.ts | 5 +++++ apps/frontend/src/pages/broker/brokerDisplay.ts | 10 +++++++--- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/pages/broker/brokerAllocation.test.ts b/apps/frontend/src/pages/broker/brokerAllocation.test.ts index a8d3f26..6fb9165 100644 --- a/apps/frontend/src/pages/broker/brokerAllocation.test.ts +++ b/apps/frontend/src/pages/broker/brokerAllocation.test.ts @@ -95,6 +95,22 @@ describe('buildBrokerAllocation', () => { ]); }); + it('ignores a tiny negative residual caused by decimal arithmetic', () => { + const result = buildBrokerAllocation(portfolio({ shares: 0.1, bonds: 0.2, portfolio: 0.3 })); + + expect(result.sectors.map(({ key }) => key)).toEqual(['shares', 'bonds']); + expect(result.negative).toEqual([]); + }); + + it('ignores a tiny positive residual caused by decimal arithmetic', () => { + const result = buildBrokerAllocation( + portfolio({ shares: 0.3, portfolio: 0.30000000000000004 }), + ); + + expect(result.sectors.map(({ key }) => key)).toEqual(['shares']); + expect(result.negative).toEqual([]); + }); + it('returns no allocation for missing or nonpositive portfolio totals', () => { expect(buildBrokerAllocation(portfolio({ shares: 100, portfolio: null }))).toEqual({ total: 0, diff --git a/apps/frontend/src/pages/broker/brokerAllocation.ts b/apps/frontend/src/pages/broker/brokerAllocation.ts index 4037b31..20c29f6 100644 --- a/apps/frontend/src/pages/broker/brokerAllocation.ts +++ b/apps/frontend/src/pages/broker/brokerAllocation.ts @@ -32,12 +32,22 @@ export function buildBrokerAllocation(portfolio: BrokerPortfolio): { const bonds = portfolio.totals.bonds?.value ?? 0; const etf = portfolio.totals.etf?.value ?? 0; const cash = portfolio.totals.currencies?.value ?? 0; + const mappedTotal = shares + bonds + etf + cash; + const residual = total - mappedTotal; + const residualTolerance = + Number.EPSILON * + Math.max( + 1, + Math.abs(total), + Math.abs(shares) + Math.abs(bonds) + Math.abs(etf) + Math.abs(cash), + ) * + 8; const values: Record = { shares, bonds, etf, cash, - other: total - shares - bonds - etf - cash, + other: Math.abs(residual) <= residualTolerance ? 0 : residual, }; const sectors: BrokerAllocationItem[] = []; diff --git a/apps/frontend/src/pages/broker/brokerDisplay.test.ts b/apps/frontend/src/pages/broker/brokerDisplay.test.ts index bea9079..0d7b808 100644 --- a/apps/frontend/src/pages/broker/brokerDisplay.test.ts +++ b/apps/frontend/src/pages/broker/brokerDisplay.test.ts @@ -138,6 +138,11 @@ describe('broker display helpers', () => { expect(labels).toEqual([...labels].sort((left, right) => left.localeCompare(right, 'ru'))); }); + it('keeps operation type options immutable at runtime', () => { + expect(Object.isFrozen(BROKER_OPERATION_TYPE_OPTIONS)).toBe(true); + expect(BROKER_OPERATION_TYPE_OPTIONS.every((option) => Object.isFrozen(option))).toBe(true); + }); + it('validates only exact known operation type values', () => { expect(isBrokerOperationType('OPERATION_TYPE_COUPON')).toBe(true); expect(isBrokerOperationType('operation_type_coupon')).toBe(false); diff --git a/apps/frontend/src/pages/broker/brokerDisplay.ts b/apps/frontend/src/pages/broker/brokerDisplay.ts index 47cb7e5..aad7c7a 100644 --- a/apps/frontend/src/pages/broker/brokerDisplay.ts +++ b/apps/frontend/src/pages/broker/brokerDisplay.ts @@ -94,9 +94,13 @@ 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')); +export const BROKER_OPERATION_TYPE_OPTIONS: ReadonlyArray< + Readonly<{ value: string; label: string }> +> = Object.freeze( + Object.entries(OPERATION_TYPE_LABELS) + .map(([value, label]) => Object.freeze({ value, label })) + .sort((left, right) => left.label.localeCompare(right.label, 'ru')), +); const BROKER_OPERATION_TYPES = new Set(BROKER_OPERATION_TYPE_OPTIONS.map(({ value }) => value));