← Back to Blog

The Retail Quant Stack: Coinbase, ccxt, vectorbt, And A Working Momentum Backtest

The Retail Quant Stack: Coinbase, ccxt, vectorbt, And A Working Momentum Backtest

The previous two posts on this site covered who actually makes the money in quant (Inside Quadrature Capital and Radix Trading) and why their edge persists (Why Top Quant Desks Outperform). This post is the runnable companion. A specific GitHub stack you can install in an afternoon, a concrete backtest you can run today, and the published academic results that tell you what kind of returns are realistic before you put real money on the table.

The frame is honest. Retail does not beat Citadel. Retail does not need to. Retail needs to beat the median retail trader, and the median retail trader is not running honest backtests, not subtracting realistic fees, and not respecting research discipline. If you do those three things, you are above the curve.

The stack, one tool per job

Layer Library What it does License
Data ccxt Unified API across 100+ exchanges, including Coinbase Advanced MIT
Backtesting (research) vectorbt Vectorized, fast, parameter-sweep oriented Apache 2.0
Backtesting (deployment) Backtesting.py Simple API, good for single-strategy work AGPL-3.0
Live execution (crypto) freqtrade Full bot framework with paper, dry-run, and live modes GPL-3.0
Native Coinbase access coinbase/coinbase-advanced-py Official Python SDK, REST and WebSocket Apache 2.0
Data analysis pandas, polars, NumPy The default for time-series work various
Plotting plotly, matplotlib Self-explanatory various

Two notes worth flagging up front. ccxt is the right place to start for cross-exchange research because it normalizes naming, market metadata, and historical OHLCV calls across venues. The official Coinbase Advanced SDK is the right place to be when you go live, because it handles authentication, rate limits, and websocket reconnection without you reinventing those wheels. (Coinbase Advanced API Python SDK)

The fee math you cannot skip

Coinbase Advanced charges 0.40 percent maker and 0.60 percent taker for accounts under $10,000 of monthly volume. Volume tiers reduce that, with the lowest published taker fee at 0.05 percent for traders above $250 million in monthly volume. Stablecoin pairs (USDC pairings other than USDT/USDC and USDT/USD as of May 2025) carry their own pricing. (Coinbase Advanced fee schedule, Coinbase exchange fees, help center)

For backtesting, the relevant numbers if you are starting out are 0.6 percent taker, 0.4 percent maker. A reasonable default if you do not split execution between maker and taker is to model 0.5 percent round-trip per rebalance, which approximates a mix of the two. That number alone is enough to wipe out the edge in many strategies you read about. The first job of an honest backtest is to surface that.

What the academic literature says before you start

Before writing any code, set realistic expectations.

Han, Kang, and Ryu (2023) ran cryptocurrency time-series and cross-sectional momentum strategies under realistic assumptions including transaction costs, finding that evidence of time-series momentum is strong while cross-sectional momentum is weak once costs are included. Many published cross-sectional momentum portfolios stop being statistically significant once realistic execution costs are subtracted. (SSRN 4675565)

Huang, Sangiorgi, and Urquhart published volume-weighted time-series momentum results showing strategy performance up to 0.94 percent per day with an annualized Sharpe ratio of 2.17 in their tested window, on volume-weighted market returns rather than naïve simple-average baskets. (SSRN 4825389)

Zarattini, Pagani, and Barbon presented a tactical momentum model on a rotational portfolio of the top 20 most liquid coins, reporting a Sharpe ratio above 1.5 net of fees and an annualized alpha of 10.8 percent versus Bitcoin in their test window. (SSRN 5209907)

Read these as a range, not a guarantee. They were published in different windows, on different universes, with different cost assumptions. The honest reading is: time-series momentum on a top-N liquid universe, properly costed, has historically produced Sharpe ratios in the 1 to 2 range, with material drawdowns. That is a real edge if you can capture it. It is not the 24.8 percent monthly story you read on Medium.

Install

