diff --git a/src/components/base/AccountBalanceTrendsChartBase.ts b/src/components/base/AccountBalanceTrendsChartBase.ts index 30807e5f..631f21d5 100644 --- a/src/components/base/AccountBalanceTrendsChartBase.ts +++ b/src/components/base/AccountBalanceTrendsChartBase.ts @@ -17,7 +17,11 @@ import type { AccountInfoResponse } from '@/models/account.ts'; import type { TransactionReconciliationStatementResponseItem } from '@/models/transaction.ts'; import { isArray } from '@/lib/common.ts'; -import { sumAmounts } from '@/lib/numeral.ts'; +import { + mean, + median, + percentile +} from '@/lib/math.ts'; import { parseDateTimeFromUnixTime, getGregorianCalendarYearAndMonthFromUnixTime, @@ -231,12 +235,12 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren const openingBalance = dataItems[0]!.accountOpeningBalance; const closingBalance = dataItems[dataItems.length - 1]!.accountClosingBalance; - const minimumBalance = Math.min(...dataItems.map(item => item.accountClosingBalance)); - const maximumBalance = Math.max(...dataItems.map(item => item.accountClosingBalance)); - const medianBalance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length / 2)]!.accountClosingBalance; - const averageBalance = Math.trunc(sumAmounts(dataItems.map(item => item.accountClosingBalance)) / dataItems.length); - const q1Balance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length / 4)]!.accountClosingBalance; - const q3Balance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length * 3 / 4)]!.accountClosingBalance; + const minimumBalance = allDataItemsSortedByClosingBalance[0]!.accountClosingBalance; + const maximumBalance = allDataItemsSortedByClosingBalance[allDataItemsSortedByClosingBalance.length - 1]!.accountClosingBalance; + const medianBalance = Math.trunc(median(allDataItemsSortedByClosingBalance, item => item.accountClosingBalance)); + const averageBalance = Math.trunc(mean(dataItems, item => item.accountClosingBalance)); + const q1Balance = Math.trunc(percentile(allDataItemsSortedByClosingBalance, 0.25, item => item.accountClosingBalance)); + const q3Balance = Math.trunc(percentile(allDataItemsSortedByClosingBalance, 0.75, item => item.accountClosingBalance)); if (props.account.isAsset) { lastOpeningBalance = openingBalance; diff --git a/src/lib/__tests__/math.ts b/src/lib/__tests__/math.ts new file mode 100644 index 00000000..2c80b9d4 --- /dev/null +++ b/src/lib/__tests__/math.ts @@ -0,0 +1,190 @@ +import { describe, expect, test } from '@jest/globals'; + +import { mean, median, percentile, sumMaxN } from '@/lib/math.ts'; + +type TestNumberItem = { + value: number; +}; + +function createNumberItems(values: number[]): TestNumberItem[] { + return values.map(value => ({ value })); +} + +function getTestTitle(functionName: string, title: string): string { + return `${functionName}: ${title}`; +} + +type MeanTestCase = { + title: string; + values: number[]; + expected: number; +}; + +const TEST_CASES_MEAN: MeanTestCase[] = [ + { + title: 'returns zero for empty array', + values: [], + expected: 0, + }, + { + title: 'returns the average for positive values', + values: [1, 2, 3, 4], + expected: 2.5, + }, + { + title: 'returns the average for negative and positive values', + values: [-10, 0, 20], + expected: 10 / 3, + }, +]; + +describe('mean', () => { + TEST_CASES_MEAN.forEach((testCase) => { + test(getTestTitle('mean', testCase.title), () => { + const result = mean(createNumberItems(testCase.values), item => item.value); + expect(result).toBeCloseTo(testCase.expected); + }); + }); +}); + +type MedianTestCase = { + title: string; + values: number[]; + expected: number; +}; + +const TEST_CASES_MEDIAN: MedianTestCase[] = [ + { + title: 'returns zero for empty sorted array', + values: [], + expected: 0, + }, + { + title: 'returns the middle value for odd-length sorted array', + values: [1, 3, 5], + expected: 3, + }, + { + title: 'returns the average of the two middle values for even-length sorted array', + values: [1, 3, 5, 7], + expected: 4, + }, +]; + +describe('median', () => { + TEST_CASES_MEDIAN.forEach((testCase) => { + test(getTestTitle('median', testCase.title), () => { + const result = median(createNumberItems(testCase.values), item => item.value); + expect(result).toBeCloseTo(testCase.expected); + }); + }); +}); + +type PercentileTestCase = { + title: string; + values: number[]; + percentileValue: number; + expected: number; +}; + +const TEST_CASES_PERCENTILE: PercentileTestCase[] = [ + { + title: 'returns zero for empty sorted array', + values: [], + percentileValue: 0.5, + expected: 0, + }, + { + title: 'returns zero when percentile is smaller than zero', + values: [1, 2, 3], + percentileValue: -0.1, + expected: 0, + }, + { + title: 'returns zero when percentile is larger than one', + values: [1, 2, 3], + percentileValue: 1.1, + expected: 0, + }, + { + title: 'returns the minimum value for zero percentile', + values: [5, 10, 15, 20], + percentileValue: 0, + expected: 5, + }, + { + title: 'returns the maximum value for one percentile', + values: [5, 10, 15, 20], + percentileValue: 1, + expected: 20, + }, + { + title: 'returns the exact indexed value when percentile maps to an integer index', + values: [10, 20, 30, 40, 50], + percentileValue: 0.25, + expected: 20, + }, + { + title: 'interpolates between neighboring values when percentile maps to a fractional index', + values: [10, 20, 30, 40, 50, 60, 70, 80], + percentileValue: 0.25, + expected: 27.5, + }, +]; + +describe('percentile', () => { + TEST_CASES_PERCENTILE.forEach((testCase) => { + test(getTestTitle('percentile', testCase.title), () => { + const result = percentile( + createNumberItems(testCase.values), + testCase.percentileValue, + item => item.value + ); + + expect(result).toBeCloseTo(testCase.expected); + }); + }); +}); + +type SumMaxNTestCase = { + title: string; + values: number[]; + n: number; + expected: number; +}; + +const TEST_CASES_SUM_MAX_N: SumMaxNTestCase[] = [ + { + title: 'returns zero for empty sorted array', + values: [], + n: 3, + expected: 0, + }, + { + title: 'returns zero when n is zero', + values: [1, 2, 3], + n: 0, + expected: 0, + }, + { + title: 'returns the sum of the largest n values', + values: [1, 2, 3, 4, 5], + n: 2, + expected: 9, + }, + { + title: 'returns the sum of all values when n is larger than array length', + values: [1, 2, 3, 4], + n: 10, + expected: 10, + }, +]; + +describe('sumMaxN', () => { + TEST_CASES_SUM_MAX_N.forEach((testCase) => { + test(getTestTitle('sumMaxN', testCase.title), () => { + const result = sumMaxN(createNumberItems(testCase.values), testCase.n, item => item.value); + expect(result).toBe(testCase.expected); + }); + }); +}); diff --git a/src/lib/math.ts b/src/lib/math.ts new file mode 100644 index 00000000..cef512c2 --- /dev/null +++ b/src/lib/math.ts @@ -0,0 +1,61 @@ +export function mean(values: T[], valueFn: (item: T) => number): number { + if (values.length < 1) { + return 0; + } + + let sum: number = 0; + + for (const item of values) { + sum += valueFn(item); + } + + return sum / values.length; +} + +export function median(sortedValues: T[], valueFn: (item: T) => number): number { + if (sortedValues.length < 1) { + return 0; + } + + const mid: number = Math.floor(sortedValues.length / 2); + + if (sortedValues.length % 2 === 0) { + return (valueFn(sortedValues[mid - 1] as T) + valueFn(sortedValues[mid] as T)) / 2; + } else { + return valueFn(sortedValues[mid] as T); + } +} + +export function percentile(sortedValues: T[], percentile: number, valueFn: (item: T) => number): number { + if (sortedValues.length < 1 || percentile < 0 || percentile > 1) { + return 0; + } + + const index: number = (sortedValues.length - 1) * percentile + 1; + const indexFloor: number = Math.floor(index); + const indexCeil: number = Math.ceil(index); + + if (indexFloor === indexCeil) { + return valueFn(sortedValues[indexFloor - 1] as T); + } else { + const value1: number = valueFn(sortedValues[indexFloor - 1] as T); + const value2: number = valueFn(sortedValues[indexCeil - 1] as T); + return value1 + (index - indexFloor) * (value2 - value1); + } +} + +export function sumMaxN(sortedValues: T[], n: number, valueFn: (item: T) => number): number { + if (sortedValues.length < 1 || n <= 0) { + return 0; + } + + let sum: number = 0; + const count: number = Math.min(n, sortedValues.length); + const startIndex: number = sortedValues.length - count; + + for (let i = sortedValues.length - 1; i >= startIndex; i--) { + sum += valueFn(sortedValues[i] as T); + } + + return sum; +} diff --git a/src/lib/numeral.ts b/src/lib/numeral.ts index e227c6ba..9282d57f 100644 --- a/src/lib/numeral.ts +++ b/src/lib/numeral.ts @@ -10,16 +10,6 @@ import { DEFAULT_DECIMAL_NUMBER_COUNT, MAX_SUPPORTED_DECIMAL_NUMBER_COUNT, DISPL import { isDefined, isString, isNumber, replaceAll, removeAll } from './common.ts'; -export function sumAmounts(amounts: number[]): number { - let sum = 0; - - for (const amount of amounts) { - sum += amount; - } - - return sum; -} - export function appendDigitGroupingSymbolAndDecimalSeparator(textualNumber: string, options: NumberFormatOptions): string { if (!textualNumber) { return textualNumber; diff --git a/src/stores/explorer.ts b/src/stores/explorer.ts index a316feb3..2415da2e 100644 --- a/src/stores/explorer.ts +++ b/src/stores/explorer.ts @@ -43,6 +43,11 @@ import { isInteger, isEquals, } from '@/lib/common.ts'; +import { + median, + percentile, + sumMaxN +} from '@/lib/math.ts'; import { getUtcOffsetByUtcOffsetMinutes, parseDateTimeFromUnixTime, @@ -707,17 +712,16 @@ export const useExplorersStore = defineStore('explorers', () => { if (sourceAmounts.length > 0) { sourceAmounts.sort((a, b) => a - b); - statisticData.medianAmount = sourceAmounts[Math.floor(sourceAmounts.length / 2)] as number; - statisticData.p90Amount = sourceAmounts[Math.floor(sourceAmounts.length * 9 / 10)] as number; + statisticData.medianAmount = Math.trunc(median(sourceAmounts, item => item)); + statisticData.p90Amount = Math.trunc(percentile(sourceAmounts, 0.9, item => item)); - const q1 = sourceAmounts[Math.floor(sourceAmounts.length / 4)] as number; - const q3 = sourceAmounts[Math.floor(sourceAmounts.length * 3 / 4)] as number; - statisticData.interquartileRange = q3 - q1; + const q1 = percentile(sourceAmounts, 0.25, item => item); + const q3 = percentile(sourceAmounts, 0.75, item => item); + statisticData.interquartileRange = Math.trunc(q3 - q1); } if (sourceAmounts.length > 5) { - const top5Count = Math.ceil(sourceAmounts.length * 0.05); - const top5AmountSum = sourceAmounts.slice(-top5Count).reduce((sum, amount) => sum + amount, 0); + const top5AmountSum = sumMaxN(sourceAmounts, 5, item => item); statisticData.top5AmountShare = statisticData.totalAmount > 0 ? 100.0 * top5AmountSum / statisticData.totalAmount : 0; } @@ -866,14 +870,14 @@ export const useExplorersStore = defineStore('explorers', () => { } else if (valueMetric === TransactionExplorerValueMetric.SourceAmountMedian) { if (allSourceAmountsInDefaultCurrency.length > 0) { allSourceAmountsInDefaultCurrency.sort((a, b) => a - b); - value = allSourceAmountsInDefaultCurrency[Math.floor(allSourceAmountsInDefaultCurrency.length / 2)] as number; + value = Math.trunc(median(allSourceAmountsInDefaultCurrency, item => item)); } else { value = 0; } } else if (valueMetric === TransactionExplorerValueMetric.SourceAmount90thPercentile) { if (allSourceAmountsInDefaultCurrency.length > 0) { allSourceAmountsInDefaultCurrency.sort((a, b) => a - b); - value = allSourceAmountsInDefaultCurrency[Math.floor(allSourceAmountsInDefaultCurrency.length * 9 / 10)] as number; + value = Math.trunc(percentile(allSourceAmountsInDefaultCurrency, 0.9, item => item)); } else { value = 0; } @@ -888,9 +892,9 @@ export const useExplorersStore = defineStore('explorers', () => { } else if (valueMetric === TransactionExplorerValueMetric.SourceAmountInterquartileRange) { if (allSourceAmountsInDefaultCurrency.length > 0) { allSourceAmountsInDefaultCurrency.sort((a, b) => a - b); - const q1 = allSourceAmountsInDefaultCurrency[Math.floor(allSourceAmountsInDefaultCurrency.length / 4)] as number; - const q3 = allSourceAmountsInDefaultCurrency[Math.floor(allSourceAmountsInDefaultCurrency.length * 3 / 4)] as number; - value = q3 - q1; + const q1 = Math.trunc(percentile(allSourceAmountsInDefaultCurrency, 0.25, item => item)); + const q3 = Math.trunc(percentile(allSourceAmountsInDefaultCurrency, 0.75, item => item)); + value = Math.trunc(q3 - q1); } else { value = 0; }