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:
@@ -3,6 +3,7 @@ import {
|
||||
accountBalances,
|
||||
accountColor,
|
||||
balanceSeries,
|
||||
decimateSeries,
|
||||
forecastSeries,
|
||||
parseHistoryRange,
|
||||
gridFor,
|
||||
@@ -119,7 +120,18 @@ describe('money tracker calculations', () => {
|
||||
settings,
|
||||
'2026-09-23'
|
||||
);
|
||||
expect(series[0].points.map((point) => point.value)).toEqual([100000, 100000, 160000, 210244]);
|
||||
const on = (index: number, date: string) =>
|
||||
series[index].points.find((point) => point.date === date)?.value;
|
||||
expect(series[0].points).toHaveLength(69);
|
||||
expect(
|
||||
['2026-09-23', '2026-09-30', '2026-10-31', '2026-11-30'].map((date) => on(0, date))
|
||||
).toEqual([100000, 100000, 160000, 210244]);
|
||||
// Day by day: the monthly item lands on the 1st and the estimate on the 15th.
|
||||
expect(on(1, '2026-10-01')).toBe(140000);
|
||||
expect(on(1, '2026-10-15')).toBe(160000);
|
||||
// Excess above the reserve moves into investments at the end of the month without a payday.
|
||||
expect(on(1, '2026-10-31')).toBe(100000);
|
||||
expect(on(2, '2026-10-31')).toBe(60000);
|
||||
expect(series[1].points.at(-1)?.value).toBe(100000);
|
||||
expect(series[2].points.at(-1)?.value).toBe(110244);
|
||||
});
|
||||
@@ -151,9 +163,10 @@ describe('money tracker calculations', () => {
|
||||
},
|
||||
'2026-09-23'
|
||||
);
|
||||
expect(projection[0].points.map((point) => point.value)).toEqual([
|
||||
40054, 40054, 167191, 294602
|
||||
]);
|
||||
const total = new Map(projection[0].points.map((point) => [point.date, point.value]));
|
||||
expect(
|
||||
['2026-09-23', '2026-09-30', '2026-10-31', '2026-11-30'].map((date) => total.get(date))
|
||||
).toEqual([40054, 40054, 167191, 294602]);
|
||||
expect(projection[1].points.at(-1)?.value).toBe(100000);
|
||||
expect(projection[2].points.at(-1)?.value).toBe(194602);
|
||||
});
|
||||
@@ -373,4 +386,62 @@ describe('history range', () => {
|
||||
'2120'
|
||||
]);
|
||||
});
|
||||
|
||||
it('draws a day-by-day sawtooth: salary on payday, costs on their days, invest the day after', () => {
|
||||
const projection = wealthProjection(
|
||||
[
|
||||
{ id: 1, kind: 'spendable', openingBalanceCents: 0 },
|
||||
{ id: 2, kind: 'investment', openingBalanceCents: 0 }
|
||||
],
|
||||
new Map([
|
||||
[1, 150000],
|
||||
[2, 0]
|
||||
]),
|
||||
[],
|
||||
[],
|
||||
[
|
||||
{ name: 'Cargill salary', amountCents: 300000, cadence: 'monthly', accountId: 1 },
|
||||
{ name: 'Spotify', amountCents: -1199, cadence: 'monthly', accountId: 1 }
|
||||
],
|
||||
{
|
||||
investmentAccountId: 2,
|
||||
reserveCents: 100000,
|
||||
annualReturnPercent: '0.00',
|
||||
contributionMode: 'excess',
|
||||
fixedMonthlyCents: 0,
|
||||
includeRecurring: true,
|
||||
endDate: '2026-12-31'
|
||||
},
|
||||
'2026-10-26',
|
||||
null,
|
||||
[
|
||||
{ date: '2026-10-21', amountCents: 298653, description: 'Salaris Cargill' },
|
||||
{ date: '2026-10-24', amountCents: -1199, description: 'Spotify AB' }
|
||||
]
|
||||
);
|
||||
const cash = new Map(projection[1].points.map((point) => [point.date, point.value]));
|
||||
const invested = new Map(projection[2].points.map((point) => [point.date, point.value]));
|
||||
expect(cash.get('2026-11-19')).toBe(150000);
|
||||
// 21 Nov is a Saturday: paid on Friday the 20th, invested above the reserve on the 21st.
|
||||
expect(cash.get('2026-11-20')).toBe(450000);
|
||||
expect(cash.get('2026-11-21')).toBe(100000);
|
||||
expect(invested.get('2026-11-21')).toBe(350000);
|
||||
expect(cash.get('2026-11-24')).toBe(100000 - 1199);
|
||||
expect(cash.get('2026-12-21')).toBe(100000 - 1199 + 300000);
|
||||
});
|
||||
|
||||
it('thins long daily series to each bucket’s low and high, keeping both ends', () => {
|
||||
const points = Array.from({ length: 3650 }, (_, index) => ({
|
||||
date: new Date(Date.UTC(2026, 0, 1 + index)).toISOString().slice(0, 10),
|
||||
value: index % 30 === 0 ? 1000 : index % 30 === 15 ? -1000 : 0
|
||||
}));
|
||||
const series = [{ label: 'Total', color: '#000', points }];
|
||||
const [thin] = decimateSeries(series, 100);
|
||||
expect(thin.points.length).toBeLessThanOrEqual(202);
|
||||
expect(thin.points[0]).toEqual(points[0]);
|
||||
expect(thin.points.at(-1)).toEqual(points.at(-1));
|
||||
expect(Math.max(...thin.points.map((point) => point.value))).toBe(1000);
|
||||
expect(Math.min(...thin.points.map((point) => point.value))).toBe(-1000);
|
||||
expect(decimateSeries(series, 5000)).toBe(series);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user