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:
@@ -1,3 +1,14 @@
|
||||
import {
|
||||
businessDay,
|
||||
dayNumber,
|
||||
dayOfMonth,
|
||||
daysInMonth,
|
||||
isoDay,
|
||||
monthFlows,
|
||||
scheduleFor,
|
||||
type HistoryTransaction
|
||||
} from './schedule';
|
||||
|
||||
export type ChartPoint = { date: string; value: number };
|
||||
export type ChartSeries = {
|
||||
label: string;
|
||||
@@ -111,6 +122,7 @@ type ProjectionAccount = {
|
||||
};
|
||||
type ProjectionMovement = { date: string; amountCents: number; accountId: number | null };
|
||||
type ProjectionRecurring = {
|
||||
name?: string;
|
||||
amountCents: number;
|
||||
cadence: string;
|
||||
accountId: number | null;
|
||||
@@ -127,6 +139,11 @@ type ProjectionSettings = {
|
||||
endDate?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The setting-based ("Simple") projection, day by day: dated transactions and estimates on their
|
||||
* date, recurring items on the days they hit (see schedule.ts), returns compounding daily, and the
|
||||
* excess above the reserve invested the day after payday (month end when there is no salary).
|
||||
*/
|
||||
export function wealthProjection(
|
||||
accounts: ProjectionAccount[],
|
||||
balances: Map<number, number>,
|
||||
@@ -136,11 +153,15 @@ export function wealthProjection(
|
||||
settings: ProjectionSettings | null,
|
||||
today: string,
|
||||
/** Overrides the settings' end date, e.g. with the chart's forecast horizon. */
|
||||
endDate?: string | null
|
||||
endDate?: string | null,
|
||||
/** Bank transactions through today, to find the day each recurring item hits. */
|
||||
history: HistoryTransaction[] = []
|
||||
): ChartSeries[] {
|
||||
const excluded = excludedIds(accounts);
|
||||
const counted = (accountId: number | null) => accountId === null || !excluded.has(accountId);
|
||||
const accountKind = new Map(accounts.map((account) => [account.id, account.kind]));
|
||||
const isInvestment = (accountId: number | null) =>
|
||||
accountId !== null && accountKind.get(accountId) === 'investment';
|
||||
const investmentId = settings?.investmentAccountId ?? null;
|
||||
const validInvestmentId =
|
||||
investmentId !== null && accountKind.get(investmentId) === 'investment' ? investmentId : null;
|
||||
@@ -156,13 +177,10 @@ export function wealthProjection(
|
||||
const totalPoints: ChartPoint[] = [{ date: today, value: spendable + invested }];
|
||||
const spendablePoints: ChartPoint[] = [{ date: today, value: spendable }];
|
||||
const investedPoints: ChartPoint[] = [{ date: today, value: invested }];
|
||||
const movements = [...futureTransactions, ...forecasts].filter(
|
||||
(entry) => entry.date > today && counted(entry.accountId)
|
||||
);
|
||||
const latestMovement = movements.reduce(
|
||||
(latest, entry) => (entry.date > latest ? entry.date : latest),
|
||||
''
|
||||
);
|
||||
const movements = [...futureTransactions, ...forecasts]
|
||||
.filter((entry) => entry.date > today && counted(entry.accountId))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
const latestMovement = movements.at(-1)?.date ?? '';
|
||||
const fallback = new Date(`${today}T12:00:00Z`);
|
||||
fallback.setUTCFullYear(fallback.getUTCFullYear() + 1);
|
||||
const horizon =
|
||||
@@ -171,43 +189,71 @@ export function wealthProjection(
|
||||
: settings?.endDate && settings.endDate > today
|
||||
? settings.endDate
|
||||
: latestMovement || fallback.toISOString().slice(0, 10);
|
||||
const monthEnd = (year: number, month: number) =>
|
||||
new Date(Date.UTC(year, month + 1, 0)).toISOString().slice(0, 10);
|
||||
const start = new Date(`${today}T12:00:00Z`);
|
||||
let year = start.getUTCFullYear();
|
||||
let month = start.getUTCMonth();
|
||||
const todayNumber = dayNumber(today);
|
||||
const endNumber = dayNumber(horizon);
|
||||
const items =
|
||||
settings?.includeRecurring === false
|
||||
? []
|
||||
: recurring
|
||||
.filter((item) => counted(item.accountId))
|
||||
.map((item) => {
|
||||
const named = { ...item, name: item.name ?? '' };
|
||||
return { item: named, schedule: scheduleFor(named, history, today) };
|
||||
});
|
||||
const salary = items.find(
|
||||
({ item, schedule }) =>
|
||||
item.amountCents > 0 && /salary|salaris/i.test(item.name) && schedule.kind === 'monthly'
|
||||
);
|
||||
const yearly = Number(settings?.annualReturnPercent ?? 5) / 100;
|
||||
const monthlyGrowth = Math.pow(1 + yearly, 1 / 12) - 1;
|
||||
for (let index = 0; index < 1212; index++) {
|
||||
const first = `${year}-${String(month + 1).padStart(2, '0')}-01`;
|
||||
const end = monthEnd(year, month);
|
||||
const date = end < horizon ? end : horizon;
|
||||
if (date > today) {
|
||||
for (const entry of movements.filter(
|
||||
(item) => (index === 0 ? item.date > today : item.date >= first) && item.date <= date
|
||||
)) {
|
||||
if (entry.accountId !== null && accountKind.get(entry.accountId) === 'investment') {
|
||||
invested += entry.amountCents;
|
||||
if (entry.accountId === validInvestmentId) indexBalance += entry.amountCents;
|
||||
} else spendable += entry.amountCents;
|
||||
}
|
||||
if (settings?.includeRecurring !== false && index > 0) {
|
||||
for (const item of recurring) {
|
||||
if (!counted(item.accountId)) continue;
|
||||
if ((item.startsOn && item.startsOn > date) || (item.endsOn && item.endsOn < first))
|
||||
continue;
|
||||
const value = Math.round(monthlyCents(item.amountCents, item.cadence));
|
||||
if (item.accountId !== null && accountKind.get(item.accountId) === 'investment') {
|
||||
invested += value;
|
||||
if (item.accountId === validInvestmentId) indexBalance += value;
|
||||
} else spendable += value;
|
||||
}
|
||||
}
|
||||
if (index > 0) {
|
||||
const growth = Math.round(indexBalance * monthlyGrowth);
|
||||
invested += growth;
|
||||
indexBalance += growth;
|
||||
const excess = Math.max(0, spendable - (settings?.reserveCents ?? 100000));
|
||||
const reserve = settings?.reserveCents ?? 100000;
|
||||
let year = Number(today.slice(0, 4));
|
||||
let month = Number(today.slice(5, 7));
|
||||
let next = 0;
|
||||
for (let guard = 0; guard < 1213; guard++) {
|
||||
const firstDay = dayOfMonth(year, month, 1);
|
||||
if (firstDay > endNumber) break;
|
||||
const length = daysInMonth(year, month);
|
||||
const toSpendable = new Float64Array(length);
|
||||
const toInvested = new Float64Array(length);
|
||||
const toIndex = new Float64Array(length);
|
||||
const add = (day: number, cents: number, accountId: number | null) => {
|
||||
const offset = day - firstDay;
|
||||
if (offset < 0 || offset >= length) return;
|
||||
if (isInvestment(accountId)) {
|
||||
toInvested[offset] += cents;
|
||||
if (accountId === validInvestmentId) toIndex[offset] += cents;
|
||||
} else toSpendable[offset] += cents;
|
||||
};
|
||||
while (next < movements.length && dayNumber(movements[next].date) < firstDay + length) {
|
||||
const entry = movements[next++];
|
||||
add(dayNumber(entry.date), entry.amountCents, entry.accountId);
|
||||
}
|
||||
for (const { item, schedule } of items) {
|
||||
const flows = monthFlows(item, schedule, year, month, todayNumber);
|
||||
for (const [day, cents] of flows.events) add(day, cents, item.accountId);
|
||||
for (let day = flows.spreadFrom; day <= flows.spreadTo; day++)
|
||||
add(day, flows.spread, item.accountId);
|
||||
}
|
||||
const paid =
|
||||
salary?.schedule.kind === 'monthly'
|
||||
? businessDay(dayOfMonth(year, month, salary.schedule.day), salary.schedule.shift)
|
||||
: null;
|
||||
const sweepDay =
|
||||
paid === null ? firstDay + length - 1 : Math.min(paid + 1, firstDay + length - 1);
|
||||
const dayGrowth = Math.pow(1 + monthlyGrowth, 1 / length);
|
||||
for (let offset = 0; offset < length; offset++) {
|
||||
const day = firstDay + offset;
|
||||
if (day <= todayNumber) continue;
|
||||
if (day > endNumber) break;
|
||||
spendable += toSpendable[offset];
|
||||
invested += toInvested[offset];
|
||||
indexBalance += toIndex[offset];
|
||||
const growth = indexBalance * (dayGrowth - 1);
|
||||
invested += growth;
|
||||
indexBalance += growth;
|
||||
if (day === sweepDay) {
|
||||
const excess = Math.max(0, spendable - reserve);
|
||||
const contribution =
|
||||
settings?.contributionMode === 'fixed'
|
||||
? Math.min(excess, settings.fixedMonthlyCents)
|
||||
@@ -216,14 +262,14 @@ export function wealthProjection(
|
||||
invested += contribution;
|
||||
indexBalance += contribution;
|
||||
}
|
||||
totalPoints.push({ date, value: spendable + invested });
|
||||
spendablePoints.push({ date, value: spendable });
|
||||
investedPoints.push({ date, value: invested });
|
||||
const date = isoDay(day);
|
||||
totalPoints.push({ date, value: Math.round(spendable + invested) });
|
||||
spendablePoints.push({ date, value: Math.round(spendable) });
|
||||
investedPoints.push({ date, value: Math.round(invested) });
|
||||
}
|
||||
if (end >= horizon) break;
|
||||
month++;
|
||||
if (month === 12) {
|
||||
month = 0;
|
||||
if (month === 13) {
|
||||
month = 1;
|
||||
year++;
|
||||
}
|
||||
}
|
||||
@@ -234,6 +280,57 @@ export function wealthProjection(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Thins long series for drawing: per time bucket only the lowest and highest point stay (in
|
||||
* date order), so a daily projection over decades keeps its spikes without plotting every day.
|
||||
* The first and last points and the solid/dashed join are always kept.
|
||||
*/
|
||||
export function decimateSeries(series: ChartSeries[], buckets: number): ChartSeries[] {
|
||||
const limit = Math.max(2, Math.floor(buckets)) * 2;
|
||||
if (!series.some((line) => line.points.length > limit)) return series;
|
||||
let from = Infinity;
|
||||
let until = -Infinity;
|
||||
for (const line of series)
|
||||
if (line.points.length) {
|
||||
from = Math.min(from, Date.parse(line.points[0].date));
|
||||
until = Math.max(until, Date.parse(line.points.at(-1)!.date));
|
||||
}
|
||||
const span = until - from || 1;
|
||||
return series.map((line) => {
|
||||
if (line.points.length <= limit) return line;
|
||||
const keep = new Uint8Array(line.points.length);
|
||||
keep[0] = 1;
|
||||
keep[line.points.length - 1] = 1;
|
||||
if (line.dashedFrom) {
|
||||
const join = line.points.findLastIndex((point) => point.date <= line.dashedFrom!);
|
||||
if (join >= 0) keep[join] = 1;
|
||||
}
|
||||
let bucket = -1;
|
||||
let low = -1;
|
||||
let high = -1;
|
||||
const flush = () => {
|
||||
if (low >= 0) keep[low] = 1;
|
||||
if (high >= 0) keep[high] = 1;
|
||||
};
|
||||
line.points.forEach((point, index) => {
|
||||
const at = Math.min(
|
||||
buckets - 1,
|
||||
Math.floor(((Date.parse(point.date) - from) / span) * buckets)
|
||||
);
|
||||
if (at !== bucket) {
|
||||
flush();
|
||||
bucket = at;
|
||||
low = high = index;
|
||||
return;
|
||||
}
|
||||
if (point.value < line.points[low].value) low = index;
|
||||
if (point.value > line.points[high].value) high = index;
|
||||
});
|
||||
flush();
|
||||
return { ...line, points: line.points.filter((_, index) => keep[index]) };
|
||||
});
|
||||
}
|
||||
|
||||
export type TimelineView = 'total' | 'split' | 'accounts';
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user