← Back to Safe to Spend Versions

Typical Spending Forecast — Math Spec

Markdown companion: docs/SAFE_TO_SPEND_TYPICAL_SPENDING_SPEC.md

How we compute the Spending habits Money Out term on Home’s Safe to Spend card (v5.4: subtracted from the runway, tagged “estimated” — formerly the context-only “if your habits repeat” projection). Walks the actual algorithm, the actual filters, and a worked example from user 53’s real transaction history.

SAFE TO SPEND v5.4 SERVICE: typical_discretionary_service.ts MVP · MONEY OUT TERM (v5.4)

What this number represents

SafeToSpend’s Runway line tells you how much cash is available today after settling all committed liabilities (known bills, CC balances) and adding expected income up to your next payday. Runway answers: "if nothing non-recurring happens, where do I land?"

Real life isn’t that clean. Users swipe cards at restaurants, hit Target, grab Ubers. Those outflows don’t show up in Runway because they aren’t recurring bills. The Typical Spending Forecast projects that unplanned spending from the user’s actual history — checking/debit accounts only since v5.4 — so the accordion can show “you’d likely have $X left by {date}” as a realistic projection.

v5.4 — this IS a formula term. The habits forecast is subtracted inside Money Out (moneyOut.habits), exactly like the essentials term: Safe to Spend is what’s truly free even if your habits repeat. Two scope rules changed with the promotion: the sample is checking/debit-funded only, like essentials (the v4.7 credit-card broadening below is historical — a card swipe never leaves checking before payday; the ccDue repayment ladder already carries it) and it excludes the essentials whitelist (groceries/gas belong to the essentials term; the old sample double-counted them, which made the retired projection line overly pessimistic). The worked example below predates v5.4 — the robust-mean mechanics it illustrates are unchanged, but its sample includes credit-card and essentials transactions a v5.4 sample would exclude.

Which transactions feed the forecast?

Only transactions that match all of these rules in the last up to 365 days (adaptive: min(365, daysOfHistoryAvailable); returns null below 60 days):

The algorithm, step-by-step

