sma200.trade
For informational and educational use only. Not investment advice. Past performance does not predict future returns. Read full disclaimer →

I open-sourced the synthetic-LETF return engine. It includes the borrow-cost fix.

· 6 min read · by Christian

A few weeks ago I posted a piece explaining why almost every synthetic leveraged-ETF backtest you see on Reddit, Bogleheads, or finance blogs overstates returns by roughly 60% over a 10-year window. The bug is a single missing term in the daily-return formula: the daily borrow cost real LETFs pay on their swap-financed exposure. Skip that term and you get TQQQ at 33x over 2015 to 2024 instead of the real fund's 20.4x.

Today I am open-sourcing the corrected engine I built to fix that. It is a small Python package called sma200-bt, MIT-licensed, and you can install it with one pip command:

pip install sma200-bt

The package source lives at github.com/prismlfx/sma200-bt. The release on PyPI is 0.1.0 and the API is stable enough that I am pinning it for the research I publish on /research.

What it does

sma200-bt produces Testfolio-compatible synthetic leveraged-ETF return series for any underlying, any leverage multiple, going as far back as your underlying data does. It models the same three terms a real LETF earns or pays each day:

daily_return = L * underlying_return
             - ER / 252
             - (L - 1) * (borrow_rate + spread) / 252

The first two terms are what every naive backtest already does: scale the underlying return by leverage, deduct the daily expense-ratio drag. The third term is what almost nobody implements: the financing cost the issuer pays on the swap-funded portion of the exposure. That swap pays a floating short rate plus a spread to the counterparty bank, and the fund passes the cost through to NAV every day.

Skip term three and your synthetic series compounds at the wrong rate. Include it correctly with the right short rate (^IRX for 13-week T-bills works well) and your series matches real fund NAVs to within tracking-error noise.

Calibration

Same table from the original piece, but worth restating because this is the proof the engine works:

Method Final wealth multiple ($1 →) Drift vs real TQQQ
Real TQQQ (ProShares fund, 2015 to 2024) 20.41x reference
Simple formula (no borrow cost) 33.00x +62%
sma200-bt (^IRX + 40bps spread) 21.46x +5%

The +5% residual is plausibly the tracking error every LETF has against its theoretical 3x exposure, plus the 40bps spread being a midpoint estimate of ProShares' actual swap pricing. Well within the noise band for any research purpose.

The same calibration works on UPRO, SOXL, QLD, and TMF. I included unit tests against each in the repo.

How to use it

Pulling a synthetic TQQQ back to 1999 (the underlying's earliest data) is five lines:

import yfinance as yf
from sma200_bt import synthetic_letf_returns, fetch_tbill_rate, compound

qqq = yf.download("QQQ", start="1999-03-10", auto_adjust=False, progress=False)["Adj Close"]
qqq_ret = qqq.pct_change().dropna()
tbill = fetch_tbill_rate(qqq_ret.index[0], qqq_ret.index[-1])

tqqq_syn = synthetic_letf_returns(
    qqq_ret, leverage=3.0, expense_ratio=0.0095,
    tbill_rate=tbill, spread_bps=40,
)

wealth = compound(tqqq_syn)
print(f"$1 in synthetic TQQQ since 1999: ${wealth[-1]:.2f}")

Plug in any underlying and any leverage. The function does not care whether you want 2x SPY (~SSO), 3x QQQ (~TQQQ), or 3x Russell 2000 (~URTY); the math is the same.

What it does not do

A few honest limitations:

1. It assumes the swap rate is the short T-bill plus a constant spread. In reality, issuers re-price swaps periodically and the spread varies with counterparty creditworthiness, vol regime, and fund AUM. The 40bps default is a reasonable midpoint for the 2015-2024 window on TQQQ; you may want to tune it for other funds or other eras. I expose the spread_bps parameter so you can.

2. It does not model dividends. TLT, SPY, and other dividend-paying underlyings have non-trivial dividend yields that LETF holders do not receive (the leveraged exposure is on price, not total return). For deep-history backtests on dividend-heavy underlyings, you will need to handle that separately.

3. It does not model creation/redemption flow effects. Real LETFs have small NAV-tracking distortions during heavy AUM swings. These are noise at the daily level but compound enough to explain part of the residual tracking error against real funds.

4. It does not model leveraged-ETF decay caused by daily rebalancing in vol regimes. This is implicit in the daily compounding of L * underlying_return. The result is your synthetic series will show the same volatility drag a real LETF shows over time. This is correct, not a bug, but worth pointing out for newcomers expecting "leverage" to mean "constant 3x of the underlying."

Why free and open

This site is built on the idea that the SMA200 trend filter is one of the few honest tools in retail investing literature, and that the right way to defend it is to ship the math so anyone can check the work. I cannot ask people to take my word that "synthetic TQQQ does X under the SMA200 filter" if my numbers are based on a buggy formula. So the formula is in your hands now too.

If you find a bug, file an issue on GitHub. If you have a better calibration for a specific fund I have not tuned, send a PR. If you want the methodology to grow to cover futures-based ETFs (KBA, CASH-equivalents), that is on the roadmap.

If you want the live SMA200 status for any US ticker plus free email alerts when it crosses, that is what sma200.trade is here for. Set an alert on whichever ticker you would actually act on. Both products are free and will stay free.