mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 03:32:54 -05:00
Add contributions to crossover report (#6639)
Add the ability to specify expected monthly contributions to the crossover report. This will allow users to estimate the growth of their investments more accurately instead of treating all account growth as exponential investment returns. When the checkbox is unclicked, the automatically calculated historical return value will be used. When the checkbox is clicked, it will display two input fields. One for expected investment growth, and one for expected monthly contributions to your retirement accounts. Signed-off-by: Jonathon Jongsma <jonathon@quotidian.org>
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
import { Link } from '@desktop-client/components/common/Link';
|
||||
import { EditablePageHeaderTitle } from '@desktop-client/components/EditablePageHeaderTitle';
|
||||
import { FinancialText } from '@desktop-client/components/FinancialText';
|
||||
import { Checkbox } from '@desktop-client/components/forms';
|
||||
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
|
||||
import {
|
||||
MobilePageHeader,
|
||||
@@ -127,7 +128,11 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
|
||||
>(accounts.map(a => a.id));
|
||||
|
||||
const [swr, setSwr] = useState(0.04);
|
||||
const [useCustomGrowth, setUseCustomGrowth] = useState(false);
|
||||
const [estimatedReturn, setEstimatedReturn] = useState<number | null>(null);
|
||||
const [expectedContribution, setExpectedContribution] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [projectionType, setProjectionType] = useState<
|
||||
'hampel' | 'median' | 'mean'
|
||||
>('hampel');
|
||||
@@ -163,7 +168,16 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
|
||||
setSelectedExpenseCategories(initialExpenseCategories);
|
||||
setSelectedIncomeAccountIds(initialIncomeAccountIds);
|
||||
setSwr(widget?.meta?.safeWithdrawalRate ?? 0.04);
|
||||
setEstimatedReturn(widget?.meta?.estimatedReturn ?? null);
|
||||
|
||||
const initialEstimatedReturn = widget?.meta?.estimatedReturn ?? null;
|
||||
const initialExpectedContribution =
|
||||
widget?.meta?.expectedContribution ?? null;
|
||||
const hasCustomGrowth =
|
||||
initialEstimatedReturn != null || initialExpectedContribution != null;
|
||||
|
||||
setUseCustomGrowth(hasCustomGrowth);
|
||||
setEstimatedReturn(initialEstimatedReturn);
|
||||
setExpectedContribution(initialExpectedContribution);
|
||||
setProjectionType(widget?.meta?.projectionType ?? 'hampel');
|
||||
setExpenseAdjustmentFactor(widget?.meta?.expenseAdjustmentFactor ?? 1.0);
|
||||
setShowHiddenCategories(widget?.meta?.showHiddenCategories ?? false);
|
||||
@@ -293,7 +307,10 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
|
||||
expenseCategoryIds: selectedExpenseCategories.map(c => c.id),
|
||||
incomeAccountIds: selectedIncomeAccountIds,
|
||||
safeWithdrawalRate: swr,
|
||||
estimatedReturn,
|
||||
estimatedReturn: useCustomGrowth ? (estimatedReturn ?? 0) : null,
|
||||
expectedContribution: useCustomGrowth
|
||||
? (expectedContribution ?? 0)
|
||||
: null,
|
||||
projectionType,
|
||||
expenseAdjustmentFactor,
|
||||
showHiddenCategories,
|
||||
@@ -335,7 +352,10 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
|
||||
expenseCategoryIds,
|
||||
incomeAccountIds: selectedIncomeAccountIds,
|
||||
safeWithdrawalRate: swr,
|
||||
estimatedReturn,
|
||||
estimatedReturn: useCustomGrowth ? (estimatedReturn ?? 0) : null,
|
||||
expectedContribution: useCustomGrowth
|
||||
? (expectedContribution ?? 0)
|
||||
: null,
|
||||
projectionType,
|
||||
expenseAdjustmentFactor,
|
||||
});
|
||||
@@ -345,7 +365,9 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
|
||||
start,
|
||||
end,
|
||||
swr,
|
||||
useCustomGrowth,
|
||||
estimatedReturn,
|
||||
expectedContribution,
|
||||
projectionType,
|
||||
expenseAdjustmentFactor,
|
||||
expenseCategoryIds,
|
||||
@@ -802,71 +824,207 @@ function CrossoverInner({ widget }: CrossoverInnerProps) {
|
||||
</View>
|
||||
|
||||
<View style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||
<View
|
||||
<label
|
||||
htmlFor="custom-growth"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id="custom-growth"
|
||||
checked={useCustomGrowth}
|
||||
onChange={e => {
|
||||
const checked = e.target.checked;
|
||||
setUseCustomGrowth(checked);
|
||||
// On first enable (when estimatedReturn is null), default to 6%
|
||||
if (checked && estimatedReturn === null) {
|
||||
setEstimatedReturn(0.06);
|
||||
}
|
||||
}}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={{ fontWeight: 600 }}>
|
||||
<Trans>Use custom growth projections</Trans>
|
||||
</Text>
|
||||
<Tooltip
|
||||
content={
|
||||
<View style={{ maxWidth: 300 }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
Enable this to specify custom monthly contribution
|
||||
amounts and investment returns that will be used to
|
||||
project your investments into the future.
|
||||
<br />
|
||||
<br />
|
||||
When disabled, uses historical performance from your
|
||||
Income Accounts (which includes both past
|
||||
contributions and investment growth).
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
placement="right top"
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
...styles.tooltip,
|
||||
}}
|
||||
>
|
||||
<Text>{t('Estimated return (annual %, optional)')}</Text>
|
||||
<Tooltip
|
||||
content={
|
||||
<View style={{ maxWidth: 300 }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
The expected annual return rate for your
|
||||
investments, used to project growth of Income
|
||||
Accounts. If not specified, the historical return
|
||||
from your Income Accounts will be used instead.
|
||||
<br />
|
||||
<br />
|
||||
Note: Historical return calculation includes
|
||||
contributions and may not reflect actual
|
||||
investment performance.
|
||||
</Trans>
|
||||
</Text>
|
||||
<SvgQuestion
|
||||
height={12}
|
||||
width={12}
|
||||
cursor="pointer"
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</label>
|
||||
|
||||
{useCustomGrowth && (
|
||||
<View
|
||||
style={{
|
||||
marginLeft: 24,
|
||||
paddingLeft: 12,
|
||||
borderLeft: `3px solid ${theme.tableBorder}`,
|
||||
}}
|
||||
>
|
||||
<View style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Text>{t('Expected return (annual %)')}</Text>
|
||||
<Tooltip
|
||||
content={
|
||||
<View style={{ maxWidth: 300 }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
The expected annual return rate for your
|
||||
investments, used to project growth of
|
||||
Income Accounts.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
placement="right top"
|
||||
style={{
|
||||
...styles.tooltip,
|
||||
}}
|
||||
>
|
||||
<SvgQuestion
|
||||
height={12}
|
||||
width={12}
|
||||
cursor="pointer"
|
||||
/>
|
||||
</Tooltip>
|
||||
</View>
|
||||
}
|
||||
placement="right top"
|
||||
style={{
|
||||
...styles.tooltip,
|
||||
}}
|
||||
>
|
||||
<SvgQuestion height={12} width={12} cursor="pointer" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.1}
|
||||
value={
|
||||
estimatedReturn === null
|
||||
? ''
|
||||
: Number((estimatedReturn * 100).toFixed(2))
|
||||
}
|
||||
onChange={e =>
|
||||
setEstimatedReturn(
|
||||
isNaN(e.target.valueAsNumber)
|
||||
? null
|
||||
: e.target.valueAsNumber / 100,
|
||||
)
|
||||
}
|
||||
onBlur={() => {
|
||||
if (estimatedReturn === null) {
|
||||
setEstimatedReturn(0);
|
||||
}
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 8 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
<Trans>Expected monthly contribution</Trans>
|
||||
</Text>
|
||||
<Tooltip
|
||||
content={
|
||||
<View style={{ maxWidth: 300 }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
The amount you plan to contribute to your
|
||||
Income Accounts each month. This amount is
|
||||
added to your balance each month before
|
||||
applying the investment return.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
placement="right top"
|
||||
style={{
|
||||
...styles.tooltip,
|
||||
}}
|
||||
>
|
||||
<SvgQuestion
|
||||
height={12}
|
||||
width={12}
|
||||
cursor="pointer"
|
||||
/>
|
||||
</Tooltip>
|
||||
</View>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={100}
|
||||
value={
|
||||
expectedContribution === null
|
||||
? ''
|
||||
: expectedContribution / 100
|
||||
}
|
||||
onChange={e =>
|
||||
setExpectedContribution(
|
||||
isNaN(e.target.valueAsNumber)
|
||||
? null
|
||||
: e.target.valueAsNumber * 100,
|
||||
)
|
||||
}
|
||||
onBlur={() => {
|
||||
if (expectedContribution === null) {
|
||||
setExpectedContribution(0);
|
||||
}
|
||||
}}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.1}
|
||||
value={
|
||||
estimatedReturn == null
|
||||
? ''
|
||||
: Number((estimatedReturn * 100).toFixed(2))
|
||||
}
|
||||
onChange={e =>
|
||||
setEstimatedReturn(
|
||||
isNaN(e.target.valueAsNumber)
|
||||
? null
|
||||
: e.target.valueAsNumber / 100,
|
||||
)
|
||||
}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
{estimatedReturn == null && historicalReturn != null && (
|
||||
)}
|
||||
|
||||
{!useCustomGrowth && historicalReturn != null && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: theme.pageTextSubdued,
|
||||
marginTop: 4,
|
||||
marginLeft: 24,
|
||||
}}
|
||||
>
|
||||
<Trans>
|
||||
Using historical return:{' '}
|
||||
Using calculated historical return of{' '}
|
||||
{(historicalReturn * 100).toFixed(1)}%
|
||||
</Trans>
|
||||
</div>
|
||||
|
||||
@@ -171,6 +171,7 @@ export function CrossoverCard({
|
||||
|
||||
const swr = meta?.safeWithdrawalRate ?? 0.04;
|
||||
const estimatedReturn = meta?.estimatedReturn ?? null;
|
||||
const expectedContribution = meta?.expectedContribution ?? null;
|
||||
const projectionType: 'hampel' | 'median' | 'mean' =
|
||||
meta?.projectionType ?? 'hampel';
|
||||
const expenseAdjustmentFactor = meta?.expenseAdjustmentFactor ?? 1.0;
|
||||
@@ -183,7 +184,8 @@ export function CrossoverCard({
|
||||
expenseCategoryIds,
|
||||
incomeAccountIds,
|
||||
safeWithdrawalRate: swr,
|
||||
estimatedReturn: estimatedReturn == null ? null : estimatedReturn,
|
||||
estimatedReturn,
|
||||
expectedContribution,
|
||||
projectionType,
|
||||
expenseAdjustmentFactor,
|
||||
}),
|
||||
@@ -194,6 +196,7 @@ export function CrossoverCard({
|
||||
incomeAccountIds,
|
||||
swr,
|
||||
estimatedReturn,
|
||||
expectedContribution,
|
||||
projectionType,
|
||||
expenseAdjustmentFactor,
|
||||
],
|
||||
|
||||
@@ -56,6 +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
|
||||
expectedContribution?: number | null; // optional monthly contribution to project future balances
|
||||
projectionType: 'hampel' | 'median' | 'mean'; // expense projection method
|
||||
expenseAdjustmentFactor?: number; // multiplier for expenses (default 1.0)
|
||||
};
|
||||
@@ -67,6 +68,7 @@ export function createCrossoverSpreadsheet({
|
||||
incomeAccountIds,
|
||||
safeWithdrawalRate,
|
||||
estimatedReturn,
|
||||
expectedContribution,
|
||||
projectionType,
|
||||
expenseAdjustmentFactor,
|
||||
}: CrossoverParams) {
|
||||
@@ -175,6 +177,7 @@ export function createCrossoverSpreadsheet({
|
||||
incomeAccountIds,
|
||||
safeWithdrawalRate,
|
||||
estimatedReturn,
|
||||
expectedContribution,
|
||||
projectionType,
|
||||
expenseAdjustmentFactor,
|
||||
},
|
||||
@@ -194,6 +197,7 @@ function recalculate(
|
||||
| 'incomeAccountIds'
|
||||
| 'safeWithdrawalRate'
|
||||
| 'estimatedReturn'
|
||||
| 'expectedContribution'
|
||||
| 'projectionType'
|
||||
| 'expenseAdjustmentFactor'
|
||||
>,
|
||||
@@ -310,6 +314,11 @@ function recalculate(
|
||||
}
|
||||
}
|
||||
|
||||
// Use user-provided contribution, or default to 0
|
||||
// (We don't use historical contribution as default because the historical
|
||||
// return calculation already includes contributions)
|
||||
const monthlyContribution = params.expectedContribution ?? 0;
|
||||
|
||||
if (months.length > 0) {
|
||||
// If no explicit return provided, use the calculated default
|
||||
if (monthlyReturn == null) {
|
||||
@@ -337,10 +346,15 @@ function recalculate(
|
||||
|
||||
for (let i = 1; i <= maxProjectionMonths; i++) {
|
||||
monthCursor = d.addMonths(monthCursor, 1);
|
||||
// grow balance
|
||||
|
||||
// Add contribution BEFORE applying growth
|
||||
projectedBalance = projectedBalance + monthlyContribution;
|
||||
|
||||
// Then grow balance
|
||||
if (monthlyReturn != null) {
|
||||
projectedBalance = projectedBalance * (1 + monthlyReturn);
|
||||
}
|
||||
|
||||
const projectedIncome = projectedBalance * monthlySWR;
|
||||
|
||||
const projectedExpenses = Math.max(0, flatExpense);
|
||||
|
||||
@@ -80,6 +80,7 @@ export type CrossoverWidget = AbstractWidget<
|
||||
timeFrame?: TimeFrame;
|
||||
safeWithdrawalRate?: number; // 0.04 default
|
||||
estimatedReturn?: number | null; // annual
|
||||
expectedContribution?: number | null; // monthly dollar amount
|
||||
projectionType?: 'hampel' | 'median' | 'mean'; // expense projection method
|
||||
showHiddenCategories?: boolean; // show hidden categories in selector
|
||||
expenseAdjustmentFactor?: number; // multiplier for expenses (default 1.0)
|
||||
|
||||
6
upcoming-release-notes/6639.md
Normal file
6
upcoming-release-notes/6639.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [jonner]
|
||||
---
|
||||
|
||||
Add the ability to specify expected contributions to the crossover point report.
|
||||
Reference in New Issue
Block a user