Skip to main content

sma200-bt

Testfolio-compatible synthetic leveraged-ETF return series for Python, with proper borrow-cost modeling. Calibrated against real TQQQ to within 5% over the 2015-2024 window.

Most synthetic LETF backtests floating around Reddit, Bogleheads, and finance blogs use the simplified formula L * daily_return - ER / 252, which omits the daily borrow cost real leveraged ETFs pay on their swap-financed exposure. That simplified formula overstates real TQQQ by 62% over 2015-2024.

This package implements the full formula:

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

The third term models the swap-financing cost that ProShares (and every other swap-based LETF issuer) pays on the leveraged portion every day. It matches what Testfolio does internally and what actual fund NAVs reflect.

Calibration vs real TQQQ (2015-2024)

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

The +5% residual is plausibly tracking error plus the 40bps spread being a midpoint estimate of ProShares' actual swap pricing. Well within the noise band for research purposes.

Install

pip install git+https://github.com/prismlfx/sma200-bt.git

(PyPI release coming once the API stabilizes.)

Usage

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

# Pull underlying daily returns
qqq = yf.download("QQQ", start="1999-03-10", auto_adjust=False, progress=False)["Adj Close"]
qqq_ret = qqq.pct_change().dropna()

# Pull the borrow rate series (13-week T-bill via ^IRX)
tbill = fetch_tbill_rate(qqq_ret.index[0], qqq_ret.index[-1])

# Build synthetic TQQQ with proper borrow cost
tqqq_syn = synthetic_letf_returns(
    qqq_ret,
    leverage=3.0,
    expense_ratio=0.0086,     # TQQQ ER
    borrow_rate=tbill,
    borrow_spread=0.0040,     # 40bps over T-bill, ProShares swap-typical
)

# Compound into an equity curve
equity = compound(tqqq_syn, initial=10000.0)
print(f"Final value: ${equity.iloc[-1]:,.0f}")

Why borrow cost matters

When short rates are near zero (most of 2009-2021), the borrow term is tiny and the simple formula gives nearly identical results. But during high-rate regimes:

  • 1970s stagflation: ~8% T-bill rate → 3x funds paid ~16% annualized in financing drag
  • Early 2000s: ~3-5% T-bill rate → ~6-10% annual drag
  • 2022 onward: Fed Funds hiked from 0.25% to 5.5% → meaningful drag returned

Synthetic backtests that ignore borrow cost produce fantasy numbers for these periods. A synthetic 3x S&P "earning billions since 1940" is a methodology artifact, not a real result.

Compatibility with Testfolio

sma200-bt uses the same daily formula as Testfolio's leveraged-fund synthesis. Results will not be bit-for-bit identical (Testfolio uses Fed Funds Effective Rate where this library uses ^IRX as the default, and the spread assumption may differ), but they will match to within a few basis points of CAGR over multi-decade windows.

What this library is NOT

  • Not a portfolio backtest engine. Use bt, vectorbt, or zipline for that. This library produces a return series; what you do with it is up to you.
  • Not investment advice. This is a research tool. Don't size positions based on backtest output. See the LICENSE for the full disclaimer.
  • Not a rigorous model of inverse leverage. The formula approximates inverse funds (e.g. SH, PSQ) but real inverse-fund mechanics differ. Use Testfolio for production-grade inverse backtests.

API reference

synthetic_letf_returns(underlying_returns, leverage, expense_ratio, borrow_rate=None, borrow_spread=0.0040)

Build a synthetic leveraged-ETF daily return series.

  • underlying_returns: pd.Series of daily returns, DatetimeIndex
  • leverage: float (2.0 for 2x, 3.0 for 3x, -1.0 for -1x inverse)
  • expense_ratio: annual ER as decimal (0.0086 for 0.86%)
  • borrow_rate: None, float, or pd.Series of daily rates as decimal. Use fetch_tbill_rate() for ^IRX. None falls back to simple-mode (logs a warning).
  • borrow_spread: float, additional bps above base rate. Default 0.0040 (40bps).

Returns: pd.Series of daily LETF returns aligned to underlying_returns.index.

fetch_tbill_rate(start, end, fallback_rate=0.04)

Pull the 13-week T-bill rate (^IRX via yfinance) as a daily decimal series.

compound(returns, initial=1.0)

Compound a daily return series into an equity curve.

Background

This library was extracted from internal research at sma200.trade after discovering that synthetic LETF numbers we'd published on Reddit had been computed with the simplified formula. Full writeup of the bug, the fix, and what the corrected numbers actually show about the SMA200 trend filter: The Hidden Cost Every Leveraged-ETF Backtest Ignores.

Run the tests

git clone https://github.com/prismlfx/sma200-bt.git
cd sma200-bt
pip install -e ".[dev]"
pytest

Six test cases pin the formula behavior across: simple-mode parity, high-rate borrow drag, zero-rate parity, inverse leverage edge case, Series-based rate alignment, and compound-helper correctness.

License

MIT. See LICENSE. For informational and educational use only; not investment advice.

Issues, PRs, discussion

Open an issue at https://github.com/prismlfx/sma200-bt/issues. PRs welcome for additional calibration data, real-fund cross-checks, or extending the inverse-leverage modeling.

Metadata

Release files for sma200-bt 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sma200-bt 0.1.0
File Size Uploaded
sma200_bt-0.1.0.tar.gz 10.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for sma200-bt 0.1.0
File Interpreter ABI Platform
sma200_bt-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 19.4 kB

Release files / sma200_bt-0.1.0.tar.gz

Download URL sma200_bt-0.1.0.tar.gz
Size 10.6 kB
Tags Source
SHA-256 checksum
How to use checksums
6e6c23dcbbffb20fee8d2079ab63efbd133d33e2393a593d960978141db60ac9
BLAKE2b-256 checksum
How to use checksums
032858fe807504ebe77e8742e579541cf73684ec94625c7caa1fba8f90b6e97c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.3

Release files / sma200_bt-0.1.0-py3-none-any.whl

Download URL sma200_bt-0.1.0-py3-none-any.whl
Size 8.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
f9599675aa4c499ef836cac890fbac0c154eba178c166683a0f90d3508822cd1
BLAKE2b-256 checksum
How to use checksums
5e0dc572f568447e042aebfaf7f818b9def7380cc6e4b6fb6106078e2c1aa965
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.3

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page