Metadata-Version: 2.5
Name: manyfutures
Version: 0.4.0
Summary: A household's retirement plan run over many simulated futures: the chance it runs short, and what moves it.
Project-URL: Repository, https://github.com/thomaspinder/manyfutures
Project-URL: Issues, https://github.com/thomaspinder/manyfutures/issues
Author: Thomas Pinder
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 2 - Pre-Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Office/Business :: Financial
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: numpy>=2.4.4
Requires-Dist: pydantic>=2.12.5
Description-Content-Type: text/markdown

# manyfutures

Runs a household's retirement plan over many simulated futures: how often the plan runs short, the money left at
the end, and which of its inputs matter most.

The engine was built for a private dashboard and moved here in 0.1.0: the plan and its assumptions, the simulated
futures and the measures over them, with the projected life table of Statistics Netherlands (CBS) for how long each
future lasts. Every amount is in today's euros. The household lives in Amsterdam, under Dutch tax, unless its plan
moves it to one of the other places the engine knows.

From 0.2.0 the plan the engine runs is a Chart: the household's life laid out as Blocks of years on six Lanes, its
jobs and pensions, its home, its spending, its children and its debts. A Scenario, the household's decisions as 0.1
took them, is drawn as one Chart and runs as it. From 0.3.0 a Block may end when the household's money reaches a
level, in a different year in each future, and start or end where another Block does. From 0.4.0 a job may be lost:
a Job loss Block on the Risks Lane runs while a Job, and in some futures a layoff puts it out of work for a while.

## Install

```bash
pip install manyfutures
```

or `uv add manyfutures`. Python 3.11 or later; the only dependencies are numpy and pydantic.

## An example

A made-up household: two adults born in 1990 and 1992, earning 180,000 a year between them and spending 40,000, with
one child, a home bought in 2024 with a 350,000 mortgage, and a student loan of £20,000. Its plan is the `Scenario`'s
defaults: retiring at 58 on 40,000 a year, 30,000 of it Must-have Spending, with 40,000 a year of Bridge Income until
the pension at 67 and a downsize to a 350,000 home.

```python
from datetime import date

import numpy as np
from manyfutures import Assumptions, Household, Profile, Scenario, Start, readouts, scenario
from manyfutures.models import Adult, MortgageTerms, Position, StudentLoanTerms

profile = Profile(first_adult=Adult(born=date(1990, 1, 1), life_series="men"), partner=Adult(born=1992, life_series="women"))
household = Household(  # its facts; the Assumptions are the engine's own model constants, each with its default
    gross_income=180_000, working_years_spending=40_000, pension=20_000, child_birth_ages=(33,),
    daycare_days_per_week=2, daycare_monthly_per_day=500, childcare_reimbursement_rate=0.33, after_school_monthly=300,
    mortgage=MortgageTerms(rate=0.035, term=30, start=date(2024, 1, 1)),
    student_loan=StudentLoanTerms(rate=0.03, repayment=500, repayment_day=1),
)
position = Position(portfolio=350_000, crypto=0, cash_buffer=30_000, home_value=520_000, mortgage_balance=340_000,
                    student_loan=20_000)
returns = np.random.default_rng(1).normal(0.07, 0.17, 120)  # made up: one a year, 1906 to 2025
start = Start(today=date(2026, 6, 1), position=position, returns=returns, return_years=np.arange(1906, 2026))
metrics = scenario.run(Scenario(), start, household=household, profile=profile, assumptions=Assumptions()).metrics
print("Chance of running short:", readouts.chance(metrics.chance_of_running_short, metrics.chance_of_running_short_se))
```

It prints:

```text
Chance of running short: 21.7% ± 0.6
```

About one future in five runs short: in at least one of its years more than a tenth of the Must-have Spending goes
unfunded. The ± is one standard error of the simulation, in points, over the 5,000 futures a `Scenario` runs by
default.

The inputs:

- **`Profile`**: the two adults, when each was born and the life table's series their chances are read from.
- **`Household`**: the household's facts, which have no default: its pay and spending while it works, its pension,
  its children and the terms of its mortgage and student loan.
- **`Start`**: where the plan begins, the household's money and debts on a date (a `Position`) and the market's past
  returns, each with its calendar year. Here the returns are made up; a real run is handed a history such as the
  S&P 500's. The engine fetches nothing.
- **`Scenario`**: the household's decisions, each with a default, so a Scenario names only those that differ.
- **`Assumptions`**: the model's constants (inflation, investment costs, the life table, the Running Short threshold
  and the rest), each with the engine's default, so `Assumptions()` is the engine's own.

