‹ Projects

A Stat Arb on Crypto Tokens

A mean-reversion strategy on 40 crypto tokens, built from the Avellaneda–Lee (2010) playbook. My first version posted a positive Sharpe ratio and still turned $1 into $0.00000005. This page walks through the idea, the build, what broke, and what the corrected version really shows.

Context: Final project, ISyE 6767 Systems Computational Finance, Georgia Tech (August–December 2025)
Stack: Python, NumPy, pandas, scikit-learn
Data: hourly FTX prices for 120+ tokens, February 2021 to September 2022

The idea

Crypto tokens move together. When Bitcoin rallies, most of the market rallies with it. That shared movement is the tide, and it lifts every boat at once. Once you strip the tide out, what remains for each token is its own story: the part of its return that has nothing to do with the market.

Statistical arbitrage bets that this leftover part behaves like a rubber band. It gets stretched by noise, flows and news, and then it tends to snap back. When a token is unusually cheap relative to what the market says it should be, you buy it. When it is unusually rich, you short it. You close the trade once the band relaxes.

Marco Avellaneda and Jeong-Hyun Lee formalised this for US equities in 2010. I applied their method to crypto, trading hourly across a one-year window from 26 September 2021 to 25 September 2022.

The data

Two files drive everything. The first holds hourly prices for more than 120 tokens. The second lists, for every hour, the 40 largest tokens by market capitalisation at that moment. The universe therefore changes over time: tokens enter the top 40 and fall out of it.

For every hour \(t\) I look back over a window of \(M = 240\) hours (ten days). I keep only the current top-40 tokens that have at least 80% valid data in that window. Returns are simple hourly returns:

\[R_{i,t} = \frac{S_{i,t} - S_{i,t-1}}{S_{i,t-1}}\]

The method, in four steps

The same four steps repeat every hour, 8,760 times.

Step 1: Find the tides with PCA

I standardise each token's returns inside the window and compute their correlation matrix. Principal component analysis then finds the directions in which the whole market moves together. I keep the top two eigenvectors \(v^{(1)}\) and \(v^{(2)}\).

To turn an eigenvector into something tradable, each token's loading is divided by its volatility \(\bar{\sigma}_i\). The result is an eigenportfolio, and its return is the factor return:

\[Q^{(j)}_i = \frac{v^{(j)}_i}{\bar{\sigma}_i}, \qquad F_{j,k} = \sum_{i=1}^{N} Q^{(j)}_i \, R_{i,k}\]

The first eigenportfolio behaves like the market. Every token carries a positive weight, and over the year it tracks Bitcoin almost exactly.

Figure 1 The first eigenportfolio is the crypto market Cumulative return, rebased to 100 on 26 September 2021. Eigenportfolios scaled to unit gross weight.
The first eigenportfolio is the crypto market The first eigenportfolio is the crypto market

Source: FTX hourly prices

The first eigenportfolio fell 56% over the year, in line with Bitcoin and Ether. The second went the other way and rose 60%. It is a relative-value factor: long some tokens, short others.

The weights show the same thing from another angle. The first eigenportfolio spreads positive weight across the whole universe, with Bitcoin the largest. The second concentrates on a handful of tokens, some held long and some short.

Figure 2 Eigenportfolio weights at the start of the test period 26 September 2021, 12:00 UTC. Share of gross weight; blue is long, red is short.
Eigenportfolio weights at the start of the test period Eigenportfolio weights at the start of the test period

Source: FTX hourly prices

Figure 3 Eigenportfolio weights mid-way through 15 April 2022, 20:00 UTC. Share of gross weight; blue is long, red is short.
Eigenportfolio weights mid-way through Eigenportfolio weights mid-way through

Source: FTX hourly prices

The second factor tends to lean heavily on a few thinly traded, exchange-linked tokens such as LEO and CEL. That makes it less stable from hour to hour than the market factor, and it is a hint that a liquidity screen would help.

Step 2: Strip out the tides

For each token I regress its returns in the window on the two factor returns:

\[R_{i,k} = \beta_{i,0} + \beta_{i,1} F_{1,k} + \beta_{i,2} F_{2,k} + \varepsilon_{i,k}\]

The betas measure how much the token rides each tide. The residual \(\varepsilon_{i,k}\) is its idiosyncratic return, the part that is purely about that token.

Step 3: Model the residual as a rubber band

Adding up the residuals gives a running total, which measures how far the band is stretched:

\[X_{i,\ell} = \sum_{n=0}^{\ell} \varepsilon_{i,\,t-M+n}\]

The mathematical rubber band is the Ornstein–Uhlenbeck process. It is pulled back towards an equilibrium level \(m\) at speed \(\kappa\) while random shocks keep jostling it. I estimate it through an AR(1) regression, which is simply its discrete-time version:

\[X_{\ell+1} = a + b\,X_\ell + \zeta_{\ell+1}\]

The AR(1) coefficients translate directly into the OU parameters, with \(\Delta t\) equal to one hour expressed in years:

\[\kappa = -\frac{\ln b}{\Delta t}, \qquad m = \frac{a}{1-b}, \qquad \sigma_{\text{eq}} = \sqrt{\frac{\operatorname{Var}(\zeta)}{1-b^2}}\]

A coefficient \(b\) close to 1 means a weak pull, where the band barely snaps back. Fits with \(b \ge 0.999\) show no usable mean reversion, so I discard them.

