Portfolio & Risk·Portfolio Construction·Intermediate

Crypto Factor Model

Construct a multi-factor risk model for cryptocurrency returns incorporating systematic factors including market beta, size, momentum, value, and carry to decompose portfolio returns and risk exposures into their constituent factor betas for performance attribution and risk management.

factor-investingmachine-learningrisk-management

Crypto Factor Model — Portfolio Construction

Category: Portfolio | Subcategory: Construction


What This Notebook Does

Factor models decompose asset returns into systematic exposures (betas to common factors) and idiosyncratic noise:

r_i = α_i + β_i1·F1 + β_i2·F2 + ... + ε_i

For crypto, we construct the following factors from price data (no external data needed):

  • Market factor (MKT): BTC return — the dominant systematic factor in crypto
  • Size factor (SMB): small-cap coins minus large-cap coins
  • Momentum factor (MOM): past 3-month winners minus losers
  • Volatility factor (VOL): low-vol coins minus high-vol coins

This notebook:

  1. Fetches data (Yahoo Finance or synthetic)
  2. Constructs all four crypto factors as long-short portfolios
  3. Estimates factor exposures (betas) for each asset via OLS regression
  4. Decomposes returns into factor-driven and idiosyncratic components
  5. Builds a factor-based portfolio targeting specific exposures
  6. Exports factor returns and betas
[ ]
!pip install numpy pandas matplotlib seaborn scipy statsmodels yfinance --quiet
[ ]
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import statsmodels.api as sm
import warnings

warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
print('Imports ready.')
Imports ready.

Section 1 — Configuration

We define a 10-asset crypto universe. BTC is used as the market factor (MKT). Size is proxied by market cap rank (first 3 = large-cap). Momentum lookback is 60 days. Volatility is measured as a 30-day rolling standard deviation.

[ ]
# ── Data Source Toggle ─────────────────────────────
USE_LIVE_DATA = False
TICKERS       = ['BTC-USD','ETH-USD','SOL-USD','BNB-USD','AVAX-USD',
                  'MATIC-USD','DOT-USD','LINK-USD','ADA-USD','XRP-USD']
START_DATE    = '2022-01-01'
END_DATE      = '2024-12-31'
# ──────────────────────────────────────────────────
ASSETS      = ['BTC','ETH','SOL','BNB','AVAX','MATIC','DOT','LINK','ADA','XRP']
LARGE_CAPS  = ['BTC','ETH','BNB']   # for SMB factor
SMALL_CAPS  = ['MATIC','DOT','LINK','ADA']  # for SMB factor
MOM_WINDOW  = 60   # days for momentum factor
VOL_WINDOW  = 30   # days for volatility factor
print('Config ready.')
Config ready.

Section 2 — Data Acquisition

We need daily returns for the full crypto universe. Yahoo Finance provides this for free via yfinance. The synthetic path generates correlated returns with heterogeneous volatilities and drifts — some assets are assigned higher drift to simulate the momentum effect being detectable in the data.

[ ]
if USE_LIVE_DATA:
    import yfinance as yf
    prices  = yf.download(TICKERS, start=START_DATE, end=END_DATE)['Close']
    prices.columns = ASSETS
    prices.dropna(inplace=True)
    print(f'Live data: {len(prices)} days')
else:
    rng  = np.random.default_rng(42)
    n    = 730
    vols = np.array([0.65,0.75,1.20,0.70,1.10,1.40,1.00,1.20,0.90,0.80])/np.sqrt(252)
    mu   = np.array([0.50,0.45,0.80,0.35,0.70,0.55,0.40,0.65,0.30,0.38])/252
    corr = np.full((10,10), 0.55); np.fill_diagonal(corr, 1.0)
    corr[0,1]=corr[1,0]=0.85; corr[0,3]=corr[3,0]=0.75
    cov  = np.outer(vols,vols)*corr
    L    = np.linalg.cholesky(cov)
    data = rng.standard_normal((n,10)) @ L.T + mu
    idx  = pd.date_range('2022-01-01', periods=n, freq='B')
    returns = pd.DataFrame(data, columns=ASSETS, index=idx)
    prices  = (1+returns).cumprod()*100
    print(f'Synthetic data: {len(returns)} days')

returns = prices.pct_change().dropna()
Synthetic data: 730 days

Section 3 — Factor Construction

Each factor is a daily long-short portfolio return:

  • MKT: BTC daily return (the market benchmark)
  • SMB: small-cap average minus large-cap average (size premium)
  • MOM: top-3 momentum assets minus bottom-3 (look back MOM_WINDOW days)
  • VOL: low-volatility assets minus high-volatility assets (volatility factor)

All factors are constructed daily using only information available at that point (no look-ahead bias).

[ ]
factors = pd.DataFrame(index=returns.index)

# MKT factor: BTC return
factors['MKT'] = returns['BTC']

# SMB factor: small-cap minus large-cap
factors['SMB'] = returns[SMALL_CAPS].mean(axis=1) - returns[LARGE_CAPS].mean(axis=1)

