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:
Jonathon Jongsma
2026-01-13 10:34:19 -06:00
committed by GitHub
parent 843e957757
commit 0467b13848
5 changed files with 39 additions and 38 deletions

View File

@@ -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 }}
/>

View File

@@ -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(

View File

@@ -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;

View File

@@ -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

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [jonner]
---
Add new projection types to crossover point report