DEV Community

Nidhi
Nidhi

Posted on

Building a $0, no-backend portfolio backtester and the data-licensing rabbit hole

I built BacktestCard, a static-site backtester for Indian investors who measure in USD. It has no framework, no backend and no dependencies. Three things were more interesting than I expected.

1. Data licensing decides your architecture

  • Yahoo, Stooq and NSE all forbid redistribution.
  • FRED was the surprise. Its ToS bars storing or caching FRED content, even for public-domain series.
  • So the build script fetches from the publishers themselves:
    • Federal Reserve DDP for H.10 and H.15
    • BLS flat files
    • the OECD SDMX API
    • the World Bank Pink Sheet (CC BY 4.0)
  • The result is a 108 KB JSON file, refreshed monthly by a GitHub Action.
  • Licensed indices such as NIFTY come only through a user CSV upload. It's parsed with FileReader and never sent anywhere.

2. Test your maths with a second implementation

The engine is about 200 lines of plain JS. The tests include a separate pandas implementation that must match every statistic to 1e-9. On day one it disagreed on bond returns.

Bond returns are modelled from yields. Each month you buy a 10-year par bond and reprice it at the new yield. I had added coupon/12 on top of a dirty price that already included accrued interest, which added 1.4%/yr of phantom return to a 60/40 mix. The fix uses a closed form:

// remaining flows at j/2 - 1/12 years => dirty = (1+y/2)^(1/6) * P_20(y)
return Math.pow(1 + y / 2, 1 / 6) * price20 - 1;
Enter fullscreen mode Exit fullscreen mode

3. Share state belongs in the URL

  • The whole portfolio state is in the hash, e.g. #p=us_eq:60,in_eq:40&s=2000-01&c=USD.
  • It's whitelisted on decode, so __proto__:5 gets dropped.
  • The share card is a 1200×630 canvas exported with toDataURL.
  • ?embed=1 gives a compact iframe with a badge, which is the growth loop.

Code (MIT): https://github.com/Nidhi-bhai/backtestcard. It's educational only, not investment advice.

Top comments (1)

Collapse
 
arhancanli profile image
Arhan Canli •

The second-implementation test catching the double-counted accrued interest is a good result, and it also shows the limit of that check: the pandas version has to agree with the JS to 1e-9, but both encode the same model. If the model itself is off, both agree on the wrong number. The par-bond-repriced-monthly model assumes the 10-year yield is the yield of a bond whose maturity resets to exactly 10 years each month, while in the model you describe it ages to 9y11m before the yield is applied. That is a modelling choice with a real size (rolldown and convexity terms), and only an outside series shows it.

A cheap outside anchor: compare your annual 10-year bond returns for the US with a published historical table (Damodaran's annual returns on the 10-year T-bond is free) and report the yearly gap, not just the cumulative one. If the gap has a sign that persists in falling-yield years, that is the model's convexity, not a bug.

Since you refresh from the publishers monthly: it may also be worth stamping each data point with the vintage it was fetched in, because H.15 and BLS series get revised, and a backtest shown on a share card should be reproducible from the hash in the URL.