python -m venv venv
source venv/bin/activate    # or venv\Scripts\activate on Windows
pip install ccxt pandas numpy vectorbt plotly

That is enough for the backtest below. To go further:

pip install coinbase-advanced-py        # native Coinbase
pip install freqtrade                   # full live-trading bot
pip install quantstats                  # tearsheet reporting

A working time-series momentum backtest

Save this as tsmom_backtest.py. It pulls daily OHLCV from Coinbase Advanced via ccxt, computes a simple time-series momentum signal on each pair, equal-weights the long pairs, and applies a realistic taker fee. Run it as python tsmom_backtest.py. The print at the end is your backtest, on your universe, in your window. Do not trust any number on a website unless you have rerun it.

"""
Time-series momentum backtest on Coinbase USD pairs.
Educational. Not investment advice. Costs and outcomes vary.
"""
import time
import ccxt
import numpy as np
import pandas as pd

EXCHANGE = ccxt.coinbase()
LOOKBACK_DAYS = 540          # ~18 months of daily data
TS_LOOKBACK = 30             # 30-day momentum signal
MIN_24H_QUOTE_VOL_USD = 5_000_000
TAKER_FEE = 0.006            # 0.6% Coinbase Advanced default
SLIPPAGE = 0.0005            # 5 bps per fill assumption

def liquid_usd_pairs(min_vol):
    EXCHANGE.load_markets()
    pairs = []
    for sym, m in EXCHANGE.markets.items():
        if m.get("quote") == "USD" and m.get("active") and "/" in sym:
            try:
                t = EXCHANGE.fetch_ticker(sym)
            except Exception:
                continue
            qv = t.get("quoteVolume") or 0
            if qv >= min_vol:
                pairs.append(sym)
            time.sleep(EXCHANGE.rateLimit / 1000)
    return pairs

def daily_close(symbol, days):
    since = EXCHANGE.parse8601(
        (pd.Timestamp.utcnow() - pd.Timedelta(days=days)).strftime("%Y-%m-%dT00:00:00Z")
    )
    rows, cursor = [], since
    while True:
        batch = EXCHANGE.fetch_ohlcv(symbol, timeframe="1d", since=cursor, limit=300)
        if not batch:
            break
        rows.extend(batch)
        cursor = batch[-1][0] + 86_400_000
        time.sleep(EXCHANGE.rateLimit / 1000)
        if len(batch) < 300:
            break
    df = pd.DataFrame(rows, columns=["ts", "o", "h", "l", "c", "v"])
    df["ts"] = pd.to_datetime(df["ts"], unit="ms", utc=True)
    return df.set_index("ts").sort_index()["c"].rename(symbol)

def build_panel(symbols, days):
    series = []
    for s in symbols:
        try:
            series.append(daily_close(s, days))
        except Exception as e:
            print(f"skip {s}: {e}")
    return pd.concat(series, axis=1).sort_index().ffill(limit=2)

def tsmom_weights(panel, lookback):
    momentum_positive = (panel.pct_change(lookback) > 0).astype(float)
    n_active = momentum_positive.sum(axis=1).replace(0, np.nan)
    weights = momentum_positive.div(n_active, axis=0).fillna(0)
    return weights

def backtest(panel, weights, fee, slippage):
    daily_returns = panel.pct_change().fillna(0)
    gross = (weights.shift(1) * daily_returns).sum(axis=1)
    turnover = (weights - weights.shift(1)).abs().sum(axis=1)
    cost = turnover * (fee + slippage)
    net = gross - cost
    equity = (1 + net).cumprod()
    return net, equity, turnover

def stats(net, equity):
    daily_mu, daily_sd = net.mean(), net.std()
    ann_ret = daily_mu * 365
    ann_vol = daily_sd * np.sqrt(365)
    sharpe = ann_ret / ann_vol if ann_vol > 0 else float("nan")
    drawdown = (equity / equity.cummax() - 1).min()
    return {
        "annualized_return": round(ann_ret, 4),
        "annualized_vol": round(ann_vol, 4),
        "sharpe": round(sharpe, 3),
        "max_drawdown": round(drawdown, 4),
        "n_observations": len(net),
    }