`scenario.run` draws the Scenario as a `Chart` (`Chart.from_scenario`) and runs that Chart, as the example below runs
one drawn by hand. It gives an `Outcome`: its `metrics` (the chance of running short, how deep and for how long, the
money left at the end, the same readouts over the futures that last to 95), the End Ages and the portfolio at each
age. `scenario.return_band` runs the same plan with every year's return a point lower and a point higher,
`scenario.compare` runs it in every place, and `scenario.inputs_that_matter` moves one of its hand-set inputs at a
time.

## A Chart drawn by hand

The same household with a plan of its own: the first adult works full time to 55, or until the money reaches 25 times
the household's spending if that comes first, and then consults for 30,000 a year to 67; the partner works to 60, the
pension of 20,000 starts at 67, and at 58 they sell the home in Amsterdam and buy one in Barcelona for 300,000. While
the first adult works full time a layoff may come, as often and for as long as the Dutch figures say. Every age on a
Chart is the first adult's, so the partner, born two years later, stops at about 58. The home, the mortgage and the loan
running today are stated as the Start has them: the home at 520,000, the 340,000 owed today over the 28 years the
mortgage's term has left, and the £20,000 of the student loan, in pounds. Run after the example above, on the Profile
and the Start it made:

```python
from manyfutures import Chart, run
from manyfutures.chart import Anchor, AtAge, AtWealth, Child, Job, JobLoss, Lane, Level, Loan, Mortgage, Own, Pension, SideIncome, SubLane
lane = lambda *sub_lanes: Lane(sub_lanes=tuple(SubLane(blocks=blocks) for blocks in sub_lanes))  # a tuple a Sub-lane
full_time, job_ends = (AtAge(age=55), AtWealth(level=25, unit="spending")), Anchor(block="Job", boundary="end")
chart = Chart(name="Consulting, Barcelona at 58", spending=lane((Level(name="Spending", amount=40_000, start=36),)),
    income=lane((Job(name="Job", gross_pay=100_000, adult="first_adult", start=36, end=full_time),
                 SideIncome(name="Consulting", gross_pay=30_000, start=job_ends, end=(AtAge(age=67),)),
                 Pension(name="Pension", amount=20_000, start=67)),
                (Job(name="Partner's job", gross_pay=80_000, adult="partner", start=36, end=(AtAge(age=60),)),)),
    home=lane((Own(name="Amsterdam", place="Amsterdam", value=520_000, start=36, end=(AtAge(age=58),)),
               Own(name="Barcelona", place="Barcelona", value=300_000, start=58))),
    must_have_spending=30_000, people=lane((Child(name="Child", start=32, after_school_monthly=300),)),
    debts=lane((Mortgage(name="Mortgage", amount=340_000, rate=0.035, term=28, taken_out=start.today, start=36),),
               (Loan(name="Student loan", balance=20_000, monthly_repayment=500, rate=0.03, currency="GBP", start=36),)),
    risks=lane((JobLoss(name="Job loss", job="Job", start=Anchor(block="Job", boundary="start"), end=(job_ends,)),)))
metrics = (outcome := run(chart, profile, start, Assumptions())).metrics  # the Outcome, and its readouts
print("Chance of running short:", readouts.chance(metrics.chance_of_running_short, metrics.chance_of_running_short_se))
print(outcome.bands[0].block, readouts.band(outcome.bands[0]))  # the Job's end, which the Consulting's start follows
print("Laid off at least once:", readouts.chance(metrics.share_laid_off, metrics.share_laid_off_se))
```

It prints:

```text
Chance of running short: 6.0% ± 0.3
Job ends by 52 in one future in ten and by 55 in nine in ten
Laid off at least once: 37.8% ± 0.7
```

About one future in seventeen runs short, where without the Job loss Block one in nineteen does, 5.3% ± 0.3. A Block is
a stretch of years with a kind and its own inputs, from the first adult's birthday at its start age to the one at its
end age; the settings that apply to every year are the Chart's, Must-have Spending, which has no default, and the
Spending Rule, the Last-resort Sale, the precision and the rest, which have.

The Job ends by its End Rules at whichever comes first: at 55, or at the end of the first year in which liquid money,
the portfolio and the cash reserve in today's euros, reaches 25 times the year's Spending Level, a million euros. So it
ends in a different year in each future, by 52 in one future in ten and at 55, its latest, in at least half. The
Consulting starts at the Job's end, an `Anchor`, in the year after the Job's last, and moves with it. Each run's
`Outcome.bands` gives where each such boundary falls over the futures, here the Job's end, the Consulting's start and
the Job loss Block's end: the first adult's age by which it has come in one future in ten, in half of them and in nine
in ten, and the share of futures in which it has not come by the end of the plan, a future whose End Age comes first
among them. `readouts.band` says it in words, an age said once for the percentiles that share it.

