Skip to main content

Testfolio-compatible synthetic leveraged-ETF return series, with proper borrow-cost modeling. Calibrated against real TQQQ to within 5%.

Project description

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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sma200_bt-0.1.0.tar.gz (10.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sma200_bt-0.1.0-py3-none-any.whl (8.8 kB view details)

Uploaded Python 3

File details

Details for the file sma200_bt-0.1.0.tar.gz.

File metadata

  • Download URL: sma200_bt-0.1.0.tar.gz
  • Upload date:
  • Size: 10.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.3

File hashes

Hashes for sma200_bt-0.1.0.tar.gz
Algorithm Hash digest
SHA256 6e6c23dcbbffb20fee8d2079ab63efbd133d33e2393a593d960978141db60ac9
MD5 74852264f1bec12c33f410ed964e41b2
BLAKE2b-256 032858fe807504ebe77e8742e579541cf73684ec94625c7caa1fba8f90b6e97c

See more details on using hashes here.

File details

Details for the file sma200_bt-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: sma200_bt-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 8.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.3

File hashes

Hashes for sma200_bt-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f9599675aa4c499ef836cac890fbac0c154eba178c166683a0f90d3508822cd1
MD5 16a6d8d568065bfe8c1f2f14967a530f
BLAKE2b-256 5e0dc572f568447e042aebfaf7f818b9def7380cc6e4b6fb6106078e2c1aa965

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page