if __name__ == "__main__":
    print("loading Coinbase markets…")
    pairs = liquid_usd_pairs(MIN_24H_QUOTE_VOL_USD)
    print(f"liquid USD pairs: {len(pairs)}")
    panel = build_panel(pairs, LOOKBACK_DAYS)
    weights = tsmom_weights(panel, TS_LOOKBACK)
    net, equity, turnover = backtest(panel, weights, TAKER_FEE, SLIPPAGE)
    print("\nbacktest summary")
    for k, v in stats(net, equity).items():
        print(f"  {k:>22}: {v}")
    print(f"\naverage daily turnover: {turnover.mean():.3f}")
    equity.to_csv("tsmom_equity.csv")
    print("\nwrote tsmom_equity.csv (cumulative net equity curve)")

A few things to notice in the code, because they matter more than they look.

The signal is shifted. weights.shift(1) * daily_returns is critical. If you do not lag the weights by one bar, you have look-ahead bias and the backtest is fiction. This is the single most common bug in retail backtests.

Fees and slippage are charged on turnover, not on every position. A pair that stays in the basket through a rebalance does not incur a fee. Many retail backtests double-count fees here.

The universe is pulled from current liquid pairs. This is survivorship bias and you should know it. A more honest backtest would pull the historical liquid set on each rebalance date. For a first pass to set expectations, the current snapshot is fine; for capital allocation, it is not.

The output is a CSV, not a plot. Treat your equity curve as data, not a story. Open it in your tool of choice, look for regime breaks, and ask yourself which window is doing the work.

The mature enhancements anyone can add

Each of these turns the toy backtest above into something closer to a research-quality system. None of them require new libraries.

1. Volatility targeting. Scale each pair's weight by the inverse of its rolling realized volatility, then renormalize. The intuition is that a basket of equal-weighted assets is dominated in risk terms by the most volatile asset. Vol targeting flattens that.

def vol_targeted_weights(panel, base_weights, vol_lookback=30, target_vol=0.50):
    realized_vol = panel.pct_change().rolling(vol_lookback).std() * np.sqrt(365)
    scaler = (target_vol / realized_vol).clip(upper=2.0).fillna(0)
    raw = base_weights * scaler
    return raw.div(raw.sum(axis=1).replace(0, np.nan), axis=0).fillna(0)

2. Regime gating. Crypto momentum is asymmetric. Long-only momentum pays in expansionary regimes and gets shredded in 2018-style or 2022-style drawdowns. Add a Bitcoin regime filter that turns the strategy off when BTC sits below its 200-day moving average.

def regime_filter(btc_close, ma_window=200):
    return (btc_close > btc_close.rolling(ma_window).mean()).astype(float)

# usage
btc = panel["BTC/USD"]
regime = regime_filter(btc).reindex(weights.index, method="ffill").fillna(0)
weights = weights.mul(regime, axis=0)

3. Daily turnover cap. Limit how much the basket can churn per rebalance. Caps the cost drag and forces the strategy to make conviction-based changes rather than noise-based ones.

def cap_turnover(weights, max_daily_turnover=0.30):
    out = weights.copy()
    for i in range(1, len(out)):
        prev = out.iloc[i - 1]
        target = out.iloc[i]
        delta = target - prev
        if delta.abs().sum() > max_daily_turnover:
            scale = max_daily_turnover / delta.abs().sum()
            out.iloc[i] = prev + delta * scale
    return out

4. Kelly-fraction sizing on the strategy itself. The output of the backtest above is unleveraged. Real allocation involves choosing a fraction of bankroll. Use a quarter-Kelly cap on the overall strategy: never more than 0.25 × (mean_return / variance) of bankroll deployed. For small accounts this is dominated by personal risk tolerance, but the math keeps you from over-betting on a noisy edge.