Step 4: Score the stretch and trade it

The s-score measures how many equilibrium standard deviations the band currently sits from its resting point:

\[s_i = \frac{X_{i,\text{last}} - m_i}{\sigma_{\text{eq},i}}\]

I subtract the cross-sectional average each hour so the scores are centred on zero. The trading rules follow the paper. Buy when \(s < -1.25\) (too cheap) and close the long once \(s > -0.50\). Short when \(s > +1.25\) (too rich) and close once \(s < +0.75\). Every signal acts on the following hour's return, so the backtest never peeks at the future.

Figure 4 Bitcoin and Ether s-scores in the first month Hourly s-score, 26 September to 25 October 2021. Shaded bands are the zones where a position opens.
Bitcoin and Ether s-scores in the first month Bitcoin and Ether s-scores in the first month

Source: FTX hourly prices

How the code is organised

The whole pipeline lives in a single Python script built around three classes. DataHandler loads prices, computes returns, and builds each hour's filtered window. FactorModel runs the PCA and produces eigenportfolio weights and factor returns. OUProcess runs the factor regression, fits the AR(1), maps it to OU parameters, and returns an s-score. A backtest driver loops over the 8,760 hours, applies the trading rules, and writes the signals, weights, performance statistics and charts. Keeping these pieces separate made it easy to find and fix problems later, which turned out to matter.

The first result: a positive Sharpe ratio, and total ruin

My submitted version reported a Sharpe ratio of 0.13 and a maximum drawdown of −100%. On average the strategy made money every hour. Compounded, it lost essentially everything: $1 invested at the start was worth $0.00000005 at the end.

Figure 5 A positive Sharpe ratio did not save version one Value of $1 invested at the start of the test period, log scale
A positive Sharpe ratio did not save version one A positive Sharpe ratio did not save version one

Source: FTX hourly prices

At the time I put this down to missing transaction costs. When I revisited the project, the real explanation turned out to be three implementation mistakes.

What went wrong

Every position was sized as if it were the whole portfolio

Each open position was worth one unit of capital, and on average 23 positions were open at once. That is roughly 23 times leverage on a portfolio worth 1. The portfolio moved more than 10% in about one hour out of every twelve.

This explains how a positive average return can coexist with ruin. A compounded portfolio grows at roughly its mean return minus half its variance:

\[\text{growth rate per hour} \approx \mu - \tfrac{1}{2}\sigma^2\]

Version one averaged \(\mu \approx 0.009\%\) per hour, with \(\sigma \approx 6.3\%\) per hour. The volatility drag \(\tfrac{1}{2}\sigma^2 \approx 0.20\%\) per hour was more than twenty times the drift. The Sharpe ratio measures the average and cannot see the drag, but the drag is what compounding feels.

Figure 6 Proper sizing shrinks hourly swings eighteen-fold Distribution of hourly strategy returns, extreme 0.2% trimmed for display
Proper sizing shrinks hourly swings eighteen-fold Proper sizing shrinks hourly swings eighteen-fold

Source: FTX hourly prices

Positions were orphaned when tokens left the universe

The trading loop only updated tokens that were in that hour's top-40 window. When a token dropped out, its position froze. It kept earning and losing money, but the model never looked at it again and never closed it. More than a third of all position-hours (36%) sat in tokens the model had stopped tracking. One token, C98, was held for 8,588 of the 8,760 hours.

Figure 7 More than a third of version one's book was on autopilot Open positions per hour in version one, daily average
More than a third of version one's book was on autopilot More than a third of version one's book was on autopilot

Source: FTX hourly prices

The eigenportfolios were never normalised

Dividing each loading by an hourly volatility of 1–5% inflates the weights, which summed to around 600. The first factor's return was therefore about 600 times the average token's move. This did not affect the regressions, because rescaling a regressor leaves its residuals unchanged. It did make the original factor chart unreadable. Scaling each eigenportfolio to unit gross weight fixed it.

After the fixes: honest, and flat

I changed three things. Positions are now equally weighted so gross exposure is always 1. Any position in a token that leaves the universe is closed. Factor returns are reported on unit-weight eigenportfolios.

Version one Version two
Total return −100% −5.6%
Annualised Sharpe ratio 0.13 −0.02
Maximum drawdown −100% −26%
Annualised volatility 593% 32%
Average open positions 23 15

The corrected strategy survives, but it has no edge: the mean hourly return is essentially zero. Costs make things worse. The strategy turns over its book roughly 1,100 times a year, so a cost of just 5 basis points per trade would have cut the return to −46%.

What I take away

The most useful lesson was the gap between the arithmetic average and compounded wealth. A Sharpe ratio built from average returns can look respectable while the portfolio underneath is being destroyed by its own volatility. Position sizing is not a detail to add after the signal works. It decides whether the signal can be felt at all.

The second lesson is about bookkeeping. A dynamic universe needs an explicit rule for what happens to a position when its asset leaves, and it is exactly the kind of bug that is invisible until you count.

On the signal itself, mean reversion in hourly crypto residuals did not pay over this year, which was a brutal bear market that included the Terra collapse. If I took this further, I would try four things. First, require faster mean reversion, since the paper insists the reversion time be short relative to the estimation window. Second, add a liquidity screen. Third, scale signals by trading volume, as Avellaneda and Lee do. Fourth, trade less often so that turnover does not eat whatever edge exists.

References

‹ Back to Projects