mirror of
https://github.com/mayswind/ezbookkeeping.git
synced 2026-05-18 08:44:25 +08:00
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 type { TransactionReconciliationStatementResponseItem } from '@/models/transaction.ts';
|
||||||
|
|
||||||
import { isArray } from '@/lib/common.ts';
|
import { isArray } from '@/lib/common.ts';
|
||||||
import { sumAmounts } from '@/lib/numeral.ts';
|
import {
|
||||||
|
mean,
|
||||||
|
median,
|
||||||
|
percentile
|
||||||
|
} from '@/lib/math.ts';
|
||||||
import {
|
import {
|
||||||
parseDateTimeFromUnixTime,
|
parseDateTimeFromUnixTime,
|
||||||
getGregorianCalendarYearAndMonthFromUnixTime,
|
getGregorianCalendarYearAndMonthFromUnixTime,
|
||||||
@@ -231,12 +235,12 @@ export function useAccountBalanceTrendsChartBase(props: CommonAccountBalanceTren
|
|||||||
|
|
||||||
const openingBalance = dataItems[0]!.accountOpeningBalance;
|
const openingBalance = dataItems[0]!.accountOpeningBalance;
|
||||||
const closingBalance = dataItems[dataItems.length - 1]!.accountClosingBalance;
|
const closingBalance = dataItems[dataItems.length - 1]!.accountClosingBalance;
|
||||||
const minimumBalance = Math.min(...dataItems.map(item => item.accountClosingBalance));
|
const minimumBalance = allDataItemsSortedByClosingBalance[0]!.accountClosingBalance;
|
||||||
const maximumBalance = Math.max(...dataItems.map(item => item.accountClosingBalance));
|
const maximumBalance = allDataItemsSortedByClosingBalance[allDataItemsSortedByClosingBalance.length - 1]!.accountClosingBalance;
|
||||||
const medianBalance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length / 2)]!.accountClosingBalance;
|
const medianBalance = Math.trunc(median(allDataItemsSortedByClosingBalance, item => item.accountClosingBalance));
|
||||||
const averageBalance = Math.trunc(sumAmounts(dataItems.map(item => item.accountClosingBalance)) / dataItems.length);
|
const averageBalance = Math.trunc(mean(dataItems, item => item.accountClosingBalance));
|
||||||
const q1Balance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length / 4)]!.accountClosingBalance;
|
const q1Balance = Math.trunc(percentile(allDataItemsSortedByClosingBalance, 0.25, item => item.accountClosingBalance));
|
||||||
const q3Balance = allDataItemsSortedByClosingBalance[Math.floor(allDataItemsSortedByClosingBalance.length * 3 / 4)]!.accountClosingBalance;
|
const q3Balance = Math.trunc(percentile(allDataItemsSortedByClosingBalance, 0.75, item => item.accountClosingBalance));
|
||||||
|
|
||||||
if (props.account.isAsset) {
|
if (props.account.isAsset) {
|
||||||
lastOpeningBalance = openingBalance;
|
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';
|
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 {
|
export function appendDigitGroupingSymbolAndDecimalSeparator(textualNumber: string, options: NumberFormatOptions): string {
|
||||||
if (!textualNumber) {
|
if (!textualNumber) {
|
||||||
return textualNumber;
|
return textualNumber;
|
||||||
|
|||||||
+16
-12
@@ -43,6 +43,11 @@ import {
|
|||||||
isInteger,
|
isInteger,
|
||||||
isEquals,
|
isEquals,
|
||||||
} from '@/lib/common.ts';
|
} from '@/lib/common.ts';
|
||||||
|
import {
|
||||||
|
median,
|
||||||
|
percentile,
|
||||||
|
sumMaxN
|
||||||
|
} from '@/lib/math.ts';
|
||||||
import {
|
import {
|
||||||
getUtcOffsetByUtcOffsetMinutes,
|
getUtcOffsetByUtcOffsetMinutes,
|
||||||
parseDateTimeFromUnixTime,
|
parseDateTimeFromUnixTime,
|
||||||
@@ -707,17 +712,16 @@ export const useExplorersStore = defineStore('explorers', () => {
|
|||||||
|
|
||||||
if (sourceAmounts.length > 0) {
|
if (sourceAmounts.length > 0) {
|
||||||
sourceAmounts.sort((a, b) => a - b);
|
sourceAmounts.sort((a, b) => a - b);
|
||||||
statisticData.medianAmount = sourceAmounts[Math.floor(sourceAmounts.length / 2)] as number;
|
statisticData.medianAmount = Math.trunc(median(sourceAmounts, item => item));
|
||||||
statisticData.p90Amount = sourceAmounts[Math.floor(sourceAmounts.length * 9 / 10)] as number;
|
statisticData.p90Amount = Math.trunc(percentile(sourceAmounts, 0.9, item => item));
|
||||||
|
|
||||||
const q1 = sourceAmounts[Math.floor(sourceAmounts.length / 4)] as number;
|
const q1 = percentile(sourceAmounts, 0.25, item => item);
|
||||||
const q3 = sourceAmounts[Math.floor(sourceAmounts.length * 3 / 4)] as number;
|
const q3 = percentile(sourceAmounts, 0.75, item => item);
|
||||||
statisticData.interquartileRange = q3 - q1;
|
statisticData.interquartileRange = Math.trunc(q3 - q1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sourceAmounts.length > 5) {
|
if (sourceAmounts.length > 5) {
|
||||||
const top5Count = Math.ceil(sourceAmounts.length * 0.05);
|
const top5AmountSum = sumMaxN(sourceAmounts, 5, item => item);
|
||||||
const top5AmountSum = sourceAmounts.slice(-top5Count).reduce((sum, amount) => sum + amount, 0);
|
|
||||||
statisticData.top5AmountShare = statisticData.totalAmount > 0 ? 100.0 * top5AmountSum / statisticData.totalAmount : 0;
|
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) {
|
} else if (valueMetric === TransactionExplorerValueMetric.SourceAmountMedian) {
|
||||||
if (allSourceAmountsInDefaultCurrency.length > 0) {
|
if (allSourceAmountsInDefaultCurrency.length > 0) {
|
||||||
allSourceAmountsInDefaultCurrency.sort((a, b) => a - b);
|
allSourceAmountsInDefaultCurrency.sort((a, b) => a - b);
|
||||||
value = allSourceAmountsInDefaultCurrency[Math.floor(allSourceAmountsInDefaultCurrency.length / 2)] as number;
|
value = Math.trunc(median(allSourceAmountsInDefaultCurrency, item => item));
|
||||||
} else {
|
} else {
|
||||||
value = 0;
|
value = 0;
|
||||||
}
|
}
|
||||||
} else if (valueMetric === TransactionExplorerValueMetric.SourceAmount90thPercentile) {
|
} else if (valueMetric === TransactionExplorerValueMetric.SourceAmount90thPercentile) {
|
||||||
if (allSourceAmountsInDefaultCurrency.length > 0) {
|
if (allSourceAmountsInDefaultCurrency.length > 0) {
|
||||||
allSourceAmountsInDefaultCurrency.sort((a, b) => a - b);
|
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 {
|
} else {
|
||||||
value = 0;
|
value = 0;
|
||||||
}
|
}
|
||||||
@@ -888,9 +892,9 @@ export const useExplorersStore = defineStore('explorers', () => {
|
|||||||
} else if (valueMetric === TransactionExplorerValueMetric.SourceAmountInterquartileRange) {
|
} else if (valueMetric === TransactionExplorerValueMetric.SourceAmountInterquartileRange) {
|
||||||
if (allSourceAmountsInDefaultCurrency.length > 0) {
|
if (allSourceAmountsInDefaultCurrency.length > 0) {
|
||||||
allSourceAmountsInDefaultCurrency.sort((a, b) => a - b);
|
allSourceAmountsInDefaultCurrency.sort((a, b) => a - b);
|
||||||
const q1 = allSourceAmountsInDefaultCurrency[Math.floor(allSourceAmountsInDefaultCurrency.length / 4)] as number;
|
const q1 = Math.trunc(percentile(allSourceAmountsInDefaultCurrency, 0.25, item => item));
|
||||||
const q3 = allSourceAmountsInDefaultCurrency[Math.floor(allSourceAmountsInDefaultCurrency.length * 3 / 4)] as number;
|
const q3 = Math.trunc(percentile(allSourceAmountsInDefaultCurrency, 0.75, item => item));
|
||||||
value = q3 - q1;
|
value = Math.trunc(q3 - q1);
|
||||||
} else {
|
} else {
|
||||||
value = 0;
|
value = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user