Skip to content

Forecasting

Purser’s forecast answers “will this account dip below zero before payday?” and it answers it the same way every time. The forecast is deterministic: it is your current balance plus the expected transactions your schedules already imply – not a prediction learned from a model.

A forecast starts from a real, stored balance and walks forward, applying each expected instance that active schedules materialize on the horizon. The result is a daily series where every step is a named, dated event — the rent on the 1st, the paycheck on the 15th — carrying the running balance_after at that point. The narration and the chart come from exactly the same data, because the events array is the chart.

There is no regression, no seasonality model, no machine-learned trend. That is a choice, not a gap. A deterministic forecast is explainable: every projected movement traces to a schedule you can inspect and edit, so when the number surprises you, you can see precisely which expectation caused it. A statistical forecast that is confidently wrong about your rent is worse than useless for planning cash.

forecast_balance(account, days) (horizon defaults to 30 days) returns the daily series, the projected minimum (low), and the account floor (min_balance, null when unset; the headline then measures against zero). Calling it only reads: it never emits an event.

The warning comes from a nightly scan instead. At 07:15 UTC Purser forecasts the next 30 days of every open account that has a floor, and when a projection crosses that floor it emits a forecast.shortfall event, once per account and first shortfall date. The warning reaches you through the event stream, a webhook, or the whats_new digest before the account actually runs dry, not after. An account without a floor is never scanned, so set one on every account you want watched: the floor is the min_balance field given when the account is created (POST /accounts).

The horizon is a count of days forward from today, up to 365. Schedules materialize their instances on a rolling 60-day window; for slots past that the forecaster expands the rules on the fly, so a 180-day call still shows every occurrence. Those events are flagged projected — the rule expects them, but no instance row exists yet to edit or match — while events with a transaction_id are instances you can skip, override or mark paid. An instance dated before today that has not been matched is flagged overdue: it lands on day 0, because it is still due. The uncleared rows of a file import are expected too, so an outstanding check lands on day 0 the same way until the matcher pairs it with the bank’s posted row. A slot you skipped never appears, projected or not. The series stays daily, and the full daily series is always available behind a purser:// resource.

An instance the matcher has already proposed a bank row for is treated as paid, pending your confirmation. That row — pending or posted — is already in the starting balance, so landing the instance as well would subtract the money twice. The forecast leaves it out, whether it is overdue or due within the five-day match window, and the line is exactly what confirming the match would give: the bank’s amount, never a leftover difference. Reject the proposal and the instance is back on the next forecast; confirm it (in the register, or with match on the same pair) and it is settled for good. An account valued by a stated figure rather than its postings (a loan or property with a valuation) keeps such instances, because no bank row moves that figure.

Every event carries a basis: what produced its amount. A schedule’s own amount is fixed, or the estimate it follows — last, rolling_avg_3, rolling_avg_12, or counterparty_balance, which for a card payment is the card’s current balance. An amount someone supplied is override (you pinned the instance with a different amount or date) or statement (a recorded card statement balance). The REST forecast and the full daily series carry basis on every event; forecast_balance names it only for statement and override, the amounts an agent should be able to explain, and leaves it off the rest to stay inside its budget.

An estimated amount is a point taken from history, and history varies. So an event whose amount a schedule estimates — a rolling average, the last amount, or a card payment’s current balance — also carries a range: the lowest and highest of the schedule’s six newest settled amounts together with the event’s own amount, so the point always sits inside. A card payment’s range is its past payments together with the card’s current balance (not the balance’s swing from zero to its peak over a cycle). It needs at least two settled amounts that differ; a fixed amount, an amount you overrode, and a recorded card statement have no range, because nothing about them is uncertain.

When any event has a range, the forecast also walks the horizon twice more: once with every ranged event at its worst (outflows at their high, inflows at their low) and once at its best. Each day then carries low and high beside its balance, and the forecast reports the lowest point of each pass. forecast_balance puts that under low.range — low and low_date for the pessimistic pass, high for the optimistic one — and when only the pessimistic pass dips below the floor, the headline says which event would cause it: “It could dip to -125.00 on 2026-10-26 if Citi payment comes in high.” The forecast.shortfall event stays on the expected amounts, so a wide range never raises an alert on its own. The forecast widget and the Schedules page chart shade the band behind the line, and an event’s tooltip names its basis and range.

forecast_balance keeps its answer inside a token budget, so its events list holds the movements that fit, not necessarily all of them. The movement that produces the projected low is always listed, then the largest by amount; pass materiality (a decimal string such as "100.00") to list only movements at least that large. Whatever is not listed is accounted for in omitted — how many movements and their net — and end_balance is the balance on the last day, so start_balance plus the listed amounts plus omitted.net is always end_balance. truncated is true only when the budget, rather than your materiality, left a movement out, and the headline then says how many. Every day is in the series the payload’s daily_uri names: read it with resources/read, or with the read_series tool when your client or gateway forwards tools but not resources. The horizon (days) is 1 to 365, and every horizon the tool accepts is one the series serves.

Every forecast carries coverage: how many active schedules touch the account, how many instances and projected occurrences went into the line, how many open suggestions name the account, and matches_pending — how many instances were left out as paid pending a match confirmation, with their net in matches_pending_net (a decimal string, present only when it is not zero). The headline names them too, so a caller knows confirmations are outstanding: list_unmatched shows each one with proposed_posted_id, the bank row it was paired with, and match on that pair confirms it. A flat line with schedules_active: 0 is not a calm month — it is an account with nothing modeled, and the headline says so instead of reporting that the balance “stays above its floor”.

Looking backward is the mirror image. One account’s history is balance_history(account, period), which reads the same kind of recorded running balance in the other direction — and has the same “the series IS the chart” property, except the numbers were stored rather than projected. Net worth over time is the whole-portfolio counterpart — see design section 8.

The authoritative spec is DESIGN.md section 6 (schedules and forecasting). The tool contract is in the MCP tool catalog under forecast_balance.