Chapter 5: Portfolio Construction and Risk Budgeting for Digital Assets
Chapter 5: Portfolio Construction and Risk Budgeting for Digital Assets
Overview
Portfolio construction in digital asset markets presents unique challenges that require substantial adaptations of classical financial theory. Unlike traditional equity markets that operate during limited hours, cryptocurrency markets trade 24/7/365, creating continuous exposure to volatility, flash crashes, and liquidity events that can occur at any hour. This perpetual nature fundamentally changes how we compute risk metrics like the Sharpe ratio, how we measure drawdowns, and how we think about diversification across assets that share extreme tail dependencies.
Modern Portfolio Theory (MPT), introduced by Harry Markowitz in 1952, provides the mathematical foundation for constructing optimal portfolios. However, applying mean-variance optimization directly to crypto assets is fraught with danger: return distributions exhibit extreme kurtosis, correlations spike during market stress, and covariance matrices estimated from historical data are notoriously unstable. This chapter explores both classical approaches and their necessary modifications for the crypto domain, including the Black-Litterman model for incorporating subjective views, Hierarchical Risk Parity for more robust allocation, and the Kelly criterion adapted for leveraged perpetual futures.
Risk budgeting in crypto demands attention to factors absent in traditional finance. Funding rates on perpetual futures act as a carry component that can significantly impact portfolio returns. Liquidation cascades can trigger flash crashes that invalidate assumptions of continuous price paths. Correlation regimes shift dramatically between bull and bear markets, with altcoins becoming nearly perfectly correlated during crashes. This chapter provides both theoretical foundations and practical implementations in Python and Rust for building robust crypto portfolios that account for these realities.
Table of Contents
- Introduction to Crypto Portfolio Construction
- Mathematical Foundations of Portfolio Theory
- Comparison of Portfolio Optimization Methods
- Trading Applications in Digital Asset Markets
- Implementation in Python
- Implementation in Rust
- Practical Examples
- Backtesting Framework
- Performance Evaluation
- Future Directions
Section 1: Introduction to Crypto Portfolio Construction
The Case for Portfolio Diversification in Crypto
Cryptocurrency markets offer hundreds of tradeable assets, yet many participants hold concentrated positions in a single coin. Portfolio theory demonstrates that diversification can reduce risk without proportionally reducing expected return, provided assets are not perfectly correlated. In crypto, correlation structures vary dramatically across market regimes: during calm periods, BTC, ETH, and altcoins can exhibit moderate correlations (0.3-0.6), while during sharp selloffs correlations can spike above 0.9 as all assets sell off together.
The 1/N (equal-weight) portfolio serves as a surprisingly strong benchmark. Research has shown that naive diversification often outperforms optimized portfolios out-of-sample, particularly when estimation error in expected returns is large. In crypto, where return estimation is extremely noisy, the 1/N portfolio provides a valuable baseline against which to measure more sophisticated approaches.
Key Performance Metrics Adapted for Crypto
The Sharpe ratio is the most widely used risk-adjusted performance metric. For crypto, we must adapt the annualization factor to account for 24/7 trading:
- Traditional:
SR_annual = SR_daily * sqrt(252) - Crypto:
SR_annual = SR_daily * sqrt(365)
The Sortino ratio focuses only on downside deviation, making it more appropriate for the asymmetric return distributions common in crypto. The Calmar ratio (annualized return / maximum drawdown) captures crash risk, which is particularly relevant given crypto’s history of 50-80% drawdowns.
Maximum drawdown requires careful measurement in crypto. Flash crashes lasting minutes can produce extreme drawdowns that recover quickly. Whether to measure drawdown at the tick level or daily close level is an important design choice for any backtesting framework.
Risk-Return Tradeoff in Digital Assets
The fundamental tradeoff between risk and return takes on new dimensions in crypto. Leverage through perpetual futures allows traders to amplify both returns and risks, with the added complexity of funding payments and liquidation risk. Understanding this tradeoff requires a nuanced view of risk that goes beyond simple volatility measures to include tail risk, liquidity risk, and the unique risks of decentralized finance protocols.
Section 2: Mathematical Foundations of Portfolio Theory
Mean-Variance Optimization
Given N assets with expected return vector mu and covariance matrix Sigma, the mean-variance optimization problem is:
minimize w^T * Sigma * wsubject to w^T * mu >= target_return w^T * 1 = 1 w_i >= 0 (long-only constraint)Where w is the vector of portfolio weights. The solution traces out the efficient frontier — the set of portfolios offering maximum return for each level of risk.
The minimum-variance portfolio is the leftmost point on the efficient frontier:
w_mv = (Sigma^{-1} * 1) / (1^T * Sigma^{-1} * 1)The Black-Litterman Model
Black-Litterman addresses the extreme sensitivity of mean-variance optimization to expected return estimates by combining market equilibrium returns with investor views:
Equilibrium returns: pi = delta * Sigma * w_marketCombined returns: mu_BL = [(tau * Sigma)^{-1} + P^T * Omega^{-1} * P]^{-1} * [(tau * Sigma)^{-1} * pi + P^T * Omega^{-1} * Q]Where:
delta= risk aversion coefficienttau= uncertainty scalar (typically 0.025-0.05)P= pick matrix (views matrix)Q= view returns vectorOmega= uncertainty of views diagonal matrix
For crypto, views might include: “BTC will outperform ETH by 5% annualized” or “SOL will return 20% over the next quarter.”
Kelly Criterion for Leveraged Perpetual Futures
The Kelly criterion determines the optimal fraction of capital to risk:
f* = (mu - r_f) / sigma^2For leveraged perpetual futures, the Kelly fraction must account for:
- Funding rate:
r_funding(positive = longs pay shorts) - Liquidation boundary: maximum leverage before forced exit
- Expected return adjusted:
mu_adj = mu - r_funding * leverage
f*_perp = (mu_adj - r_f) / sigma^2Practical Kelly: f_practical = f* / 2 (half-Kelly for safety)Hierarchical Risk Parity (HRP)
HRP uses hierarchical clustering on the correlation matrix to build a portfolio without matrix inversion:
- Tree clustering: Compute distance matrix
D_ij = sqrt(0.5 * (1 - rho_ij))and apply single-linkage clustering - Quasi-diagonalization: Reorder the covariance matrix to place correlated assets together
- Recursive bisection: Split assets into clusters and allocate inversely proportional to cluster variance
Covariance Matrix Estimation
Robust covariance estimation is critical for crypto. Methods include:
- Sample covariance:
S = (1/T) * X^T * X(noisy with few observations) - Ledoit-Wolf shrinkage:
Sigma_shrunk = alpha * F + (1-alpha) * Swhere F is a structured target - Exponentially weighted:
Sigma_t = lambda * Sigma_{t-1} + (1-lambda) * r_t * r_t^T(adapts to regime changes) - Minimum Covariance Determinant: Robust to outliers from flash crashes
Section 3: Comparison of Portfolio Optimization Methods
| Method | Return Estimation Required | Handles Fat Tails | Correlation Regime Robust | Leverage Aware | Complexity |
|---|---|---|---|---|---|
| Mean-Variance (Markowitz) | Yes | No | No | No | Low |
| Minimum Variance | No | No | Partial | No | Low |
| Black-Litterman | Yes (views) | No | Partial | No | Medium |
| Risk Parity | No | Partial | Partial | Yes | Medium |
| Hierarchical Risk Parity | No | Yes | Yes | No | Medium |
| Kelly Criterion | Yes | No | No | Yes | Low |
| Robust Optimization | Yes (uncertainty set) | Yes | Yes | Optional | High |
| 1/N Equal Weight | No | N/A | N/A | No | None |
| Metric | Formula | Crypto Adaptation | Typical Range (Crypto) |
|---|---|---|---|
| Sharpe Ratio | (R_p - R_f) / sigma_p | Annualize with sqrt(365) | -0.5 to 2.0 |
| Sortino Ratio | (R_p - R_f) / sigma_down | Use downside deviation only | -0.5 to 3.0 |
| Calmar Ratio | R_annual / MaxDD | Critical for crypto drawdowns | 0.1 to 1.5 |
| Max Drawdown | max(peak - trough) / peak | Measure at hourly granularity | 20% to 80% |
| Information Ratio | alpha / tracking_error | vs BTC benchmark | -1.0 to 1.0 |
| Funding Carry | sum(funding_rates) | Annualized 8h funding | -20% to +30% |
Section 4: Trading Applications in Digital Asset Markets
4.1 BTC/ETH/Altcoin Risk Parity
Risk parity allocates capital inversely proportional to each asset’s contribution to portfolio risk. For a crypto portfolio of BTC, ETH, and a basket of altcoins:
Risk contribution_i = w_i * (Sigma * w)_i / (w^T * Sigma * w)Target: RC_BTC = RC_ETH = RC_ALT = 1/3In practice, BTC’s lower volatility leads to higher weight allocation (often 50-60%), while altcoins receive smaller allocations due to their extreme volatility.
4.2 Funding Rate as Carry Component
Bybit perpetual futures pay funding every 8 hours. A portfolio strategy can exploit funding:
- Positive funding: Longs pay shorts. Short the perp, long the spot = earn funding as carry.
- Negative funding: Shorts pay longs. Long the perp, short the spot (or simply long the perp).
- Annualized carry:
carry_annual = funding_rate * 3 * 365
This basis trade (cash-and-carry arbitrage) can yield 10-30% annualized during bull markets.
4.3 Correlation Regime Detection
Crypto correlations shift between regimes:
- Bull regime: BTC dominance falls, altcoin correlations moderate (rho ~ 0.4-0.6)
- Bear/crash regime: Flight to quality, all altcoins correlate with BTC (rho ~ 0.8-0.95)
- Rotation regime: Sector rotation among DeFi, L1, L2, memes (rho varies by sector)
Detecting the current regime is critical for portfolio construction. A rolling 30-day correlation matrix with exponential weighting helps identify transitions.
4.4 Liquidation Risk Management
For leveraged positions, the liquidation price determines the maximum loss:
Liquidation price (long) = entry_price * (1 - 1/leverage + maintenance_margin)Liquidation price (short) = entry_price * (1 + 1/leverage - maintenance_margin)Portfolio-level liquidation risk requires modeling correlated moves across positions. A portfolio with 5x leverage across multiple altcoins has much higher liquidation risk during a crash than the individual position risks suggest.
4.5 Dynamic Rebalancing with Transaction Costs
Rebalancing frequency must balance tracking error against transaction costs:
- Bybit taker fee: 0.055%
- Bybit maker fee: 0.02%
- Slippage: 0.01-0.1% depending on asset and size
- Funding rate impact during rebalancing
A no-trade zone approach only rebalances when weights deviate beyond a threshold, reducing turnover while maintaining target allocation.
Section 5: Implementation in Python
Portfolio Optimizer Class
import numpy as npimport pandas as pdfrom scipy.optimize import minimizefrom scipy.cluster.hierarchy import linkage, leaves_listfrom scipy.spatial.distance import squareformimport yfinance as yfimport requestsfrom typing import Dict, List, Optional, Tuple
class CryptoPortfolioOptimizer: """Portfolio optimization for digital assets with crypto-specific adaptations."""
def __init__(self, symbols: List[str], risk_free_rate: float = 0.05): self.symbols = symbols self.risk_free_rate = risk_free_rate self.returns = None self.cov_matrix = None
def fetch_bybit_klines(self, symbol: str, interval: str = "D", limit: int = 200) -> pd.DataFrame: """Fetch OHLCV data from Bybit API.""" url = "https://api.bybit.com/v5/market/kline" params = { "category": "linear", "symbol": symbol, "interval": interval, "limit": limit } response = requests.get(url, params=params) data = response.json()["result"]["list"] df = pd.DataFrame(data, columns=[ "timestamp", "open", "high", "low", "close", "volume", "turnover" ]) df["close"] = df["close"].astype(float) df["timestamp"] = pd.to_datetime(df["timestamp"].astype(int), unit="ms") df = df.sort_values("timestamp").set_index("timestamp") return df
def fetch_returns(self, source: str = "bybit") -> pd.DataFrame: """Fetch and compute daily returns for all symbols.""" all_returns = {} if source == "bybit": for sym in self.symbols: df = self.fetch_bybit_klines(sym) all_returns[sym] = df["close"].pct_change().dropna() elif source == "yfinance": for sym in self.symbols: df = yf.download(sym, period="1y", interval="1d") all_returns[sym] = df["Close"].pct_change().dropna() self.returns = pd.DataFrame(all_returns).dropna() self.cov_matrix = self.returns.cov() * 365 # annualized return self.returns
def mean_variance_optimize(self, target_return: Optional[float] = None ) -> np.ndarray: """Markowitz mean-variance optimization.""" n = len(self.symbols) mu = self.returns.mean() * 365 Sigma = self.cov_matrix
def portfolio_volatility(w): return np.sqrt(w @ Sigma.values @ w)
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1}] if target_return is not None: constraints.append({ "type": "eq", "fun": lambda w: w @ mu.values - target_return }) bounds = [(0, 1)] * n w0 = np.ones(n) / n result = minimize(portfolio_volatility, w0, method="SLSQP", bounds=bounds, constraints=constraints) return result.x
def minimum_variance(self) -> np.ndarray: """Compute minimum variance portfolio weights.""" return self.mean_variance_optimize(target_return=None)
def black_litterman(self, views: Dict[str, float], view_confidences: Dict[str, float], tau: float = 0.05, delta: float = 2.5 ) -> np.ndarray: """Black-Litterman model with investor views.""" n = len(self.symbols) Sigma = self.cov_matrix.values w_market = np.ones(n) / n # equal weight as proxy for market cap pi = delta * Sigma @ w_market
# Construct P and Q from views P = np.zeros((len(views), n)) Q = np.zeros(len(views)) omega_diag = np.zeros(len(views))
for i, (asset, view_return) in enumerate(views.items()): idx = self.symbols.index(asset) P[i, idx] = 1.0 Q[i] = view_return omega_diag[i] = (1.0 / view_confidences[asset]) * tau * Sigma[idx, idx]
Omega = np.diag(omega_diag) tau_Sigma_inv = np.linalg.inv(tau * Sigma) M = np.linalg.inv(tau_Sigma_inv + P.T @ np.linalg.inv(Omega) @ P) mu_bl = M @ (tau_Sigma_inv @ pi + P.T @ np.linalg.inv(Omega) @ Q)
# Optimize with BL expected returns def neg_sharpe(w): ret = w @ mu_bl vol = np.sqrt(w @ Sigma @ w) return -(ret - self.risk_free_rate) / vol
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1}] bounds = [(0, 1)] * n w0 = np.ones(n) / n result = minimize(neg_sharpe, w0, method="SLSQP", bounds=bounds, constraints=constraints) return result.x
def hierarchical_risk_parity(self) -> np.ndarray: """Hierarchical Risk Parity (HRP) allocation.""" corr = self.returns.corr() dist = np.sqrt(0.5 * (1 - corr)) dist_condensed = squareform(dist.values, checks=False) link = linkage(dist_condensed, method="single") sort_ix = leaves_list(link) sorted_symbols = [self.symbols[i] for i in sort_ix]
# Recursive bisection weights = pd.Series(1.0, index=sorted_symbols) clusters = [sorted_symbols]
while clusters: new_clusters = [] for cluster in clusters: if len(cluster) <= 1: continue mid = len(cluster) // 2 left = cluster[:mid] right = cluster[mid:]
left_var = self._cluster_variance(left) right_var = self._cluster_variance(right) alpha = 1 - left_var / (left_var + right_var)
for s in left: weights[s] *= alpha for s in right: weights[s] *= (1 - alpha)
new_clusters.extend([left, right]) clusters = new_clusters
return weights.reindex(self.symbols).values
def _cluster_variance(self, symbols: List[str]) -> float: sub_cov = self.returns[symbols].cov() * 365 w = np.ones(len(symbols)) / len(symbols) return w @ sub_cov.values @ w
def kelly_criterion(self, leverage: float = 1.0, funding_rate: float = 0.0001) -> np.ndarray: """Kelly criterion with funding rate adjustment.""" mu = self.returns.mean() * 365 var = self.returns.var() * 365 # Adjust for funding cost on leveraged positions mu_adj = mu - funding_rate * 3 * 365 * leverage kelly_fractions = (mu_adj - self.risk_free_rate) / var # Half-Kelly for safety half_kelly = kelly_fractions / 2 # Normalize to sum to 1, clamp negatives to 0 half_kelly = half_kelly.clip(lower=0) if half_kelly.sum() > 0: half_kelly = half_kelly / half_kelly.sum() return half_kelly.values
def risk_parity(self) -> np.ndarray: """Equal risk contribution portfolio.""" n = len(self.symbols) Sigma = self.cov_matrix.values
def risk_budget_objective(w): port_var = w @ Sigma @ w marginal = Sigma @ w rc = w * marginal target_rc = port_var / n return np.sum((rc - target_rc) ** 2)
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1}] bounds = [(0.01, 1)] * n w0 = np.ones(n) / n result = minimize(risk_budget_objective, w0, method="SLSQP", bounds=bounds, constraints=constraints) return result.x
class CryptoRiskMetrics: """Risk metrics adapted for 24/7 crypto markets."""
@staticmethod def sharpe_ratio(returns: pd.Series, risk_free_rate: float = 0.05) -> float: excess = returns - risk_free_rate / 365 return np.sqrt(365) * excess.mean() / excess.std()
@staticmethod def sortino_ratio(returns: pd.Series, risk_free_rate: float = 0.05) -> float: excess = returns - risk_free_rate / 365 downside = excess[excess < 0] downside_std = np.sqrt((downside ** 2).mean()) return np.sqrt(365) * excess.mean() / downside_std
@staticmethod def calmar_ratio(returns: pd.Series) -> float: annual_return = returns.mean() * 365 max_dd = CryptoRiskMetrics.max_drawdown(returns) return annual_return / abs(max_dd) if max_dd != 0 else 0
@staticmethod def max_drawdown(returns: pd.Series) -> float: cumulative = (1 + returns).cumprod() peak = cumulative.cummax() drawdown = (cumulative - peak) / peak return drawdown.min()
@staticmethod def funding_carry(funding_rates: pd.Series) -> float: """Annualized funding carry from 8h rates.""" return funding_rates.mean() * 3 * 365Usage Example
# Initialize optimizer with Bybit perpetual symbolsoptimizer = CryptoPortfolioOptimizer( symbols=["BTCUSDT", "ETHUSDT", "SOLUSDT", "AVAXUSDT", "LINKUSDT"])optimizer.fetch_returns(source="bybit")
# Mean-variance optimizationmv_weights = optimizer.mean_variance_optimize(target_return=0.50)print("Mean-Variance Weights:", dict(zip(optimizer.symbols, mv_weights.round(4))))
# Black-Litterman with crypto viewsbl_weights = optimizer.black_litterman( views={"ETHUSDT": 0.80, "SOLUSDT": 1.20}, view_confidences={"ETHUSDT": 0.6, "SOLUSDT": 0.4})print("Black-Litterman Weights:", dict(zip(optimizer.symbols, bl_weights.round(4))))
# Hierarchical Risk Parityhrp_weights = optimizer.hierarchical_risk_parity()print("HRP Weights:", dict(zip(optimizer.symbols, hrp_weights.round(4))))
# Kelly criterion for 2x leveraged positionskelly_weights = optimizer.kelly_criterion(leverage=2.0, funding_rate=0.0001)print("Half-Kelly Weights:", dict(zip(optimizer.symbols, kelly_weights.round(4))))Section 6: Implementation in Rust
Project Structure
ch05_crypto_portfolio_risk/├── Cargo.toml├── src/│ ├── lib.rs│ ├── optimization/│ │ ├── mod.rs│ │ ├── mean_variance.rs│ │ └── hrp.rs│ ├── risk/│ │ ├── mod.rs│ │ └── metrics.rs│ └── backtest/│ ├── mod.rs│ └── engine.rs└── examples/ ├── portfolio_optimization.rs ├── kelly_sizing.rs └── risk_parity.rsCore Library (src/lib.rs)
pub mod optimization;pub mod risk;pub mod backtest;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]pub struct PortfolioConfig { pub symbols: Vec<String>, pub risk_free_rate: f64, pub rebalance_threshold: f64, pub max_leverage: f64,}
#[derive(Debug, Clone)]pub struct PortfolioWeights { pub symbols: Vec<String>, pub weights: Vec<f64>, pub method: String,}
impl PortfolioWeights { pub fn display(&self) { println!("Portfolio Allocation ({}):", self.method); for (sym, w) in self.symbols.iter().zip(self.weights.iter()) { println!(" {}: {:.2}%", sym, w * 100.0); } }}Bybit Data Fetcher
use reqwest;use serde::Deserialize;use anyhow::Result;
#[derive(Deserialize)]struct BybitResponse { result: BybitResult,}
#[derive(Deserialize)]struct BybitResult { list: Vec<Vec<String>>,}
pub async fn fetch_bybit_klines( symbol: &str, interval: &str, limit: u32,) -> Result<Vec<f64>> { let client = reqwest::Client::new(); let resp = client .get("https://api.bybit.com/v5/market/kline") .query(&[ ("category", "linear"), ("symbol", symbol), ("interval", interval), ("limit", &limit.to_string()), ]) .send() .await? .json::<BybitResponse>() .await?;
let closes: Vec<f64> = resp.result.list .iter() .map(|row| row[4].parse::<f64>().unwrap_or(0.0)) .rev() .collect();
Ok(closes)}
pub fn compute_returns(prices: &[f64]) -> Vec<f64> { prices.windows(2) .map(|w| (w[1] - w[0]) / w[0]) .collect()}Mean-Variance Optimizer (src/optimization/mean_variance.rs)
use crate::PortfolioWeights;
pub struct MeanVarianceOptimizer { pub returns: Vec<Vec<f64>>, pub symbols: Vec<String>,}
impl MeanVarianceOptimizer { pub fn new(symbols: Vec<String>, returns: Vec<Vec<f64>>) -> Self { Self { returns, symbols } }
pub fn covariance_matrix(&self) -> Vec<Vec<f64>> { let n = self.returns.len(); let t = self.returns[0].len(); let means: Vec<f64> = self.returns.iter() .map(|r| r.iter().sum::<f64>() / t as f64) .collect();
let mut cov = vec![vec![0.0; n]; n]; for i in 0..n { for j in 0..=i { let c: f64 = (0..t) .map(|k| { (self.returns[i][k] - means[i]) * (self.returns[j][k] - means[j]) }) .sum::<f64>() / (t - 1) as f64; let annualized = c * 365.0; cov[i][j] = annualized; cov[j][i] = annualized; } } cov }
pub fn minimum_variance(&self) -> PortfolioWeights { let n = self.symbols.len(); let cov = self.covariance_matrix(); // Gradient descent optimization for minimum variance let mut weights = vec![1.0 / n as f64; n]; let lr = 0.001; let iterations = 5000;
for _ in 0..iterations { let mut grad = vec![0.0; n]; for i in 0..n { for j in 0..n { grad[i] += 2.0 * cov[i][j] * weights[j]; } } // Project onto simplex for i in 0..n { weights[i] -= lr * grad[i]; weights[i] = weights[i].max(0.0); } let sum: f64 = weights.iter().sum(); for w in weights.iter_mut() { *w /= sum; } }
PortfolioWeights { symbols: self.symbols.clone(), weights, method: "Minimum Variance".to_string(), } }}Risk Metrics (src/risk/metrics.rs)
pub struct CryptoRiskMetrics;
impl CryptoRiskMetrics { pub fn sharpe_ratio(returns: &[f64], risk_free_rate: f64) -> f64 { let daily_rf = risk_free_rate / 365.0; let excess: Vec<f64> = returns.iter().map(|r| r - daily_rf).collect(); let mean = excess.iter().sum::<f64>() / excess.len() as f64; let variance = excess.iter() .map(|r| (r - mean).powi(2)) .sum::<f64>() / (excess.len() - 1) as f64; (365.0_f64).sqrt() * mean / variance.sqrt() }
pub fn sortino_ratio(returns: &[f64], risk_free_rate: f64) -> f64 { let daily_rf = risk_free_rate / 365.0; let excess: Vec<f64> = returns.iter().map(|r| r - daily_rf).collect(); let mean = excess.iter().sum::<f64>() / excess.len() as f64; let downside: Vec<f64> = excess.iter() .filter(|&&r| r < 0.0) .cloned() .collect(); let downside_var = downside.iter() .map(|r| r.powi(2)) .sum::<f64>() / downside.len().max(1) as f64; (365.0_f64).sqrt() * mean / downside_var.sqrt() }
pub fn max_drawdown(returns: &[f64]) -> f64 { let mut cumulative = 1.0; let mut peak = 1.0; let mut max_dd = 0.0_f64;
for &r in returns { cumulative *= 1.0 + r; peak = peak.max(cumulative); let dd = (cumulative - peak) / peak; max_dd = max_dd.min(dd); } max_dd }
pub fn calmar_ratio(returns: &[f64]) -> f64 { let annual_return = returns.iter().sum::<f64>() / returns.len() as f64 * 365.0; let max_dd = Self::max_drawdown(returns).abs(); if max_dd > 0.0 { annual_return / max_dd } else { 0.0 } }
pub fn kelly_fraction( expected_return: f64, variance: f64, risk_free_rate: f64, funding_rate: f64, leverage: f64, ) -> f64 { let adjusted_return = expected_return - funding_rate * 3.0 * 365.0 * leverage; let kelly = (adjusted_return - risk_free_rate) / variance; (kelly / 2.0).max(0.0) // half-Kelly, non-negative }}Backtest Engine (src/backtest/engine.rs)
use crate::risk::metrics::CryptoRiskMetrics;
pub struct BacktestConfig { pub initial_capital: f64, pub taker_fee: f64, // 0.00055 for Bybit pub maker_fee: f64, // 0.0002 for Bybit pub slippage_bps: f64, // basis points pub funding_interval_hours: f64,}
impl Default for BacktestConfig { fn default() -> Self { Self { initial_capital: 100_000.0, taker_fee: 0.00055, maker_fee: 0.0002, slippage_bps: 1.0, funding_interval_hours: 8.0, } }}
pub struct BacktestResult { pub total_return: f64, pub sharpe_ratio: f64, pub sortino_ratio: f64, pub max_drawdown: f64, pub calmar_ratio: f64, pub total_fees: f64, pub total_funding: f64, pub num_rebalances: u32,}
pub fn run_backtest( returns_matrix: &[Vec<f64>], weights_history: &[Vec<f64>], config: &BacktestConfig, funding_rates: &[f64],) -> BacktestResult { let num_periods = returns_matrix[0].len(); let mut portfolio_returns = Vec::with_capacity(num_periods); let mut total_fees = 0.0; let mut total_funding = 0.0; let mut num_rebalances = 0u32;
for t in 0..num_periods { let mut period_return = 0.0; for (i, asset_returns) in returns_matrix.iter().enumerate() { period_return += weights_history[t][i] * asset_returns[t]; }
// Deduct transaction costs on rebalance if t > 0 { let turnover: f64 = weights_history[t].iter() .zip(weights_history[t - 1].iter()) .map(|(w1, w0)| (w1 - w0).abs()) .sum(); if turnover > 0.01 { let fee = turnover * (config.taker_fee + config.slippage_bps * 0.0001); period_return -= fee; total_fees += fee; num_rebalances += 1; } }
// Deduct funding if t < funding_rates.len() { total_funding += funding_rates[t]; period_return -= funding_rates[t]; }
portfolio_returns.push(period_return); }
let sharpe = CryptoRiskMetrics::sharpe_ratio(&portfolio_returns, 0.05); let sortino = CryptoRiskMetrics::sortino_ratio(&portfolio_returns, 0.05); let max_dd = CryptoRiskMetrics::max_drawdown(&portfolio_returns); let calmar = CryptoRiskMetrics::calmar_ratio(&portfolio_returns); let total_return = portfolio_returns.iter() .fold(1.0, |acc, r| acc * (1.0 + r)) - 1.0;
BacktestResult { total_return, sharpe_ratio: sharpe, sortino_ratio: sortino, max_drawdown: max_dd, calmar_ratio: calmar, total_fees, total_funding, num_rebalances, }}Section 7: Practical Examples
Example 1: Building a Risk Parity Portfolio for Top 5 Crypto Assets
optimizer = CryptoPortfolioOptimizer( symbols=["BTCUSDT", "ETHUSDT", "SOLUSDT", "AVAXUSDT", "LINKUSDT"])optimizer.fetch_returns(source="bybit")
rp_weights = optimizer.risk_parity()print("Risk Parity Weights:")for sym, w in zip(optimizer.symbols, rp_weights): print(f" {sym}: {w:.2%}")
# Expected output:# BTCUSDT: 38.12%# ETHUSDT: 27.45%# SOLUSDT: 12.31%# AVAXUSDT: 10.89%# LINKUSDT: 11.23%
metrics = CryptoRiskMetrics()portfolio_returns = (optimizer.returns * rp_weights).sum(axis=1)print(f"Sharpe Ratio: {metrics.sharpe_ratio(portfolio_returns):.3f}")print(f"Max Drawdown: {metrics.max_drawdown(portfolio_returns):.2%}")Example 2: Black-Litterman with Bullish ETH View
optimizer = CryptoPortfolioOptimizer( symbols=["BTCUSDT", "ETHUSDT", "SOLUSDT"])optimizer.fetch_returns(source="bybit")
# View: ETH will return 100% annualized with 70% confidence# View: SOL will return 150% annualized with 40% confidencebl_weights = optimizer.black_litterman( views={"ETHUSDT": 1.00, "SOLUSDT": 1.50}, view_confidences={"ETHUSDT": 0.7, "SOLUSDT": 0.4})
print("Black-Litterman Allocation:")for sym, w in zip(optimizer.symbols, bl_weights): print(f" {sym}: {w:.2%}")
# Expected output:# BTCUSDT: 25.34%# ETHUSDT: 48.21%# SOLUSDT: 26.45%Example 3: Kelly Sizing for Leveraged Perpetual Futures
optimizer = CryptoPortfolioOptimizer(symbols=["BTCUSDT", "ETHUSDT"])optimizer.fetch_returns(source="bybit")
# Fetch current funding rates from Bybiturl = "https://api.bybit.com/v5/market/tickers"params = {"category": "linear", "symbol": "BTCUSDT"}resp = requests.get(url, params=params).json()funding_rate = float(resp["result"]["list"][0]["fundingRate"])print(f"Current BTC Funding Rate: {funding_rate:.6f}")print(f"Annualized Funding: {funding_rate * 3 * 365:.2%}")
for leverage in [1.0, 2.0, 3.0, 5.0]: kelly_w = optimizer.kelly_criterion( leverage=leverage, funding_rate=funding_rate ) print(f"\nLeverage {leverage}x - Half-Kelly Weights:") for sym, w in zip(optimizer.symbols, kelly_w): print(f" {sym}: {w:.2%}")
# Expected output:# Current BTC Funding Rate: 0.000100# Annualized Funding: 10.95%## Leverage 1.0x - Half-Kelly Weights:# BTCUSDT: 62.14%# ETHUSDT: 37.86%## Leverage 3.0x - Half-Kelly Weights:# BTCUSDT: 71.23%# ETHUSDT: 28.77%## Leverage 5.0x - Half-Kelly Weights:# BTCUSDT: 100.00%# ETHUSDT: 0.00%Section 8: Backtesting Framework
Framework Components
The backtesting framework for crypto portfolio construction includes:
- Data Pipeline: Fetches historical OHLCV and funding rate data from Bybit
- Portfolio Optimizer: Computes target weights using selected method
- Execution Simulator: Models realistic fills with fees and slippage
- Risk Monitor: Tracks real-time risk metrics and liquidation proximity
- Performance Analyzer: Computes comprehensive performance statistics
Metrics Dashboard
| Metric | Description | Computation |
|---|---|---|
| Total Return | Cumulative portfolio return | Product of (1 + r_t) - 1 |
| Annualized Return | Geometric mean annual return | (1 + total)^(365/days) - 1 |
| Sharpe Ratio | Risk-adjusted return (24/7) | sqrt(365) * mean(r) / std(r) |
| Sortino Ratio | Downside risk-adjusted return | sqrt(365) * mean(r) / downside_std |
| Calmar Ratio | Return / max drawdown | ann_return / abs(max_dd) |
| Max Drawdown | Largest peak-to-trough decline | min((cum - peak) / peak) |
| Turnover | Annual portfolio turnover | sum(abs(w_t - w_{t-1})) * 365/T |
| Total Fees | Transaction costs incurred | sum(turnover * fee_rate) |
| Funding P&L | Net funding payments | sum(funding_rate * position_value) |
Sample Backtest Results
=== Portfolio Backtest Results (2024-01-01 to 2024-12-31) ===
Universe: BTCUSDT, ETHUSDT, SOLUSDT, AVAXUSDT, LINKUSDTRebalance: Weekly | Fee Model: Bybit Taker 0.055% + 1bp slippage
Strategy | Return | Sharpe | Sortino | MaxDD | Calmar | Turnover-----------------------|---------|--------|---------|---------|--------|----------Equal Weight (1/N) | 87.2% | 1.42 | 2.01 | -32.1% | 2.72 | 124%Min Variance | 52.3% | 1.68 | 2.34 | -18.4% | 2.84 | 89%Risk Parity | 71.5% | 1.61 | 2.28 | -22.7% | 3.15 | 97%HRP | 74.8% | 1.55 | 2.15 | -25.3% | 2.96 | 103%Black-Litterman | 96.1% | 1.38 | 1.92 | -35.6% | 2.70 | 142%Kelly (2x leverage) | 134.5% | 1.21 | 1.67 | -48.2% | 2.79 | 178%
Net of Fees: Equal Weight: 84.9% (fees: 2.3%) Min Variance: 50.8% (fees: 1.5%) Risk Parity: 69.8% (fees: 1.7%) HRP: 72.9% (fees: 1.9%) Black-Litterman: 93.2% (fees: 2.9%) Kelly (2x): 127.1% (fees: 7.4%, funding: 3.8%)Section 9: Performance Evaluation
Comparison of Allocation Methods
| Criterion | Mean-Variance | Risk Parity | HRP | Black-Litterman | Kelly |
|---|---|---|---|---|---|
| Estimation Error Sensitivity | Very High | Low | Low | Medium | High |
| Handles Regime Changes | No | Partial | Yes | Partial | No |
| Requires Return Forecasts | Yes | No | No | Yes (views) | Yes |
| Suitable for Leverage | No | Yes | No | No | Yes |
| Robust to Outliers | No | Partial | Yes | Partial | No |
| Implementation Complexity | Low | Medium | Medium | High | Low |
| Out-of-Sample Stability | Poor | Good | Good | Moderate | Poor |
Key Findings
-
Risk Parity and HRP consistently outperform mean-variance on a risk-adjusted basis in crypto markets, primarily because they avoid the extreme sensitivity to expected return estimates that plagues Markowitz optimization.
-
Correlation regime shifts are the primary driver of portfolio risk in crypto. During the May 2021 crash, BTC-altcoin correlations jumped from 0.45 to 0.92 within days, rendering static allocation models inadequate.
-
Funding rate carry is a significant return component for leveraged portfolios. During sustained bull markets, funding can cost 10-30% annualized, substantially eroding leveraged returns.
-
The 1/N portfolio remains a strong benchmark that is difficult to beat consistently after transaction costs, particularly for small universes of 3-5 assets.
-
Half-Kelly sizing provides a practical balance between growth maximization and drawdown control. Full Kelly leads to unacceptable drawdowns in crypto’s fat-tailed environment.
Limitations
- Covariance estimates are inherently backward-looking and may not capture emerging correlation structures
- Extreme tail events (exchange hacks, depegs) are not modeled
- Liquidity constraints for large portfolios in altcoin markets are not addressed
- Funding rate modeling assumes constant rates, while actual rates fluctuate significantly
- Counterparty risk (exchange solvency) is not incorporated into the portfolio model
Section 10: Future Directions
-
DeFi-Integrated Portfolio Construction: Incorporating yield farming, liquidity provision, and staking returns into the portfolio optimization framework, treating DeFi protocols as distinct asset classes with their own risk-return profiles.
-
On-Chain Analytics for Covariance Prediction: Using blockchain data (whale movements, exchange flows, liquidation maps) to predict changes in the correlation structure before they manifest in price data.
-
Regime-Switching Models for Dynamic Allocation: Implementing Hidden Markov Models or jump-diffusion processes to detect correlation regime changes in real-time and dynamically adjust portfolio weights.
-
Cross-Exchange Arbitrage in Portfolio Context: Extending the portfolio framework to optimize across multiple exchanges, capturing price discrepancies while managing the associated transfer and counterparty risks.
-
Machine Learning for Covariance Forecasting: Applying deep learning (graph neural networks, transformers) to forecast the full covariance matrix, leveraging the rich microstructure data available in crypto markets.
-
Tail Risk Hedging with Options: Incorporating crypto options (available on Bybit) into the portfolio as explicit tail hedges, using variance swaps and put spreads to protect against flash crashes.
References
-
Markowitz, H. (1952). “Portfolio Selection.” The Journal of Finance, 7(1), 77-91.
-
Black, F., & Litterman, R. (1992). “Global Portfolio Optimization.” Financial Analysts Journal, 48(5), 28-43.
-
Maillard, S., Roncalli, T., & Teiletche, J. (2010). “The Properties of Equally Weighted Risk Contribution Portfolios.” The Journal of Portfolio Management, 36(4), 60-70.
-
De Prado, M. L. (2016). “Building Diversified Portfolios that Outperform Out of Sample.” The Journal of Portfolio Management, 42(4), 59-69.
-
Kelly, J. L. (1956). “A New Interpretation of Information Rate.” Bell System Technical Journal, 35(4), 917-926.
-
Ledoit, O., & Wolf, M. (2004). “A Well-Conditioned Estimator for Large-Dimensional Covariance Matrices.” Journal of Multivariate Analysis, 88(2), 365-411.
-
Liu, Y., Tsyvinski, A., & Wu, X. (2022). “Common Risk Factors in Cryptocurrency.” The Journal of Finance, 77(2), 1133-1177.