The Job loss Block is on the Risks Lane. It names the Job it runs while, its start anchored to the Job's start and its
end to the Job's end, so in each future it runs in exactly the Job's years, whichever End Rule ends the Job there. It
states no input of its own, so its inputs are the documented preset's, the default of three (`chart.JOB_LOSS_PRESETS`:
light, documented and severe, each with its sources): at the start of each year the Job runs, unless a spell out of work
still runs, a layoff comes at a chance of 2% and puts the first adult out of work for 7 months; the benefit pays 70% of
the pay before while out of work, at most 56,620.56 a year, the Dutch WW's cap, for up to 12 months, and from the
spell's end the Job pays 88% of its pay before, to its end. In a year of equity stress, one whose drawn real return is
at or below the 20th percentile of the history's, a layoff is two and a half times as likely and its spell half as long
again. The Job is the first adult's own, so a layoff stops all of its pay, the Block's share of the pay at its default
of one; on a Job that is a joint income the Block states the share that is the adult at risk's. The partner's job has no
Job loss Block, so a layoff loses the first adult's pay alone. In 37.8% of the futures at least one layoff comes, and
among them the median is out of work for 0.9 years in all: `readouts.layoffs` and `readouts.years_out_of_work` say both
in words.

The Blocks on one Sub-lane never overlap, and a Lane's Sub-lanes add up: while both Jobs run the household earns both,
and a year is a working year while either runs, so the years to 60 are working years with the first adult consulting.
Consulting is Side income, which earns without making a working year, as the Bridge Income does. Home and Spending have
one Sub-lane each, which must cover every year to the model's last age, 100: the second Home Block is the move, the home
owned before it sold with its place's selling costs and the new one bought with Barcelona's buying costs at the end of
the year at 57, which is lived in Amsterdam, and Barcelona's taxes and prices from 58: the Place changes when the home
does. A gap on any other Sub-lane means none of its thing, no pay, no child, no debt: the partner's Sub-lane earns
nothing from 60. The child was born in the year at 32, from the first adult's 32nd birthday to the 33rd, and is past
daycare today.

`Chart.problems` lists everything wrong with a Chart at once, each problem naming its Lane, Sub-lane and Block, and
`run` refuses a Chart with any, raising `ChartRefused`, and a Block running today that does not state the Start: the
home's value, what is owed on the mortgage and on the loan. In this release a Block starts at an age or at another
Block's start or end, an `Anchor`, and ends at an age, after a number of years, at an Anchor or when liquid money
reaches a level, an amount in today's euros or a multiple of the Spending Level, at the first or the last of its End
Rules as its combiner says; a Block anchored at both ends to one other, start to start and end to end, runs while it. A
home may change more than once, a Mortgage starting with a new home is its change of home's loan, and a Rent Block rents
one; a change of home lives its year where the home sold is and the new home's Place from the next, and one that
follows a Block ending at a level comes in each future's own year. A Job loss Block runs while the Job it names, and a
Job has one at most. A first home outside Amsterdam, a home bought after renting and any other new mortgage are refused
by name as not yet honoured. A Chart is written and read as JSON with `Chart.model_dump_json` and
`Chart.model_validate_json`, and loses nothing.

At the package's top level beside `run`, `return_band(chart, profile, start, assumptions)` runs a Chart with every
year's return a point lower and a point higher, on the same futures, `compare(chart, profile, start, assumptions)`
runs it with its move to each of the nine places the engine knows, and `inputs_that_matter(chart, profile, start,
assumptions)` runs it again with each of its inputs moved, one at a time, on the same futures: each Block's start and
end after today a year earlier and later, a number of years by one, a wealth level by a tenth, the spending, the
pension and the children's costs by the steps the Scenario's table moves them, the pay, a home's value and a rent by a
tenth, a Mortgage's rate by a point and its term by a year, a Job loss Block's chance a year, pay on return and benefit
by a tenth and its months out of work by one, and the Assumptions. It gives the change in the chance of running short
of each, with its error bar, largest first.

## The engine's words and decisions

- [GLOSSARY.md](GLOSSARY.md): the words, each defined once.
- [docs/decisions.md](docs/decisions.md): the modelling decisions, numbered as they were made, each with its rule,
  its reason and the code and tests that hold it.
- [docs/adr/](docs/adr/): the decisions that are not to be reopened, the End Age's among them.
- [CHANGELOG.md](CHANGELOG.md): what each release changed, and whether it moved the headline.

## Licence

MIT: see [LICENSE](LICENSE). The bundled life table is CBS's (CC BY 4.0) and the US inflation the BLS's, through FRED
(public domain); each file's header says where it comes from.
