Program note · Writing · 6 Sep 2026
I replayed the money I actually invested into portfolios I never owned
Divvy is a Python backtester that replays your real contribution dates and amounts into a counterfactual portfolio, dividends included.

Every backtester I could find answers a question I never asked: what if you had put $500 in on the first of every month, without fail, for ten years? I did not do that. I doubt anyone does. I skipped months when money was tight and dropped lump sums in when a bonus landed. My investing history is lumpy, and any tool that smooths it into a tidy monthly schedule is backtesting somebody else.
Divvy takes the money I actually invested, on the dates I actually invested it, and replays it into a portfolio I did not own. Then it tells me what dividends and returns that portfolio would have paid. It is a Python package (divvy-backtest on PyPI, built with uv), a CLI, and a local Streamlit app.
One thing to say up front: it is a backtester, not advice. It answers "what already happened over this window," and one window says nothing about the next.
The whole idea is a two-column table
The engine's only required input is a table with two columns: date, contributed. That is the contract. Everything upstream of the engine exists to produce it, and nothing downstream cares where it came from.
Three things produce it today. divvy.contributions reads any broker's export as a generic CSV, tolerant of header names, summing same-day rows. divvy.ledger is a Fidelity-specific parser that classifies raw transaction rows: "YOU BOUGHT" is new money, "REINVESTMENT" is not. It emits the same calendar, and also the dividends I really received, so my actual account can sit in the comparison table as its own row next to the hypotheticals. And synthetic.monthly_contributions generates the flat monthly schedule for people who want to try the tool without exporting anything.
Other brokers slot in as sibling parsers. The rest of the pipeline never knows.
Replay, not simulation
engine.run_backtest is a small event loop. It builds one sorted list of events: a contribution for every row in the calendar, and a dividend for every ex-dividend date of every symbol in the bucket, pulled from yfinance and cached as parquet. Then it walks the list in order.
A contribution is split by target weight and bought at that day's close. If the market was shut, it rolls forward to the next trading day. A dividend pays shares_held * per_share in cash, which is immediately reinvested into the same symbol at that day's close. That is DRIP, and it is why holdings drift away from target weights over time, exactly as they do in a real account.
The detail that matters most is same-day ordering. If a contribution and a dividend land on the same date, the dividend settles first, because shares bought today were not held on the record date. Get that backwards and you pay yourself dividends on shares you did not own yet, and the error compounds for years.
Three realism knobs default to off. A flat dividend_tax_rate reinvests only the after-tax cash (gross is still reported). An expense_ratio shaves shares continuously over the days between events. And rebalance injects periodic events that reset holdings to target weights.
Money-weighted, on purpose
Most performance numbers you see are time-weighted, which deliberately strips out when you added money so a fund manager can be judged on picks alone. That is the wrong metric here. My timing is the point. If I happened to have cash when a fund was cheap, I want to see that reflected.
So report.summarize computes XIRR. Every contribution is a negative cashflow on its real date, the ending value is a single positive cashflow on the last price date, and a Newton solve finds the annualized rate that zeroes the net present value. Because every bucket is fed the identical calendar, the XIRRs are apples to apples. Two portfolios can show the same total return and very different XIRRs if one of them happened to be cheap when I happened to be buying.
Dividends get reported as a first-class metric, twice: the lifetime total, and trailing-twelve-month income, which is the run rate a dividend investor actually watches. Max drawdown and annualized volatility are computed on a contribution-free growth-of-$1 NAV, so they describe the basket's own risk and not my cash-flow luck.
The Experiment Lab
divvy ui launches a Streamlit app wrapped around the same run_backtest call. The sidebar picks a contribution source (synthetic DCA or an uploaded CSV), exposes up to four portfolios as editable ticker and weight tables, and holds the realism knobs. Hit Run and it renders the headline metrics, the comparison table with an SPY benchmark row, and charts for value, cumulative dividends, and dividends by year. Market data is memoized with st.cache_data, so tweaking a weight and re-running is quick.
Private by default
The real numbers never leave the machine. data/, results/, personal buckets/, and .env are gitignored; only code and fake examples/ live in the repo. The hosted demo on Streamlit Community Cloud runs on synthetic data only, and the Fidelity importer is CLI-only by design. There is no upload-your-brokerage-statement path on the public site.
What it is not
Divvy is a replay of the past. It is not a forecast and it is not advice. Forward planning lives in a separate divvy project command (a closed-form target plus a Monte Carlo range), kept in its own module so nobody mistakes an assumption-driven projection for something that actually happened.
Try the demo at divvy-backtest.streamlit.app, install it with pip install divvy-backtest (PyPI), or read the code at github.com/DanMat/Divvy. If you have a broker export you want supported, it is one parser away.