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:
2026-10-26 01:13:43 +00:00
parent 5d732fc750
commit 807a6b3104
8 changed files with 1163 additions and 248 deletions

View File

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