Size Factor Analysis
Analyze the size risk factor in cryptocurrency markets by constructing long-short portfolios based on market capitalization rankings, measuring the historical small-cap premium magnitude and statistical significance, and investigating its relationship to liquidity and volatility effects.
Size Factor Analysis — Research & Experimentation
Category: Research & Experimentation | Subcategory: Factor Research
What This Notebook Does
The size factor (SMB — Small Minus Big) captures the historical tendency for small-cap assets to outperform large-cap assets on a risk-adjusted basis. In equity markets, this Fama-French factor was discovered in 1992. In crypto, the analogous factor ranks assets by market capitalisation: small-cap altcoins vs large-cap blue chips like BTC and ETH.
Key dynamics of the crypto size factor:
- Small-caps tend to be higher beta and more volatile — they outperform in bull markets and underperform in crashes
- The size premium in crypto may be larger than in equities because many small-cap tokens are genuinely underfollowed and illiquid, creating a liquidity discount
- The size factor can be negative during risk-off periods ("flight to quality" = flight to BTC/ETH)
This notebook:
- Simulates market cap data alongside price returns
- Constructs the SMB size factor as a long-short portfolio
- Analyses size factor performance across different market regimes
- Decomposes the size premium into return, volatility, and correlation components
- Compares the size factor with momentum for factor correlation
- Exports the factor data
!pip install numpy pandas matplotlib seaborn scipy yfinance --quietimport numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
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
SIZE_SPLIT determines the fraction of assets classified as 'big' vs 'small'. With 10 assets and SIZE_SPLIT=0.3, the top 3 by market cap are 'big' and bottom 3 are 'small'. REBAL_FREQ controls how often the size classification is updated — monthly is standard since market caps don't change dramatically day-to-day.
# ── Data Source Toggle ─────────────────────────────
USE_LIVE_DATA = False
TICKERS = ['BTC-USD','ETH-USD','SOL-USD','BNB-USD','AVAX-USD',
'MATIC-USD','LINK-USD','DOT-USD','ATOM-USD','ALGO-USD']
START_DATE = '2020-01-01'
END_DATE = '2024-12-31'
# ──────────────────────────────────────────────────
ASSETS = ['BTC','ETH','SOL','BNB','AVAX','MATIC','LINK','DOT','ATOM','ALGO']
SIZE_SPLIT = 0.30 # top 30% = big, bottom 30% = small
REBAL_FREQ = 21 # rebalance monthly
N_BIG_SMALL = 3 # number of assets in each leg
print('Config ready.')Config ready.
Section 2 — Data & Market Cap Simulation
Market cap is simulated as a combination of price level (higher-priced assets like BTC have higher cap) and a synthetic supply factor. BTC and ETH are assigned the largest market caps; smaller altcoins (ALGO, ATOM) have smaller caps. Market caps are mean-reverting around these baseline levels, reflecting the relative stability of tier rankings over time.
rng = np.random.default_rng(42)
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)
returns = prices.pct_change().dropna()
print(f'Live data: {len(returns)} days')
else:
n = 1200
vols = np.array([0.65,0.75,1.20,0.70,1.10,1.40,1.15,0.90,1.05,1.30]) / np.sqrt(252)
mu = np.array([0.30,0.25,0.50,0.20,0.45,0.60,0.40,0.35,0.42,0.55]) / 252
corr = np.full((10,10), 0.60); np.fill_diagonal(corr, 1.0)
cov = np.outer(vols, vols) * corr
L = np.linalg.cholesky(cov)
data = rng.standard_t(df=4, size=(n, 10)) @ L.T + mu
idx = pd.date_range('2020-01-01', periods=n, freq='B')
returns = pd.DataFrame(data, columns=ASSETS, index=idx)
prices = (1 + returns).cumprod() * np.array([30000,2000,50,350,20,1.2,10,8,12,1.1])
print(f'Synthetic spot data: {n} days')
# Simulate market cap as price × circulating supply (relative ranking stable)
base_mcap = np.array([1.0, 0.50, 0.10, 0.25, 0.08, 0.06, 0.07, 0.09, 0.05, 0.04]) # normalised
noise = np.exp(rng.standard_normal((len(returns), 10)) * 0.02) # small mcap noise
mcap = pd.DataFrame(base_mcap * noise, index=returns.index, columns=ASSETS)
print('Market cap simulation complete.')Synthetic spot data: 1200 days Market cap simulation complete.
Section 3 — SMB Factor Construction
At each rebalancing date, we sort assets by market cap. The 'small' portfolio holds the N_BIG_SMALL assets with the lowest market cap; the 'big' portfolio holds the N_BIG_SMALL assets with the highest market cap. The SMB factor return is the equal-weighted small return minus the equal-weighted big return each day.
smb_rets = pd.Series(np.nan, index=returns.index)
small_w = np.zeros(len(ASSETS))
big_w = np.zeros(len(ASSETS))
for t in range(1, len(returns)):
if t % REBAL_FREQ == 0:
# Rank by market cap at current date
today_mcap = mcap.iloc[t]
ranks = today_mcap.rank()
small_mask = ranks <= N_BIG_SMALL
big_mask = ranks >= (len(ranks) - N_BIG_SMALL + 1)
small_w = small_mask.astype(float).values / N_BIG_SMALL
big_w = big_mask.astype(float).values / N_BIG_SMALL
daily = returns.iloc[t].values
smb_rets.iloc[t] = (small_w * daily).sum() - (big_w * daily).sum()
smb_rets = smb_rets.dropna()
sharpe = smb_rets.mean() / (smb_rets.std() + 1e-9) * np.sqrt(252)
print(f'SMB factor: Ann Ret={smb_rets.mean()*252:.1%}, Sharpe={sharpe:.2f}')
# Compare big vs small separately
small_only_ret = pd.Series(np.nan, index=returns.index)
big_only_ret = pd.Series(np.nan, index=returns.index)
small_w2, big_w2 = np.zeros(len(ASSETS)), np.zeros(len(ASSETS))
for t in range(1, len(returns)):
if t % REBAL_FREQ == 0:
ranks = mcap.iloc[t].rank()
small_w2 = (ranks <= N_BIG_SMALL).astype(float).values / N_BIG_SMALL
big_w2 = (ranks >= (len(ranks)-N_BIG_SMALL+1)).astype(float).values / N_BIG_SMALL
small_only_ret.iloc[t] = (small_w2 * returns.iloc[t].values).sum()
big_only_ret.iloc[t] = (big_w2 * returns.iloc[t].values).sum()
small_only_ret = small_only_ret.dropna()
big_only_ret = big_only_ret.dropna()SMB factor: Ann Ret=40.0%, Sharpe=0.42
Section 4 — Visualisation
The left panel shows separate equity curves for small-cap, big-cap, and the SMB spread. If the size premium is positive, small-cap line should be above big-cap over time. The right panel is a beta analysis: scatter of SMB factor returns vs the market (big-cap return), where a near-zero slope indicates the size factor is not simply leveraged market exposure.
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle('Size Factor (SMB) Analysis', fontsize=13, fontweight='bold')
common = small_only_ret.index.intersection(big_only_ret.index)
ax1 = axes[0]
ax1.plot((1+small_only_ret[common]).cumprod(), lw=1.5, color='#e53935', label='Small-cap')
ax1.plot((1+big_only_ret[common]).cumprod(), lw=1.5, color='#1976d2', label='Big-cap')
smb_eq = (1 + smb_rets.reindex(common).dropna()).cumprod()
ax1.plot(smb_eq, lw=1.5, color='#43a047', label='SMB spread')
ax1.set_ylabel('Cumulative Return'); ax1.legend(fontsize=9)
ax1.set_title('Small vs Big Cap Equity Curves')
ax2 = axes[1]
smb_al = smb_rets.reindex(big_only_ret.index).dropna()
mkt_al = big_only_ret.reindex(smb_al.index)
ax2.scatter(mkt_al, smb_al, s=3, alpha=0.2, color='#9e9e9e')
m, b = np.polyfit(mkt_al, smb_al, 1)
xl = np.linspace(mkt_al.min(), mkt_al.max(), 100)
ax2.plot(xl, m*xl+b, color='#e53935', lw=2, label=f'β={m:.2f}')
ax2.axvline(0, color='black', lw=0.5, ls=':')
ax2.axhline(0, color='black', lw=0.5, ls=':')
ax2.set_xlabel('Market Return (Big-cap)'); ax2.set_ylabel('SMB Return')
ax2.legend(fontsize=9); ax2.set_title('SMB Beta vs Market')
plt.tight_layout(); plt.show()Section 5 — Export
Save the SMB factor returns, small-cap only, and big-cap only return series. These can be combined with the momentum and carry factor outputs for multi-factor portfolio construction.
pd.DataFrame({'smb': smb_rets, 'small': small_only_ret, 'big': big_only_ret}).to_csv(
'size_factor_analysis.csv')
print('Saved: size_factor_analysis.csv')Saved: size_factor_analysis.csv