fix incorrect calculations of median and quartiles in some cases, and fix incorrect top 5 amount share calculation
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
export function mean<T>(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<T>(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<T>(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<T>(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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+16
-12
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user