fix incorrect calculations of median and quartiles in some cases, and fix incorrect top 5 amount share calculation

This commit is contained in:
MaysWind
2026-04-14 23:29:20 +08:00
parent c0641b1db5
commit 36529abf08
5 changed files with 278 additions and 29 deletions
@@ -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;
+190
View File
@@ -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);
});
});
});
+61
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}