mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 06:02:22 -05:00
Update the projection types in crossover report (#6589)
* Add unfiltered median projection type to crossover report Signed-off-by: Jonathon Jongsma <jonathon@quotidian.org> * Add a new 'mean' projection type to crossover report Some people may want to use the average monthly expenses rather than median expenses. Signed-off-by: Jonathon Jongsma <jonathon@quotidian.org> * Remove 'linear trend' from crossover report The linear projection type almost never provides any useful information for projecting future expenses. For example, if my expenses have been declining for the past several months, that doesn't mean that they will continue to decline until they reach 0 in retirement. It's way too easy to receive a nonsense projection with the linear projection type. Just remove it. Signed-off-by: Jonathon Jongsma <jonathon@quotidian.org> * Add release notes for crossover point projections Signed-off-by: Jonathon Jongsma <jonathon@quotidian.org> * [autofix.ci] apply automated fixes --------- Signed-off-by: Jonathon Jongsma <jonathon@quotidian.org> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -127,9 +127,9 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
|
||||
|
||||
const [swr, setSwr] = useState(0.04);
|
||||
const [estimatedReturn, setEstimatedReturn] = useState<number | null>(null);
|
||||
const [projectionType, setProjectionType] = useState<'trend' | 'hampel'>(
|
||||
'hampel',
|
||||
);
|
||||
const [projectionType, setProjectionType] = useState<
|
||||
'hampel' | 'median' | 'mean'
|
||||
>('hampel');
|
||||
const [expenseAdjustmentFactor, setExpenseAdjustmentFactor] = useState(1.0);
|
||||
const [showHiddenCategories, setShowHiddenCategories] = useState(false);
|
||||
const [selectionsInitialized, setSelectionsInitialized] = useState(false);
|
||||
@@ -704,6 +704,13 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
|
||||
<br />
|
||||
Hampel Filtered Median: Filters out outliers
|
||||
before calculating the median expense.
|
||||
<br />
|
||||
<br />
|
||||
Median: Uses the median of all historical expenses
|
||||
without filtering.
|
||||
<br />
|
||||
<br />
|
||||
Mean: Uses the average of all historical expenses.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
@@ -720,11 +727,12 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
|
||||
<Select
|
||||
value={projectionType}
|
||||
onChange={value =>
|
||||
setProjectionType(value as 'trend' | 'hampel')
|
||||
setProjectionType(value as 'hampel' | 'median' | 'mean')
|
||||
}
|
||||
options={[
|
||||
['trend', t('Linear Trend')],
|
||||
['hampel', t('Hampel Filtered Median')],
|
||||
['median', t('Median')],
|
||||
['mean', t('Mean')],
|
||||
]}
|
||||
style={{ width: 200, marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
@@ -165,7 +165,8 @@ export function CrossoverCard({
|
||||
|
||||
const swr = meta?.safeWithdrawalRate ?? 0.04;
|
||||
const estimatedReturn = meta?.estimatedReturn ?? null;
|
||||
const projectionType = meta?.projectionType ?? 'hampel';
|
||||
const projectionType: 'hampel' | 'median' | 'mean' =
|
||||
meta?.projectionType ?? 'hampel';
|
||||
const expenseAdjustmentFactor = meta?.expenseAdjustmentFactor ?? 1.0;
|
||||
|
||||
const params = useMemo(
|
||||
|
||||
@@ -21,6 +21,12 @@ function calculateMedian(values: number[]): number {
|
||||
: sorted[mid];
|
||||
}
|
||||
|
||||
function calculateMean(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sum = values.reduce((acc, val) => acc + val, 0);
|
||||
return sum / values.length;
|
||||
}
|
||||
|
||||
function calculateMAD(values: number[], median: number): number {
|
||||
const deviations = values.map(v => Math.abs(v - median));
|
||||
return calculateMedian(deviations);
|
||||
@@ -50,7 +56,7 @@ export type CrossoverParams = {
|
||||
incomeAccountIds: AccountEntity['id'][]; // selected accounts for both historical returns and projections
|
||||
safeWithdrawalRate: number; // annual percent, e.g. 0.04 for 4%
|
||||
estimatedReturn?: number | null; // optional annual return to project future balances
|
||||
projectionType: 'trend' | 'hampel'; // expense projection method
|
||||
projectionType: 'hampel' | 'median' | 'mean'; // expense projection method
|
||||
expenseAdjustmentFactor?: number; // multiplier for expenses (default 1.0)
|
||||
};
|
||||
|
||||
@@ -314,29 +320,19 @@ function recalculate(
|
||||
const maxProjectionMonths = 600;
|
||||
let projectedBalance = lastBalance;
|
||||
let monthCursor = d.parseISO(months[months.length - 1] + '-01');
|
||||
// Calculate expense projection parameters based on projection type
|
||||
let expenseSlope = 0;
|
||||
let expenseIntercept = lastExpense;
|
||||
let hampelFilteredExpense = 0;
|
||||
let flatExpense = 0;
|
||||
|
||||
const y: number[] = months.map(m => expenseMap.get(m) || 0);
|
||||
|
||||
if (params.projectionType === 'trend') {
|
||||
// Linear trend calculation: y = a + b * t
|
||||
const x: number[] = months.map((_m, i) => i);
|
||||
const n = x.length;
|
||||
const sumX = x.reduce((a, b) => a + b, 0);
|
||||
const sumY = y.reduce((a, b) => a + b, 0);
|
||||
const sumXY = x.reduce((a, xi, idx) => a + xi * y[idx], 0);
|
||||
const sumX2 = x.reduce((a, xi) => a + xi * xi, 0);
|
||||
const denom = n * sumX2 - sumX * sumX;
|
||||
if (denom !== 0) {
|
||||
expenseSlope = (n * sumXY - sumX * sumY) / denom;
|
||||
expenseIntercept = (sumY - expenseSlope * sumX) / n;
|
||||
}
|
||||
} else if (params.projectionType === 'hampel') {
|
||||
if (params.projectionType === 'hampel') {
|
||||
// Hampel filtered median calculation
|
||||
hampelFilteredExpense = calculateHampelFilteredMedian(y);
|
||||
flatExpense = calculateHampelFilteredMedian(y);
|
||||
} else if (params.projectionType === 'median') {
|
||||
// Plain median calculation without filtering
|
||||
flatExpense = calculateMedian(y);
|
||||
} else if (params.projectionType === 'mean') {
|
||||
// Mean (average) calculation
|
||||
flatExpense = calculateMean(y);
|
||||
}
|
||||
|
||||
for (let i = 1; i <= maxProjectionMonths; i++) {
|
||||
@@ -347,17 +343,7 @@ function recalculate(
|
||||
}
|
||||
const projectedIncome = projectedBalance * monthlySWR;
|
||||
|
||||
// Project expenses based on projection type
|
||||
let projectedExpenses: number;
|
||||
if (params.projectionType === 'trend') {
|
||||
projectedExpenses = Math.max(
|
||||
0,
|
||||
expenseIntercept + expenseSlope * (months.length - 1 + i),
|
||||
);
|
||||
} else {
|
||||
// Hampel filtered median - flat projection
|
||||
projectedExpenses = Math.max(0, hampelFilteredExpense);
|
||||
}
|
||||
const projectedExpenses = Math.max(0, flatExpense);
|
||||
|
||||
// Calculate adjusted expenses
|
||||
const adjustedProjectedExpenses = projectedExpenses * adjustmentFactor;
|
||||
|
||||
@@ -73,7 +73,7 @@ export type CrossoverWidget = AbstractWidget<
|
||||
timeFrame?: TimeFrame;
|
||||
safeWithdrawalRate?: number; // 0.04 default
|
||||
estimatedReturn?: number | null; // annual
|
||||
projectionType?: 'trend' | 'hampel'; // expense projection method
|
||||
projectionType?: 'hampel' | 'median' | 'mean'; // expense projection method
|
||||
showHiddenCategories?: boolean; // show hidden categories in selector
|
||||
expenseAdjustmentFactor?: number; // multiplier for expenses (default 1.0)
|
||||
} | null
|
||||
|
||||
6
upcoming-release-notes/6589.md
Normal file
6
upcoming-release-notes/6589.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [jonner]
|
||||
---
|
||||
|
||||
Add new projection types to crossover point report
|
||||
Reference in New Issue
Block a user