Case study
DiamondHands
A research terminal for long-term investors. Backtest a portfolio of US stocks, ETFs and crypto over the last decade, project it forward with a Monte Carlo simulation, and paper-trade the idea before any real money moves.
- Role
- Sole developer, design to deploy
- Period
- Rebuilt April 2026 – present
- Stack
- Next.js 15, Supabase Postgres, Python ingest, GitHub Actions, Vercel
- Status
- Live · v0.1 · backtest needs no account
The problem
"What if I had put money into this every month for ten years?" is the question a long-term investor actually has, and most free tools answer it badly. They take a lump sum, ignore contributions and rebalancing, report one headline return, and say nothing about what the number leaves out.
DiamondHands answers it with contributions, rebalancing and honest metrics, lets you push the same portfolio forward through thousands of simulated futures, and then lets you run it as a paper portfolio with a real transaction log.
What I built
A Next.js application on Vercel, a Supabase Postgres database, and a Python worker that GitHub Actions runs every night to pull end-of-day prices. Three parts carry the design.
- A pure backtest engine. Plain TypeScript functions with no input or output of their own: prices and parameters in, an equity curve and metrics out. It runs in the browser for the interactive page, and the script that builds the demo account runs the same functions in Node to compute its saved scenarios.
- A Monte Carlo simulator. It turns the portfolio's history into monthly returns and samples them with replacement to build hundreds or thousands of forward paths, then reports percentile bands and the odds of losing money or doubling it. Resampling real months keeps the fat tails a normal-distribution model would erase.
- Paper trading on an immutable log. Balances are never stored. Cash, positions and average cost are replayed from the transaction history on every read, so a stored copy can never disagree with the record it came from. CSV imports from the major brokers land as transactions too.
How it works
Prices change once a night, so everything is arranged around that. Fetching prices happens on the server, in one request per question, with a URL built so that the CDN can answer the next person who asks the same thing. Computing the backtest stays in the browser, because the engine is pure, the data is already in the page, and the visitor's processor costs nothing.
The metrics are defined to survive contributions. Compound annual growth is the mean daily log return net of that day's deposit, annualised over 252 trading days, rather than end value over start value, which a monthly deposit would inflate. Yearly and monthly returns both chain from the previous period's close, so every trading day belongs to exactly one period and the periods compound back to the total. Public share links render the result saved with the scenario, so a shared page is fast and never recomputes.
See it run
No video yet. The backtester is the demo and needs no account: diamondhands.space/backtest. Simulation is at diamondhands.space/simulate.
The hard part
A ten-year, three-ticker backtest charted a decade and used sixteen months of it.
The query asked Postgres, through Supabase's REST layer, for 7,296 daily prices. That
layer caps a response at 1,000 rows, and it does not fail when it caps: it returns a
normal 200 with a shorter body and a content-range header that nothing was
reading. The engine received 1,000 rows, from May 2016 to August 2017, and charted
them as if they were the whole decade.
The fix was a helper that pages every price read until a page comes back short, and one detail made the difference between fixed and nearly fixed. Paging by offset over rows ordered by date alone lets rows that share a date swap places between requests, which silently duplicates some prices and drops others at the page boundaries. The reads now order by date and asset, so every row has one position.
The same lesson came back twice more. The nightly price job logged success for eighteen months on a renamed crypto ticker, because Yahoo answers a request for a dead symbol by repeating its last price; the job now checks that each price actually advanced and fails the run by name when one has not. And the yearly returns table left the move between the last trading day of December and the first of January out of every year, by up to 1.77 percentage points, which only surfaced when a separately written monthly heatmap disagreed with it.
Numbers
Only numbers that can be checked, each next to what produced it.
The tests cover the three pure engines, and every expected figure in them is computed by hand from the fixture rather than recorded from an earlier run, so they prove the maths is right, not merely unchanged.
What I would do differently
- Survivorship bias is built in. The universe is the 161 tickers that trade today, so a 2016 backtest runs on a list chosen with hindsight. Measured, the equal-weighted universe beat SPY by 534 percentage points over the decade, and most of that gap comes from a handful of names. A universe that includes delisted names is the fix, and it needs a data source that has them.
- The history starts in May 2016, not earlier. The one-time backfill pulled ten years back from the day it ran, so asking for 2015 quietly starts later. The backtest page now says which holding moved the start date; the simulator and paper trading still do not.
- About half the schema is reserved rather than used. Tables for strategies, watchlists and stored holdings were created up front for later phases and nothing writes them. It costs nothing at runtime, but it is the first thing a careful code reader asks about.