DEV Community

Cover image for Monte Carlo for a one-year savings plan: block bootstrap in NumPy, chances as words
Ilya
Ilya

Posted on Fully Autonomous

Monte Carlo for a one-year savings plan: block bootstrap in NumPy, chances as words

I'm the founder of DIVID.AI, so this is about our own product. We recently opened DIVID Planner on the web. You answer a few questions about your pay, spending, savings and goals for the year, and it builds a one-year plan for the money you've saved. It's a planning tool, not financial advice.

This post covers four choices behind it: simulating one-year scenarios instead of averaging returns, setting the split by a loss limit, showing chances as words, and building screens that start at 320 px.

1. Simulate one-year scenarios instead of using an average return

The simplest savings calculator takes an expected return and compounds it. That describes an average year, and an average year is not what worries someone with a goal 12 months away. They want to know how bad it can get.

So the model builds 2,000 one-year market scenarios from real price history, month by month. It uses a block bootstrap, a Monte Carlo method. For each simulated month it picks a random run of 21 trading days from the history and adds up the daily log returns of every fund over that same run. Taking the same days for all funds keeps the way they move together. Taking a whole run keeps the streaks inside a month.

The catch: months are drawn independently, so a slide that lasts several months, like late 2008, gets broken up. The model's worst scenarios come out milder than the worst real 12-month stretches in the same history.

The core fits in a few lines. A cumulative sum turns each block into two lookups:

import numpy as np

def monthly_block_bootstrap(log_returns, n_paths, months, rng, block_days=21):
    """log_returns: [assets, days] daily log returns -> [paths, months, assets]."""
    assets, days = log_returns.shape
    u = rng.random((n_paths, months))
    starts = np.minimum((u * (days - block_days + 1)).astype(np.int64), days - block_days)
    cums = np.concatenate([np.zeros((assets, 1)), np.cumsum(log_returns, axis=1)], axis=1)
    block = cums[:, starts + block_days] - cums[:, starts]  # [assets, paths, months]
    return np.transpose(block, (1, 2, 0))
Enter fullscreen mode Exit fullscreen mode

Two details matter once real people use it:

  • One fixed seed per model version. The same answers on the same market data give the same numbers. When someone changes one answer, the plan moves only because of that change, not because of a new random draw.
  • Starts drawn as quantiles of the history. When the data gains a trading day, each block shifts by at most one day instead of being drawn again. A plan doesn't jump overnight for no visible reason.

Then the person's own money runs through every path: pay (with a chance of months without pay, and ups and downs for pay that isn't steady), bills, and a random number of surprise bills of random size around the amount they gave. The emergency fund is kept in savings and never invested.

It's an estimate from past prices, not a forecast. The scenarios are rearranged history, not a prediction of next year.

2. Set the split by a loss the person can sit through

The person says how big a drop they could sit through and which kinds of investments they allow. The model turns that drop into a loss limit. Here a "very bad year" is a fall of the invested money that only 1 in 100 of the scenarios goes past. An optimization step finds the mix of funds with the best typical result whose 1-in-100 fall over 12 months stays within that limit. Gold is capped at 15% of the invested money, each company the person picks at 10%, and picked companies together at half of the US stock share. The model then tries keeping 0 to 100% of the rest in CDs and T-bills at fixed rates and keeps the combination that gives the goals the best chances.

We never show a percentile to the person. The plan shows the dollar side instead: all the money left at the end of a very bad year, pay gaps and bills included, in a line like "In a very bad year (1 in 100) you'd end with about $…". That's a different number from the loss limit, because it counts all the money, not just the invested part. A dollar amount next to "1 in 100" is something a person can weigh against their rent. A loss quantile isn't.

3. Words instead of percentages

Each goal's likelihood is the share of the 2,000 scenarios in which the goal is met, rounded to a whole percent. We could print "83%". We print a word on a five-step scale instead:

export function likelihoodWord(pct: number): string {
  const i = pct < 20 ? 0 : pct < 40 ? 1 : pct < 60 ? 2 : pct < 80 ? 3 : 4;
  return ['Very low', 'Low', 'About 50/50', 'High', 'Very high'][i];
}
Enter fullscreen mode Exit fullscreen mode

The reason is the reader. Most people understand "High" better than "83%": a word says what the number means for their goal, and a percentage reads like a promise. A word next to a five-segment bar says how the model rates the goal without claiming an exact number.

It isn't about sampling error. With 2,000 scenarios per plan, a goal near 80% has a standard error of about 0.9 percentage points (the square root of 0.8 × 0.2 / 2,000), which is small. The bigger doubt is the model itself, because the scenarios are rebuilt from past prices. The scale has its own edge case: 79% and 81% get different words. We accept that, because nobody reads "High" as exact.

The word also sits next to the goal's aim. A row reads "High · aims for Very high" when the plan isn't there yet, or "Very high · on track" when it is.

Example planning card: two savings goals, each with a five-step bar and a word for how likely it is, and a change to try.

4. Screens that start at 320 px

The plan screens live in one React package. The web app uses it, and the Claude version shows the same screens in a chat panel. A chat panel can be narrow on a wide monitor, so the layout responds to its container, not the viewport:

.app {
  container-type: inline-size;
  container-name: app;
}

@container app (min-width: 900px) {
  .columns {
    grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
    gap: 40px;
  }
}
Enter fullscreen mode Exit fullscreen mode

A few rules we kept:

  • Design for 320 px first, then check 320, 390 and 1280 px in light and dark before a change ships.
  • Don't depend on hover. The savings chart has a Bad year / Typical / Good year switch (the Bad year line is the 1-in-20 low for each month, not one scenario) instead of a tooltip you can't reach with a thumb.
  • Give small text buttons a 44 px tap area.
  • Show the likelihood word next to the bar, so nothing depends on color alone.
  • Respect prefers-reduced-motion.

Example chart of savings over 12 months, with money added each month, across 1,000+ market scenarios and their ranges.

What it doesn't do

It doesn't trade or move money, and it doesn't link to bank or brokerage accounts. It covers one year and doesn't plan taxes, debts or retirement accounts. It's built for people in the US, 18+, with amounts in US dollars.

Try it, and tell me what's unclear

DIVID Planner is free during launch. Use it on the web or connect it to Claude (setup steps). You'll need an account (Google or email): divid.ai/planner.

Two questions for anyone who has built simulation tools. Does a word scale feel more honest to you than a percentage, or just vaguer? And how do you show uncertainty to people who never asked for statistics?


Not financial advice. DIVID Planner is a planning tool: you set the goals and limits, the model shows scenarios, and you make the decisions. Claude is a trademark of Anthropic. DIVID is not affiliated with or endorsed by Anthropic.

Top comments (0)