Pull adaptive lookback. Find the user’s oldest transaction. lookbackDays = min(365, today − oldest + 1). If < 60, return null (the UI renders “we’ll show your pattern after a few more weeks”).
Query qualifying transactions over the lookback window (see filters above). Return only transaction_date and amount.
Aggregate by date. Build dailySpend: Map<ISODate, TotalSpendThatDay>. The map only stores keys for days that have at least one qualifying transaction — zero-spend days have no entry at this stage. This is just an intermediate data structure; zero-spend days are added back as zeros in the next step.
Walk every day in the lookback window (not just days with spend — critical). For each day, look up its spend in dailySpend with ?? 0 (defaulting to zero when the date isn't in the map) and push it into a bucket keyed by weekday. So zero-spend days ARE included in the per-weekday samples — they just enter as zeros at this step rather than being stored in the intermediate map.
Robust-mean per weekday. For each weekday’s samples: sort ascending, find the 90th-percentile index via floor(n × 0.9), take that value as the cap, replace any observation above the cap with the cap value, then take the plain mean. Below 4 samples for a weekday → return 0 (refuse to publish noise).
Per-tab window forecasts. Each SafeToSpend tab has its own windowEnd (Today = today; This Week = end-of-ISO-week-in-user-tz; This Month = end-of-month; Until Payday = nextPayday − 1 day, clamped to today — v5.4: matches the STS untilPayday tab so the metadata window covers exactly the days the ledger subtracts). For each tab: walk today through windowEnd inclusive, sum the perDayOfWeek[weekday] value for each day in the walk. That sum is forecastAmount.

Why robust mean and not median?

Previous iteration used median per weekday. It breaks on sparse-data users: if Monday has 14 samples with 10 zeros and 4 non-zero spend days, the median is zero. The forecast collapses to zero. Wrong — the user does sometimes spend on Mondays.

Plain mean is too fragile the other way: one $4,210 Thursday pulls the Thursday average up by $4,210 / n. A single Costco run warps the whole forecast.

Robust mean with p90 cap gets both: zero-heavy weekdays still produce non-zero estimates (because the non-zeros count at full weight), and outliers get pulled down to the 90th-percentile value. Simple enough to explain, strong enough to survive real user data.

Known limitation: day-of-week seasonality only. Month-of-year patterns (December holiday spike, summer travel) need multi-year data to denoise. We have at most 1 year. When we have 3+, we’ll layer month-of-year as a second signal.

Worked example — user 53, April 23 2026

Lookback: 101 days (oldest transaction is 2026-01-13; today is 2026-04-23; capped at 365).
Sample size after filters: 160 non-recurring discretionary transactions.
Observed days (days with any qualifying spend): 67 of 101 ≈ 66%.

Step A: daily spend (top recent days shown)

From the filtered query, grouping by date:

DateWeekdayDaily totalTx count
2026-04-22Wed$44.891
2026-04-21Tue$201.073
2026-04-19Sun$1,944.375
2026-04-17Fri$2,210.642
2026-04-16Thu$15.001
2026-04-15Wed$133.953
2026-04-13Mon$77.884
2026-04-10Fri$16,979.106
2026-04-08Wed$54.763
2026-04-07Tue$193.084
… (57 more rows)

Step B: walk all 101 days, bucket by weekday

Every day in the 101-day lookback is pushed into exactly one weekday bucket with its spend (zero if no qualifying transactions). Example — all 15 Thursdays in the window:

DateSpend
2026-01-15$7.25
2026-01-22$0.00
2026-01-29$69.38
2026-02-05$17.04
2026-02-12$37.31
2026-02-19$357.00
2026-02-26$166.00
2026-03-05$11.95
2026-03-12$4,180.56
2026-03-19$0.00
2026-03-26$957.12
2026-04-02$4,210.00
2026-04-09$0.00
2026-04-16$15.00
2026-04-23$0.00 (today)

Step C: compute the robust mean for Thursday

Sort the 15 samples ascending:

[0, 0, 0, 0, 7.25, 11.95, 15, 17.04, 37.31, 69.38, 166, 357, 957.12, 4180.56, 4210]

90th-percentile index: floor(15 × 0.9) = floor(13.5) = 13. sorted[13] = 4180.56. That’s the cap.

Apply the cap (replace values above $4,180.56): the single $4,210 outlier gets pulled down to $4,180.56. Everything else unchanged.

capped = [0, 0, 0, 0, 7.25, 11.95, 15, 17.04, 37.31, 69.38, 166, 357, 957.12, 4180.56, 4180.56]

Mean: sum(capped) / 15 = 9999.17 / 15 = $666.61. That’s the Thursday number you see on the Typical Spending card.

Step D: all seven weekday averages

WeekdaySample countRaw meanP90 capRobust mean (shipped)
Monday14$75.00$226.33$72.15
Tuesday15$160.12$215.24$120.56
Wednesday15$227.49$133.95$59.96
Thursday15$716.33$4,180.56$666.61
Friday14$1,516.38$2,210.64$461.49
Saturday14$0.00$0.00$0.00
Sunday14$261.51$312.17$146.30

Step E: per-tab window forecasts

For each SafeToSpend tab, walk today through the tab’s windowEnd inclusive, sum perDayOfWeek[weekday] for every day walked. Today is Thursday April 23, 2026.

Today tab

Window: Apr 23 → Apr 23 (1 day)
Thu $666.61
$666.61

This Week tab

Window: Apr 23 → Apr 26 (4 days)
Thu $666.61 + Fri $461.49 + Sat $0.00 + Sun $146.30
$1,274.40

This Month tab

Window: Apr 23 → Apr 30 (8 days)
Thu + Fri + Sat + Sun + Mon + Tue + Wed + Thu
$666.61 + $461.49 + $0 + $146.30 + $72.15 + $120.56 + $59.96 + $666.61
$2,193.68

Until Payday tab

Window: Apr 23 → May 1 (9 days)
This Month’s 8 days + Fri May 1
$2,193.68 + $461.49
$2,655.17

Why Until Payday > This Month. Until Payday’s 9-day window stretches past end-of-month into May 1 (Friday — your next payday). Friday is a heavy-spend weekday ($461.49). That single extra Friday explains why Until Payday is $461.49 more than This Month, even though both end within 24 hours of each other.

Projection-at-{windowEnd} math

The number users see as "You’d likely have $X left at payday (May 1)" is:

projection = runway − typicalDiscretionary.windows[selectedTab].forecastAmount

For user 53 today:

TabRunwayTypical spendingYou’d have left
Today$6,795.80$666.61$6,129.19
This Week$6,795.80$1,274.40$5,521.40
This Month$6,795.80$2,193.68$4,602.12
Until Payday$6,795.80$2,655.17$4,140.63

Runway stays constant across tabs — it’s a snapshot of the moment. What changes is how much typical spending gets subtracted, because each tab projects over a different length of time.

What’s excluded and why

ExcludedReason
Recurring streams (via recurring_stream_id IS NOT NULL)Already in Runway’s “Bills before payday” line — would double-count
Transfers (TRANSFER_IN, TRANSFER_OUT)Between your own accounts; not net spending
Loan payments (LOAN_PAYMENTS parent)Mortgage, auto, student loan — committed, not discretionary. Some are recurring (already excluded) but this also catches one-off paydowns
Bank fees (BANK_FEES)Interest, overdraft, maintenance — not user-chosen spending
Income (INCOME parent, but also filtered by transaction_type = 'debit')Inflows aren’t spending
CC-account side of a CC bill paymentWhen you pay a CC from checking, Plaid emits two transactions (one on each account). The checking side is already a LOAN_PAYMENTS outflow; the CC side is a balance credit. Excluded so we don’t count the same event twice
Pending transactionsAmount/date could still change. Once cleared they flow back into the sample
Loan accountsPlaid reports them but they’re not discretionary spend sources

Why is user 53’s Thursday so high? — the BILT story

$666 felt high. Here’s why: your monthly rent ($4,200/mo on Bilt Rewards card) is misclassified as discretionary because the recurrence detector hasn’t caught it yet. Bilt has only fired twice in your 102-day lookback (Mar 12, Apr 2 — about 21 days apart, not exactly biweekly or monthly), so the detector hasn’t built a stream for it. With recurring_stream_id IS NULL on those transactions, they fall into typical-discretionary’s sample.

The two big Thursdays

DateDescriptionAmountShould be?
2026-03-12 ThuBILT CARD HOUSING$4,170.55Recurring bill (rent)
2026-04-02 ThuBILT CARD HOUSING$4,200.00Recurring bill (rent)

Even with the p90 cap, both contribute heavily: cap = sorted[13] = $4,180.56, so Apr 2’s $4,210 gets clamped to $4,180.56 but Mar 12’s $4,170.55 is below the cap and contributes its full value.

What it would look like if BILT were detected as a stream

If BILT were classified as a recurring bill:

The fix — "Confirm a recurring bill" UX (planned)

Mirror of the existing “Confirm your paycheck” pattern. User taps a transaction in their history → it gets user_confirmed=true on a recurring_streams row → bypasses the detector's occurrence-count threshold. Same plumbing exists; just needs UI and the bill-side flag. Documented as planned post-MVP feature.

What this means for everyone, not just you

Any user with thin Plaid history (less than ~3 months), irregular-cadence bills (quarterly insurance, semiannual property tax), or just-started bills (new gym, first month of rent on a new card) will see the same misclassification. The typical-discretionary bucket over-counts in those cases, projection-at-payday under-counts what they’ll have left. Resolved by Confirm-a-recurring-bill once we ship it.

Code references

Single caller, single source of truth. Both the Home dashboard and the AI Coach’s calculate_safe_to_spend tool read the same numbers from the same service. If a user asks the Coach “what would I typically spend by payday?” the answer comes from exactly this math, not a second implementation.