# MOM factor: rolling past-momentum winners minus losers
rolling_cum = (1 + returns).rolling(MOM_WINDOW).apply(np.prod, raw=True) - 1
mom_long, mom_short = [], []
for date, row in rolling_cum.iterrows():
    valid = row.dropna()
    if len(valid) >= 6:
        top3    = valid.nlargest(3).index
        bottom3 = valid.nsmallest(3).index
        mom_long.append(returns.loc[date, top3].mean())
        mom_short.append(returns.loc[date, bottom3].mean())
    else:
        mom_long.append(np.nan); mom_short.append(np.nan)
factors['MOM'] = pd.Series([l - s for l, s in zip(mom_long, mom_short)], index=returns.index)

# VOL factor: low-vol minus high-vol (inverse volatility)
rolling_vol  = returns.rolling(VOL_WINDOW).std()
vol_lo, vol_hi = [], []
for date, row in rolling_vol.iterrows():
    valid = row.dropna()
    if len(valid) >= 6:
        low3  = valid.nsmallest(3).index
        high3 = valid.nlargest(3).index
        vol_lo.append(returns.loc[date, low3].mean())
        vol_hi.append(returns.loc[date, high3].mean())
    else:
        vol_lo.append(np.nan); vol_hi.append(np.nan)
factors['VOL'] = pd.Series([l - h for l, h in zip(vol_lo, vol_hi)], index=returns.index)
factors.dropna(inplace=True)

print('Factor summary (annualised Sharpe):')
for col in factors.columns:
    sr = factors[col].mean() / (factors[col].std() + 1e-9) * np.sqrt(252)
    print(f'  {col}: Sharpe = {sr:.2f}')
Factor summary (annualised Sharpe):
  MKT: Sharpe = 1.14
  SMB: Sharpe = -0.19
  MOM: Sharpe = 5.15
  VOL: Sharpe = 0.13

Section 4 — Factor Regression (Beta Estimation)

We regress each asset's daily return on the four factors using OLS. The resulting beta coefficients measure how sensitively each asset responds to each factor. An R² near 1 means the factors explain most of the asset's variation; a low R² means a large idiosyncratic component.

[ ]
common_idx = returns.index.intersection(factors.index)
Y = returns.loc[common_idx]
X = sm.add_constant(factors.loc[common_idx])

betas_list = []
for asset in ASSETS:
    model  = sm.OLS(Y[asset], X).fit()
    row    = {'Asset': asset, 'Alpha': model.params['const'],
               'R2': model.rsquared}
    for fac in factors.columns:
        row[f'β_{fac}'] = model.params[fac]
    betas_list.append(row)

betas_df = pd.DataFrame(betas_list).set_index('Asset')
print('Factor betas (selected):')
print(betas_df[['β_MKT','β_SMB','β_MOM','β_VOL','R2']].round(3))
Factor betas (selected):
       β_MKT  β_SMB  β_MOM  β_VOL     R2
Asset                                   
BTC    1.000  0.000  0.000  0.000  1.000
ETH    0.968 -0.025  0.008 -0.139  0.718
SOL    0.739 -0.352  0.174 -1.103  0.614
BNB    0.799  0.073 -0.019 -0.060  0.574
AVAX   0.885  0.281  0.027 -0.428  0.540
MATIC  0.996  0.771 -0.274 -0.699  0.749
DOT    0.924  1.220  0.023  0.247  0.638
LINK   0.874  0.837  0.169 -0.272  0.620
ADA    0.896  1.235  0.067  0.458  0.639
XRP    0.688  0.639  0.020  0.145  0.434

Section 5 — Visualisation

The left heatmap shows beta exposures for each asset across all factors — red = high positive beta, blue = high negative beta. The right panel shows cumulative factor returns, helping identify which factors have been most profitable in this period.

[ ]
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle('Crypto Factor Model', fontsize=13, fontweight='bold')

ax1 = axes[0]
beta_cols = [c for c in betas_df.columns if c.startswith('β_')]
sns.heatmap(betas_df[beta_cols].astype(float), ax=ax1, cmap='RdBu_r',
            center=0, annot=True, fmt='.2f', linewidths=0.5,
            cbar_kws={'label': 'Beta'})
ax1.set_title('Factor Betas per Asset')

ax2 = axes[1]
for col in factors.columns:
    cum = (1 + factors[col]).cumprod()
    ax2.plot(cum.index, cum, lw=1.5, label=col)
ax2.axhline(1, color='black', ls='--', lw=0.8)
ax2.set_ylabel('Cumulative Factor Return')
ax2.legend(fontsize=9)
ax2.set_title('Factor Performance')

plt.tight_layout(); plt.show()
cell output

Section 6 — Export

Save factor daily returns and the beta exposure matrix. These outputs feed directly into portfolio optimisation notebooks that target specific factor exposures.

[ ]
factors.to_csv('crypto_factor_returns.csv')
betas_df.to_csv('crypto_factor_betas.csv')
print('Saved: crypto_factor_returns.csv, crypto_factor_betas.csv')
Saved: crypto_factor_returns.csv, crypto_factor_betas.csv

Conclusion

This notebook successfully demonstrates the construction of a crypto factor model. We fetched data, built MKT, SMB, MOM, and VOL factors, estimated asset betas, and visualized the results. The generated factor returns and betas can be used for further portfolio optimization targeting specific factor exposures.