money: day-by-day forecast with real payment dates
The wealth forecast now runs per day instead of per month, so it shows the same sawtooth as the actual history: - recurring items hit on the day the bank history shows (most common day of the last four months, clamped to month end), else their start date - "every N weeks" items (Basic-Fit, haircut) run on an exact N-day cadence from the last charge; yearly items on their renewal date - salary and its extras (holiday pay in May, 13th month in November, bonus, settlement) land on the payday from history (contract 25th as fallback), moved to the Friday before a weekend; toeslagen move to the Monday after - variable budgets are spread per day, the vacation budget over Jul-Aug - excess above the buffer is invested the day after payday; returns compound daily - long horizons stay fast (~35 ms for 100 years): the chart thins each pixel bucket to its low and high
This commit is contained in:
@@ -9,6 +9,17 @@
|
||||
*/
|
||||
|
||||
import type { ChartSeries } from './charts';
|
||||
import {
|
||||
businessDay,
|
||||
dayNumber,
|
||||
dayOfMonth,
|
||||
daysInMonth,
|
||||
isoDay,
|
||||
monthFlows,
|
||||
scheduleFor,
|
||||
type HistoryTransaction,
|
||||
type ItemSchedule
|
||||
} from './schedule';
|
||||
|
||||
export type ScenarioId = 'pessimistic' | 'realistic' | 'optimistic';
|
||||
export const scenarioIds: ScenarioId[] = ['pessimistic', 'realistic', 'optimistic'];
|
||||
@@ -246,6 +257,8 @@ export type ScenarioRecurring = {
|
||||
endsOn?: string | null;
|
||||
};
|
||||
export type ScenarioMovement = { date: string; amountCents: number; description: string };
|
||||
/** A recurring item with the days it hits (see schedule.ts). */
|
||||
export type ScheduledRecurring = ScenarioRecurring & { schedule: ItemSchedule };
|
||||
|
||||
export type ScenarioInputs = {
|
||||
today: string;
|
||||
@@ -254,10 +267,12 @@ export type ScenarioInputs = {
|
||||
investedCents: number;
|
||||
/** Net salary per month from the recurring list. */
|
||||
netSalaryCents: number;
|
||||
/** Salary payday from the bank history (null: the contract's 25th). */
|
||||
payday: { day: number; shift: 'none' | 'before' | 'after'; source: string } | null;
|
||||
/** Recurring items other than salary and Joint income. */
|
||||
personal: ScenarioRecurring[];
|
||||
personal: ScheduledRecurring[];
|
||||
/** Joint recurring costs, shared after the move. */
|
||||
household: ScenarioRecurring[];
|
||||
household: ScheduledRecurring[];
|
||||
/** Rent start: the move month. */
|
||||
moveDate: string | null;
|
||||
oneOffs: ScenarioMovement[];
|
||||
@@ -300,18 +315,6 @@ const salaryPattern = /salary|salaris/i;
|
||||
/** Tracker estimates the scenarios model themselves (holiday pay, 13th month, bonus). */
|
||||
const modelledPattern = /cargill|holiday|vakantiegeld|13th|dertiende|bonus/i;
|
||||
|
||||
function monthly(item: ScenarioRecurring) {
|
||||
return item.cadence === 'weekly'
|
||||
? (item.amountCents * 52) / 12
|
||||
: item.cadence === 'yearly'
|
||||
? item.amountCents / 12
|
||||
: item.amountCents;
|
||||
}
|
||||
|
||||
function activeIn(item: ScenarioRecurring, first: string, last: string) {
|
||||
return !(item.startsOn && item.startsOn > last) && !(item.endsOn && item.endsOn < first);
|
||||
}
|
||||
|
||||
/** Splits tracker data into the pieces the scenario model needs. */
|
||||
export function scenarioInputs(input: {
|
||||
accounts: { id: number; kind: string; name: string; includeInTotal?: boolean }[];
|
||||
@@ -326,6 +329,8 @@ export function scenarioInputs(input: {
|
||||
}[];
|
||||
today: string;
|
||||
endDate?: string | null;
|
||||
/** Bank transactions through today, to find the day each recurring item hits. */
|
||||
history?: HistoryTransaction[];
|
||||
}): ScenarioInputs {
|
||||
const counted = input.accounts.filter((account) => account.includeInTotal !== false);
|
||||
const countedIds = new Set(counted.map((account) => account.id));
|
||||
@@ -354,15 +359,25 @@ export function scenarioInputs(input: {
|
||||
!modelledPattern.test(item.description)
|
||||
)
|
||||
.map(({ date, amountCents, description }) => ({ date, amountCents, description }));
|
||||
const history = input.history ?? [];
|
||||
const scheduled = (item: ScenarioRecurring): ScheduledRecurring => ({
|
||||
...item,
|
||||
schedule: scheduleFor(item, history, input.today)
|
||||
});
|
||||
const salarySchedule = salary ? scheduleFor(salary, history, input.today) : null;
|
||||
return {
|
||||
today: input.today,
|
||||
endDate: input.endDate && input.endDate > input.today ? input.endDate : '2030-12-31',
|
||||
spendableCents: spendable,
|
||||
investedCents: invested,
|
||||
netSalaryCents: salary?.amountCents ?? 0,
|
||||
payday:
|
||||
salarySchedule?.kind === 'monthly' && salarySchedule.last
|
||||
? { day: salarySchedule.day, shift: 'before', source: salarySchedule.source }
|
||||
: null,
|
||||
// Joint income (your partner's salary) pays her share of the household, so it stays out.
|
||||
personal: recurring.filter((item) => item !== salary && !isJoint(item)),
|
||||
household,
|
||||
personal: recurring.filter((item) => item !== salary && !isJoint(item)).map(scheduled),
|
||||
household: household.map(scheduled),
|
||||
moveDate: rent?.startsOn ?? null,
|
||||
oneOffs
|
||||
};
|
||||
@@ -410,6 +425,17 @@ export function marketReturns(params: ScenarioParams) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Contract payday (the 25th) when the bank history has no salary yet. */
|
||||
const contractPayday = 25;
|
||||
|
||||
/**
|
||||
* Runs one scenario day by day from tomorrow to the end date. Every flow lands on its own day:
|
||||
* salary and its extras on payday, recurring items on the days the bank history (or their start
|
||||
* date) puts them, variable budgets spread over the days of the month, one-offs on their date.
|
||||
* Investing happens the day after payday (everything above the buffer), returns compound daily,
|
||||
* and investments are sold on any day the cash would go below zero. Amounts per month are the
|
||||
* same as a monthly model's; only the timing within the month differs.
|
||||
*/
|
||||
export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): ScenarioResult {
|
||||
const facts = scenarioFacts;
|
||||
const baseGross = facts.grossCents;
|
||||
@@ -425,6 +451,9 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
params.hopEveryYears > 0 &&
|
||||
year >= params.firstHopYear &&
|
||||
(year - params.firstHopYear) % Math.max(1, Math.round(params.hopEveryYears)) === 0;
|
||||
const payday = inputs.payday ?? { day: contractPayday, shift: 'before' as const };
|
||||
const todayNumber = dayNumber(inputs.today);
|
||||
const endNumber = dayNumber(inputs.endDate);
|
||||
let cash = inputs.spendableCents;
|
||||
let invested = inputs.investedCents;
|
||||
let basis = inputs.investedCents;
|
||||
@@ -444,11 +473,12 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
];
|
||||
const years = new Map<number, ScenarioYear>();
|
||||
const target = facts.targetMonthlyCents;
|
||||
let buffer = facts.bufferCents;
|
||||
const milestones: { label: string; date: string | null; test: () => boolean }[] = [
|
||||
{
|
||||
label: 'Cash buffer full',
|
||||
date: null,
|
||||
test: () => cash >= facts.bufferCents * deflator - 100
|
||||
test: () => cash >= buffer - 100
|
||||
},
|
||||
...[10_000, 100_000, 250_000, 1_000_000].map((euros) => ({
|
||||
label: `€${euros >= 1_000_000 ? `${euros / 1_000_000}M` : `${euros / 1000}k`} invested`,
|
||||
@@ -475,170 +505,268 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
Number(firstMonth.slice(5, 7)) +
|
||||
1;
|
||||
const moveDate = inputs.moveDate ?? '9999-12-31';
|
||||
for (let index = 0; index < Math.min(totalMonths, 1212); index++) {
|
||||
const moveMonth = moveDate.slice(0, 7) + '-01';
|
||||
const moveDay = inputs.moveDate ? dayNumber(inputs.moveDate) : 0;
|
||||
const yearRow = (year: number) =>
|
||||
years.get(year) ??
|
||||
years
|
||||
.set(year, {
|
||||
year,
|
||||
income: 0,
|
||||
spending: 0,
|
||||
saved: 0,
|
||||
savingsRate: 0,
|
||||
total: 0,
|
||||
invested: 0,
|
||||
real: 0,
|
||||
box3: 0,
|
||||
age: year - facts.birthYear,
|
||||
grossMonthly: 0,
|
||||
baseReal: 0,
|
||||
role: '',
|
||||
events: []
|
||||
})
|
||||
.get(year)!;
|
||||
// Index -1 is the rest of the current month (only recurring items, budgets and one-offs; the
|
||||
// salary for it has been paid), then one index per calendar month.
|
||||
for (let index = -1; index < Math.min(totalMonths, 1212); index++) {
|
||||
const first = addMonths(firstMonth, index);
|
||||
const last = monthEnd(first);
|
||||
const date = last < inputs.endDate ? last : inputs.endDate;
|
||||
const year = Number(first.slice(0, 4));
|
||||
const month = Number(first.slice(5, 7));
|
||||
deflator *= monthlyInflation;
|
||||
const row =
|
||||
years.get(year) ??
|
||||
years
|
||||
.set(year, {
|
||||
year,
|
||||
income: 0,
|
||||
spending: 0,
|
||||
saved: 0,
|
||||
savingsRate: 0,
|
||||
total: 0,
|
||||
invested: 0,
|
||||
real: 0,
|
||||
box3: 0,
|
||||
age: year - facts.birthYear,
|
||||
grossMonthly: 0,
|
||||
baseReal: 0,
|
||||
role: '',
|
||||
events: []
|
||||
})
|
||||
.get(year)!;
|
||||
const firstDay = dayNumber(first);
|
||||
const length = daysInMonth(year, month);
|
||||
const partial = index < 0;
|
||||
const row = yearRow(year);
|
||||
const startDeflator = deflator;
|
||||
// The current month only inflates over its remaining days.
|
||||
const monthInflation = partial
|
||||
? Math.pow(monthlyInflation, (firstDay + length - 1 - todayNumber) / length)
|
||||
: monthlyInflation;
|
||||
deflator *= monthInflation;
|
||||
if (month === 1) {
|
||||
investedAtYearStart = invested;
|
||||
contributedThisYear = 0;
|
||||
realisedThisYear = 0;
|
||||
}
|
||||
const flow = new Float64Array(length);
|
||||
const at = (day: number, cents: number) => {
|
||||
const offset = day - firstDay;
|
||||
if (offset >= 0 && offset < length) flow[offset] += cents;
|
||||
};
|
||||
/** Spreads a month's amount over its days, from tomorrow in the current month. */
|
||||
const spreadMonth = (cents: number, from = firstDay, to = firstDay + length - 1) => {
|
||||
const perDay = cents / length;
|
||||
for (let day = Math.max(from, todayNumber + 1); day <= to; day++) at(day, perDay);
|
||||
};
|
||||
const paid = businessDay(dayOfMonth(year, month, payday.day), payday.shift);
|
||||
const retired = first >= retireStart;
|
||||
// Salary path: extension or next job after the contract (optional gap), raises, job hops
|
||||
// in October (optional gap before each), capped at the career plateau in today's euros.
|
||||
const hopStart = isHopYear(year) ? `${year}-10-01` : null;
|
||||
// A gap that starts in October can run into the next year.
|
||||
const hops = [year - 1, year].filter(isHopYear).map((hopYear) => {
|
||||
const start = `${hopYear}-10-01`;
|
||||
return { start, end: addMonths(start, Math.max(0, params.hopGapMonths)) };
|
||||
});
|
||||
const inHopGap = hops.some((hop) => first >= hop.start && first < hop.end);
|
||||
const hopEnd = hops.find((hop) => hop.end === first)?.end ?? null;
|
||||
const inContractGap = params.gapMonths > 0 && first >= gapStart && first < gapEnd;
|
||||
const employed = !retired && !inHopGap && !inContractGap;
|
||||
if (first === gapStart) {
|
||||
gross = baseGross * (1 + pct(params.nextJobPercent));
|
||||
row.events.push(
|
||||
params.gapMonths > 0 ? `Contract ends, ${params.gapMonths} mo gap` : 'Contract renewed'
|
||||
);
|
||||
}
|
||||
if (
|
||||
first > gapStart &&
|
||||
year >= params.raiseFromYear &&
|
||||
month === params.raiseMonth &&
|
||||
!(hopStart && month === 10)
|
||||
)
|
||||
gross *= 1 + pct(params.raisePercent);
|
||||
const cap = ((params.salaryCapEuros * 100) / 12) * deflator;
|
||||
if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
|
||||
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
|
||||
if (hopEnd && first === hopEnd && !retired) {
|
||||
const plateau = gross >= cap * 0.99;
|
||||
gross *= 1 + pct(params.hopRaisePercent);
|
||||
row.events.push(
|
||||
(params.hopGapMonths > 0 ? 'New job' : 'Job hop') + (plateau ? ' (at plateau)' : '')
|
||||
);
|
||||
}
|
||||
if (gross > cap) gross = cap;
|
||||
const jobChange =
|
||||
first === retireStart ||
|
||||
(hopStart !== null && first === hopStart) ||
|
||||
(params.gapMonths > 0 && first === gapStart);
|
||||
let income = 0;
|
||||
if (employed) income += netFromGross(gross);
|
||||
else if (!retired) income += params.gapNetEuros * 100 * deflator;
|
||||
if (employed) {
|
||||
accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross);
|
||||
accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross);
|
||||
}
|
||||
if (month === 5 || jobChange) {
|
||||
income += accruedHoliday;
|
||||
accruedHoliday = 0;
|
||||
}
|
||||
if (month === 11 || jobChange) {
|
||||
income += accrued13;
|
||||
accrued13 = 0;
|
||||
}
|
||||
if (month === 8 && year >= facts.firstBonusYear && employed)
|
||||
income +=
|
||||
pct(params.bonusPercentOfTarget) *
|
||||
(year === facts.firstBonusYear
|
||||
? facts.firstBonusCents
|
||||
: facts.bonusNetYearCents * (gross / baseGross));
|
||||
if (retired) {
|
||||
income += params.pensionNetEuros * 100 * deflator;
|
||||
if (first === retireStart) row.events.push(`Retire at ${params.retireAge}`);
|
||||
} else {
|
||||
const ramp = Math.min(1, Math.max(0, (index - 3) / 57));
|
||||
const sideGross =
|
||||
params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
|
||||
income += sideGross * 100 * deflator * (1 - facts.sideTaxRate);
|
||||
let spending = 0;
|
||||
let employed = false;
|
||||
if (!partial) {
|
||||
// Salary path: extension or next job after the contract (optional gap), raises, job hops
|
||||
// in October (optional gap before each), capped at the career plateau in today's euros.
|
||||
const hopStart = isHopYear(year) ? `${year}-10-01` : null;
|
||||
// A gap that starts in October can run into the next year.
|
||||
const hops = [year - 1, year].filter(isHopYear).map((hopYear) => {
|
||||
const start = `${hopYear}-10-01`;
|
||||
return { start, end: addMonths(start, Math.max(0, params.hopGapMonths)) };
|
||||
});
|
||||
const inHopGap = hops.some((hop) => first >= hop.start && first < hop.end);
|
||||
const hopEnd = hops.find((hop) => hop.end === first)?.end ?? null;
|
||||
const inContractGap = params.gapMonths > 0 && first >= gapStart && first < gapEnd;
|
||||
employed = !retired && !inHopGap && !inContractGap;
|
||||
if (first === gapStart) {
|
||||
gross = baseGross * (1 + pct(params.nextJobPercent));
|
||||
row.events.push(
|
||||
params.gapMonths > 0 ? `Contract ends, ${params.gapMonths} mo gap` : 'Contract renewed'
|
||||
);
|
||||
}
|
||||
if (
|
||||
first > gapStart &&
|
||||
year >= params.raiseFromYear &&
|
||||
month === params.raiseMonth &&
|
||||
!(hopStart && month === 10)
|
||||
)
|
||||
gross *= 1 + pct(params.raisePercent);
|
||||
const cap = ((params.salaryCapEuros * 100) / 12) * deflator;
|
||||
if (hopStart && first === hopStart && params.hopGapMonths > 0 && !retired)
|
||||
row.events.push(`Between jobs (${params.hopGapMonths} mo)`);
|
||||
if (hopEnd && first === hopEnd && !retired) {
|
||||
const plateau = gross >= cap * 0.99;
|
||||
gross *= 1 + pct(params.hopRaisePercent);
|
||||
row.events.push(
|
||||
(params.hopGapMonths > 0 ? 'New job' : 'Job hop') + (plateau ? ' (at plateau)' : '')
|
||||
);
|
||||
}
|
||||
if (gross > cap) gross = cap;
|
||||
const jobChange =
|
||||
first === retireStart ||
|
||||
(hopStart !== null && first === hopStart) ||
|
||||
(params.gapMonths > 0 && first === gapStart);
|
||||
// Pay, benefit or pension, and the contract extras: all on payday.
|
||||
let pay = 0;
|
||||
if (employed) pay += netFromGross(gross);
|
||||
else if (!retired) pay += params.gapNetEuros * 100 * deflator;
|
||||
if (employed) {
|
||||
accruedHoliday += (facts.holidayNetYearCents / 12) * (gross / baseGross);
|
||||
accrued13 += (facts.thirteenthNetYearCents / 12) * (gross / baseGross);
|
||||
}
|
||||
if (month === 5 || jobChange) {
|
||||
pay += accruedHoliday;
|
||||
accruedHoliday = 0;
|
||||
}
|
||||
if (month === 11 || jobChange) {
|
||||
pay += accrued13;
|
||||
accrued13 = 0;
|
||||
}
|
||||
if (month === 8 && year >= facts.firstBonusYear && employed)
|
||||
pay +=
|
||||
pct(params.bonusPercentOfTarget) *
|
||||
(year === facts.firstBonusYear
|
||||
? facts.firstBonusCents
|
||||
: facts.bonusNetYearCents * (gross / baseGross));
|
||||
if (retired) {
|
||||
pay += params.pensionNetEuros * 100 * deflator;
|
||||
if (first === retireStart) row.events.push(`Retire at ${params.retireAge}`);
|
||||
}
|
||||
income += pay;
|
||||
at(paid, pay);
|
||||
if (!retired) {
|
||||
// Side income: invoices come in through the month, so it is spread.
|
||||
const ramp = Math.min(1, Math.max(0, (index - 3) / 57));
|
||||
const sideGross =
|
||||
params.sideStartEuros + (params.sideEndEuros - params.sideStartEuros) * ramp;
|
||||
const side = sideGross * 100 * deflator * (1 - facts.sideTaxRate);
|
||||
income += side;
|
||||
spreadMonth(side);
|
||||
}
|
||||
if (index >= 3)
|
||||
spendGrowth *= Math.pow(
|
||||
1 + pct(index < 123 ? params.spendGrowthPercent : params.inflationPercent),
|
||||
1 / 12
|
||||
);
|
||||
}
|
||||
// Recurring items from the tracker (positive ones such as benefits count as income).
|
||||
if (index >= 3)
|
||||
spendGrowth *= Math.pow(
|
||||
1 + pct(index < 123 ? params.spendGrowthPercent : params.inflationPercent),
|
||||
1 / 12
|
||||
);
|
||||
let spending = 0;
|
||||
for (const item of inputs.personal) {
|
||||
if (!activeIn(item, first, last)) continue;
|
||||
const value = monthly(item);
|
||||
if (value >= 0) income += retired ? 0 : value;
|
||||
else spending -= value * spendGrowth;
|
||||
const addItem = (item: ScheduledRecurring, scale: number, incomeAllowed: boolean) => {
|
||||
const flows = monthFlows(item, item.schedule, year, month, todayNumber);
|
||||
for (const [day, cents] of flows.events) {
|
||||
if (cents >= 0) {
|
||||
if (!incomeAllowed) continue;
|
||||
income += cents;
|
||||
at(day, cents);
|
||||
} else {
|
||||
spending -= cents * scale;
|
||||
at(day, cents * scale);
|
||||
}
|
||||
}
|
||||
if (flows.spread && flows.spreadTo >= flows.spreadFrom) {
|
||||
const cents = flows.spread * (flows.spreadTo - flows.spreadFrom + 1);
|
||||
if (cents >= 0) {
|
||||
if (!incomeAllowed) return;
|
||||
income += cents;
|
||||
for (let day = flows.spreadFrom; day <= flows.spreadTo; day++) at(day, flows.spread);
|
||||
} else {
|
||||
spending -= cents * scale;
|
||||
for (let day = flows.spreadFrom; day <= flows.spreadTo; day++)
|
||||
at(day, flows.spread * scale);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const item of inputs.personal) addItem(item, spendGrowth, !retired);
|
||||
// Spending not in the recurring list: spread over the month.
|
||||
const remaining = partial ? (firstDay + length - 1 - todayNumber) / length : 1;
|
||||
const other = params.otherSpendEuros * 100 * spendGrowth;
|
||||
spending += other * remaining;
|
||||
spreadMonth(-other);
|
||||
if (first >= '2027-01-01') {
|
||||
// Health insurance premium: collected on the 1st.
|
||||
const premium = params.healthInsuranceEuros * 100 * spendGrowth;
|
||||
const day = dayOfMonth(year, month, 1);
|
||||
if (day > todayNumber) {
|
||||
spending += premium;
|
||||
at(day, -premium);
|
||||
}
|
||||
}
|
||||
spending += params.otherSpendEuros * 100 * spendGrowth;
|
||||
if (first >= '2027-01-01') spending += params.healthInsuranceEuros * 100 * spendGrowth;
|
||||
if (first < moveDate.slice(0, 7) + '-01') spending += facts.preMoveHouseholdCents;
|
||||
else {
|
||||
let household = params.householdExtraEuros * 100;
|
||||
for (const item of inputs.household)
|
||||
if (activeIn(item, first, last)) household -= monthly(item);
|
||||
spending += household * pct(params.householdSharePercent) * spendGrowth;
|
||||
if (first < moveMonth) {
|
||||
spending += facts.preMoveHouseholdCents * remaining;
|
||||
spreadMonth(-facts.preMoveHouseholdCents);
|
||||
} else {
|
||||
const share = pct(params.householdSharePercent) * spendGrowth;
|
||||
const extra = params.householdExtraEuros * 100 * share;
|
||||
spending += extra * remaining;
|
||||
spreadMonth(-extra);
|
||||
for (const item of inputs.household) addItem(item, share, false);
|
||||
}
|
||||
if (first === moveDate.slice(0, 7) + '-01') spending += params.moveCostEuros * 100;
|
||||
if (year >= facts.duoFrom && year <= facts.duoUntil) spending += facts.duoCents;
|
||||
for (const item of inputs.oneOffs)
|
||||
if ((index === 0 ? item.date > inputs.today : item.date >= first) && item.date <= last) {
|
||||
if (first === moveMonth && moveDay > todayNumber) {
|
||||
spending += params.moveCostEuros * 100;
|
||||
at(moveDay, -params.moveCostEuros * 100);
|
||||
}
|
||||
if (!partial && year >= facts.duoFrom && year <= facts.duoUntil) {
|
||||
// DUO collects the repayment around the 24th.
|
||||
spending += facts.duoCents;
|
||||
at(dayOfMonth(year, month, 24), -facts.duoCents);
|
||||
}
|
||||
for (const item of inputs.oneOffs) {
|
||||
const day = dayNumber(item.date);
|
||||
if (day > todayNumber && day >= firstDay && day < firstDay + length) {
|
||||
if (item.amountCents >= 0) income += item.amountCents;
|
||||
else spending -= item.amountCents;
|
||||
at(day, item.amountCents);
|
||||
}
|
||||
}
|
||||
let tax = 0;
|
||||
if (month === 5) {
|
||||
if (month === 5 && !partial) {
|
||||
// Box 3 assessment for the previous year, paid at the start of May.
|
||||
tax = box3Due;
|
||||
box3Due = 0;
|
||||
at(firstDay, -tax);
|
||||
}
|
||||
cash += income - spending - tax;
|
||||
// Returns: crash and correction years spread their drop over March–October.
|
||||
// Returns: crash and correction years spread their drop over March–October; daily
|
||||
// compounding gives the same monthly return.
|
||||
const market = markets.yearReturn(year);
|
||||
const rate =
|
||||
const monthRate =
|
||||
market.kind === 'normal'
|
||||
? Math.pow(1 + market.rate, 1 / 12) - 1
|
||||
: month >= 3 && month <= 10
|
||||
? Math.pow(1 + market.rate, 1 / 8) - 1
|
||||
: 0;
|
||||
invested += invested * rate;
|
||||
cash *= Math.pow(1 + pct(params.cashRatePercent), 1 / 12);
|
||||
const buffer = facts.bufferCents * deflator;
|
||||
if (cash > buffer) {
|
||||
const extra = cash - buffer;
|
||||
invested += extra;
|
||||
basis += extra;
|
||||
contributedThisYear += extra;
|
||||
cash = buffer;
|
||||
} else if (cash < 0 && invested > 0) {
|
||||
// Withdrawals: sell investments to cover the shortfall.
|
||||
const sold = Math.min(invested, -cash);
|
||||
const soldBasis = invested > 0 ? basis * (sold / invested) : 0;
|
||||
realisedThisYear += sold - soldBasis;
|
||||
basis -= soldBasis;
|
||||
invested -= sold;
|
||||
contributedThisYear -= sold;
|
||||
cash += sold;
|
||||
const dayGrowth = Math.pow(1 + monthRate, 1 / length);
|
||||
const dayCash = Math.pow(1 + pct(params.cashRatePercent), 1 / 12 / length);
|
||||
const dayInflation = Math.pow(monthlyInflation, 1 / length);
|
||||
const sweepDay = Math.min(paid + 1, firstDay + length - 1);
|
||||
let dayDeflator = partial ? 1 : startDeflator;
|
||||
buffer = facts.bufferCents * deflator;
|
||||
for (let offset = 0; offset < length; offset++) {
|
||||
const day = firstDay + offset;
|
||||
if (day <= todayNumber) continue;
|
||||
if (day > endNumber) break;
|
||||
dayDeflator *= dayInflation;
|
||||
cash += flow[offset];
|
||||
invested *= dayGrowth;
|
||||
cash *= dayCash;
|
||||
if (day === sweepDay && cash > buffer) {
|
||||
// Investing right after payday: everything above the buffer.
|
||||
const extra = cash - buffer;
|
||||
invested += extra;
|
||||
basis += extra;
|
||||
contributedThisYear += extra;
|
||||
cash = buffer;
|
||||
} else if (cash < 0 && invested > 0) {
|
||||
// Withdrawals: sell investments to cover the shortfall.
|
||||
const sold = Math.min(invested, -cash);
|
||||
const soldBasis = invested > 0 ? basis * (sold / invested) : 0;
|
||||
realisedThisYear += sold - soldBasis;
|
||||
basis -= soldBasis;
|
||||
invested -= sold;
|
||||
contributedThisYear -= sold;
|
||||
cash += sold;
|
||||
}
|
||||
const total = cash + invested;
|
||||
points.push({ date: isoDay(day), cash, invested, total, real: total / dayDeflator });
|
||||
for (const milestone of milestones)
|
||||
if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7);
|
||||
}
|
||||
if (month === 12) {
|
||||
const gain = invested - investedAtYearStart - contributedThisYear;
|
||||
@@ -659,21 +787,28 @@ export function runScenario(inputs: ScenarioInputs, params: ScenarioParams): Sce
|
||||
}
|
||||
}
|
||||
const total = cash + invested;
|
||||
const real = total / deflator;
|
||||
recentRealIncome.push(income / deflator);
|
||||
if (recentRealIncome.length > 12) recentRealIncome.shift();
|
||||
points.push({ date, cash, invested, total, real });
|
||||
if (!partial) {
|
||||
recentRealIncome.push(income / deflator);
|
||||
if (recentRealIncome.length > 12) recentRealIncome.shift();
|
||||
}
|
||||
row.income += income;
|
||||
row.spending += spending + tax;
|
||||
row.box3 += tax;
|
||||
row.total = total;
|
||||
row.invested = invested;
|
||||
row.real = real;
|
||||
row.grossMonthly = employed ? gross : 0;
|
||||
row.baseReal = retired ? 0 : (gross * 12) / deflator / 100;
|
||||
row.role = retired ? 'Retired' : roleFor(row.baseReal);
|
||||
row.real = total / deflator;
|
||||
if (!partial) {
|
||||
row.grossMonthly = employed ? gross : 0;
|
||||
row.baseReal = retired ? 0 : (gross * 12) / deflator / 100;
|
||||
row.role = retired ? 'Retired' : roleFor(row.baseReal);
|
||||
} else {
|
||||
row.grossMonthly = gross;
|
||||
row.baseReal = (gross * 12) / 100;
|
||||
row.role = roleFor(row.baseReal);
|
||||
}
|
||||
for (const milestone of milestones)
|
||||
if (milestone.date === null && milestone.test()) milestone.date = first.slice(0, 7);
|
||||
if (last >= inputs.endDate) break;
|
||||
}
|
||||
for (const row of years.values()) {
|
||||
row.saved = row.income - row.spending;
|
||||
|
||||
Reference in New Issue
Block a user