5. Walk-forward, not in-sample. Run the same backtest above for three windows: train on 2020-2022, validate on 2023, test on 2024. If the parameters that work in train are different from those that work in validate and test, your model has fit to noise. The Han, Kang, Ryu (2023) paper applies a similar discipline; it is the right discipline. (SSRN 4675565)

6. Polymarket sharp-wallet overlay. A genuine retail edge that does not show up in any institutional research. Pull Polymarket Subgraph data via Dune, identify wallets with lifetime PnL above $100,000, win rate above 60 percent, and at least 50 trades in the last 90 days. A cluster of three or more such wallets entering the same market in the same direction within 24 hours is a tradeable signal. The decay profile is short, on the order of 4 to 12 hours. None of the elite firms cited in this series compete here.

Going from research to live

The same backtest logic does not become a live bot just because you wrap it in a while True loop. The serious move is to translate the strategy into freqtrade, which gives you paper trading (dry-run), historical replay, hyperparameter optimization (Hyperopt), and live execution against Coinbase, Binance, Kraken, and others, all from one repo. (freqtrade GitHub, freqtrade docs on Coinbase Pro / Advanced)

For pure Coinbase work, the official coinbase-advanced-py SDK gives you direct REST and WebSocket access for order placement, account state, and live market data. Stub out the strategy logic from the backtest above, route it through the SDK, and start with dry-run for at least 30 days before any real money moves. (Coinbase Advanced Python SDK)

The single biggest upgrade you can make in the live transition is logging. Every signal, every fill, every fee, every rejection, every reconnect. Live trading reveals dozens of edge cases that backtests cannot simulate. The traders who survive the transition are the ones who keep enough state to debug after the fact.

How to use the whale framework with this stack

For people who already work from the Whale Intelligence Polymarket integration, the natural pairing is:

  1. Run the time-series momentum stack above on Coinbase top-20 USD pairs as the always-on base allocation.
  2. Add a discretionary sleeve sized at no more than 20 percent of bankroll, driven by the whale framework's CRITICAL-tier signals: cross-system insider plus options sweep plus Polymarket sharp wallet alignment.
  3. Use the same Section 1256 and Section 475(f) considerations from the prior post when you choose between trading SPY options versus SPX options versus ES futures for any equity-side hedge.
  4. Track expected versus realized for both sleeves separately. The strategy and the discretionary book diverge, so do not pool them in your evaluation.

That setup is replicable. It is also still a small fraction of what an institutional desk does. The difference is that this fraction is honest, costed, and yours.

Related reading

Fact-check notes and sources

This post is informational and educational, not investment, tax, or legal advice. The code shown is for illustration; running it produces a result on your machine, in your window, with your assumptions, and that result is not a forecast. Cryptocurrency trading involves risk of total loss. Past performance of any cited academic strategy is not indicative of future results. Consult a qualified financial advisor and CPA before deploying capital.

← Back to Blog

Accessibility Options

Text Size
High Contrast
Reduce Motion
Reading Guide
Link Highlighting
Accessibility Statement

J.A. Watte is committed to ensuring digital accessibility for people with disabilities. This site conforms to WCAG 2.1 and 2.2 Level AA guidelines.

Measures Taken

  • Semantic HTML with proper heading hierarchy
  • ARIA labels and roles for interactive components
  • Color contrast ratios meeting WCAG AA (4.5:1)
  • Full keyboard navigation support
  • Skip navigation link
  • Visible focus indicators (3:1 contrast)
  • 44px minimum touch/click targets
  • Dark/light theme with system preference detection
  • Responsive design for all devices
  • Reduced motion support (CSS + toggle)
  • Text size customization (14px–20px)
  • Print stylesheet

Feedback

Contact: jwatte.com/contact

Full Accessibility StatementPrivacy Policy

Last updated: April 2026