Research·Factor Research & Analysis·Advanced

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.

factor-investingquant-research

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:

  1. Simulates market cap data alongside price returns
  2. Constructs the SMB size factor as a long-short portfolio
  3. Analyses size factor performance across different market regimes
  4. Decomposes the size premium into return, volatility, and correlation components
  5. Compares the size factor with momentum for factor correlation
  6. Exports the factor data
[ ]
!pip install numpy pandas matplotlib seaborn scipy yfinance --quiet
[ ]
import 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()
cell output

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