Глава 13: Обнаружение скрытой структуры: обучение без учителя для крипторынков
Обзор
Криптовалютные рынки представляют уникальную задачу для количественных аналитиков: сотни токенов торгуются одновременно, движимые пересекающимися нарративами, общими технологическими основами и коррелированными спекулятивными циклами. В отличие от традиционных акций, где отраслевые классификации (GICS, ICB) предоставляют готовую таксономию, криптовселенная не имеет общепринятой структуры. Обучение без учителя заполняет этот пробел, обнаруживая скрытые паттерны непосредственно из рыночных данных — выявляя, какие токены движутся вместе, почему они движутся вместе и как эти связи эволюционируют со временем.
Метод главных компонент (PCA) и анализ независимых компонент (ICA) разлагают многомерное пространство криптодоходностей на интерпретируемые факторы. Первая главная компонента матрицы доходностей широкого крипторынка почти неизменно соответствует «доминированию BTC» — рыночной бете, которая тянет за собой большинство альткоинов. Последующие компоненты захватывают секторные темы: фактор DeFi с высокими нагрузками на AAVE, UNI и COMP; фактор Layer-1, движимый SOL, AVAX и NEAR; и даже мем-фактор, отражающий совместное движение DOGE, SHIB и PEPE. Извлекая эти собственные портфели (eigenportfolios), трейдеры могут строить хеджированные позиции, изолирующие экспозицию на отдельный нарратив и нейтрализующие широкий рыночный риск.
Помимо факторной декомпозиции, алгоритмы кластеризации (k-Means, DBSCAN, иерархическая кластеризация) и методы визуализации многообразий (t-SNE, UMAP) позволяют отобразить всю вселенную токенов на интерпретируемые двумерные ландшафты. Модели гауссовых смесей (GMM) расширяют это до определения режимов — выявляя, находится ли рынок в фазе risk-on ралли альткоинов, в фазе бегства в качество с доминированием BTC или в фазе коррелированной просадки. Наконец, иерархический паритет рисков (HRP) использует структуру дендрограммы корреляций токенов для построения диверсифицированных портфелей без обращения зашумлённой ковариационной матрицы. Эта глава охватывает полный конвейер от сырых доходностей до практического построения портфеля.
Содержание
- Введение в обучение без учителя в криптовалютах
- Математические основы
- Сравнение методов обучения без учителя
- Торговые приложения
- Реализация на Python
- Реализация на Rust
- Практические примеры
- Фреймворк бэктестирования
- Оценка производительности
- Перспективные направления
Раздел 1: Введение в обучение без учителя в криптовалютах
Почему обучение без учителя?
Обучение с учителем требует размеченных данных — сигналов покупки/продажи, меток режимов или будущих доходностей. Обучение без учителя не требует ничего из этого. Оно обнаруживает структуру, уже существующую в данных, что делает его идеальным для исследовательского анализа быстро развивающегося рынка, где метки дороги, субъективны или просто недоступны.
На крипторынках методы обучения без учителя отвечают на фундаментальные вопросы:
- Какие скрытые факторы движут доходностями? PCA показывает, что 60-70% поперечной дисперсии объясняется одним фактором доминирования BTC.
- Какие токены ведут себя похоже? Кластеризация группирует токены по статистическому поведению, а не по произвольной таксономии.
- В каком режиме мы находимся? GMM обнаруживает переключения между средами risk-on и risk-off.
- Как распределить капитал? HRP строит портфели с учётом иерархической структуры корреляций.
Проклятие размерности в криптовалютах
При 200+ ликвидных токенах матрица доходностей имеет высокую размерность. Ковариационная матрица 200 активов содержит 20 100 уникальных элементов, но год дневных данных даёт лишь ~365 наблюдений. Это соотношение делает выборочную ковариационную матрицу сингулярной или почти сингулярной — собственные значения искажены, а наивная оптимизация среднего-дисперсии порождает экстремальные, нестабильные веса. Снижение размерности через PCA или факторные модели не просто удобно; оно необходимо для стабильной оценки.
Ключевая терминология
- PCA (метод главных компонент): Линейное преобразование, находящее ортогональные направления максимальной дисперсии.
- ICA (анализ независимых компонент): Находит статистически независимые (не просто некоррелированные) исходные сигналы.
- Собственные портфели (Eigenportfolios): Портфели, веса которых являются собственными векторами ковариационной матрицы.
- Собственные значения / Собственные векторы: Скаляры и направления, удовлетворяющие Av = λv для ковариационной матрицы A.
- Объяснённая дисперсия: Доля общей дисперсии, захваченная каждой главной компонентой.
- Обучение на многообразиях (Manifold Learning): Нелинейное снижение размерности с сохранением локальной или глобальной геометрии.
- t-SNE: Стохастическое вложение соседей, сохраняющее локальные расстояния в 2D/3D.
- UMAP: Uniform Manifold Approximation and Projection — быстрее, лучше сохраняет глобальную структуру, чем t-SNE.
- LLE (локально линейное вложение): Реконструирует каждую точку как линейную комбинацию соседей.
- k-Means: Кластеризация на основе разбиений, минимизирующая внутрикластерную сумму квадратов.
- Иерархическая кластеризация: Строит дендрограмму вложенных кластеров агломеративным или дивизивным методом.
- DBSCAN: Кластеризация на основе плотности, обнаруживающая кластеры произвольной формы и помечающая выбросы.
- GMM (модели гауссовых смесей): Вероятностная кластеризация, предполагающая, что данные порождены смесью гауссиан.
- Коэффициент силуэта: Мера качества кластеризации от -1 (неверный кластер) до +1 (хорошая кластеризация).
- Дендрограмма: Древовидная диаграмма, показывающая иерархию объединений кластеров.
- Иерархический паритет рисков (HRP): Распределение портфеля с использованием иерархической кластеризации корреляционной матрицы.
- Ковариационная матрица: Матрица попарных ковариаций доходностей активов.
- Снижение размерности: Проецирование многомерных данных в меньшее число измерений с сохранением структуры.
- Вращение факторов: Вращение компонент PCA (например, varimax) для более интерпретируемых нагрузок.
Раздел 2: Математические основы
Метод главных компонент
Дана матрица доходностей X размера (T x N), где T — количество временных периодов, N — количество активов. PCA выполняется следующим образом:
- Центрирование данных: X̃ = X - μ, где μ — среднее по столбцам.
- Вычисление ковариационной матрицы: Σ = (1/T) X̃ᵀX̃
- Спектральное разложение: Σ = VΛVᵀ, где V — матрица собственных векторов и Λ = diag(λ₁, λ₂, …, λ_N) при λ₁ ≥ λ₂ ≥ … ≥ λ_N.
- Проекция: Z = X̃V_k, где V_k содержит первые k собственных векторов.
Доля объяснённой дисперсии для компоненты i:
EVR_i = λ_i / Σⱼ λⱼАнализ независимых компонент
ICA предполагает, что наблюдаемые данные X являются линейной смесью независимых источников S:
X = ASгде A — матрица смешивания. ICA восстанавливает демиксирующую матрицу W = A⁻¹, максимизируя негауссовость оценённых источников Ŝ = WX, обычно используя негэнтропию или эксцесс в качестве целевой функции.
Модели гауссовых смесей
GMM моделирует распределение данных как:
p(x) = Σₖ πₖ N(x | μₖ, Σₖ)где πₖ — веса смешивания (Σ πₖ = 1), и каждая компонента является многомерной гауссианой. Параметры оцениваются через алгоритм Expectation-Maximization (EM):
- E-шаг: Вычисление ответственностей γₖ(xᵢ) = πₖ N(xᵢ|μₖ,Σₖ) / p(xᵢ)
- M-шаг: Обновление μₖ, Σₖ, πₖ по взвешенным достаточным статистикам.
Иерархический паритет рисков
HRP (Маркос Лопес де Прадо, 2016) выполняется в три этапа:
- Кластеризация в дерево: Вычисляется матрица расстояний d(i,j) = √(0.5(1 - ρᵢⱼ)) и строится дендрограмма методом single/complete/ward.
- Квази-диагонализация: Переупорядочение ковариационной матрицы так, чтобы коррелированные активы были смежными.
- Рекурсивная бисекция: Распределение весов путём рекурсивного разделения отсортированных активов и назначения обратно-дисперсионных весов каждой половине.
Коэффициент силуэта
Для точки данных i в кластере Cₖ:
s(i) = (b(i) - a(i)) / max(a(i), b(i))где a(i) — среднее внутрикластерное расстояние, b(i) — среднее расстояние до ближайшего кластера.
Раздел 3: Сравнение методов обучения без учителя
| Метод | Тип | Масштабируемость | Нелинейность | Интерпретируемость | Ключевой гиперпараметр |
|---|---|---|---|---|---|
| PCA | Декомпозиция | Отличная (O(N²T)) | Нет (линейный) | Высокая (нагрузки) | n_components |
| ICA | Декомпозиция | Хорошая | Частично | Средняя | n_components |
| t-SNE | Визуализация | Плохая (O(N²)) | Да | Низкая (только визуально) | perplexity |
| UMAP | Визуализация | Хорошая (O(N log N)) | Да | Низкая (только визуально) | n_neighbors, min_dist |
| k-Means | Кластеризация | Отличная (O(NKI)) | Нет | Высокая | k (n_clusters) |
| DBSCAN | Кластеризация | Хорошая (O(N log N)) | Да | Средняя | eps, min_samples |
| Иерархическая | Кластеризация | Плохая (O(N³)) | Нет | Высокая (дендрограмма) | метод связывания |
| GMM | Кластеризация | Хорошая | Частично (гауссова) | Средняя | n_components, covariance_type |
| HRP | Портфель | Хорошая | Нет | Высокая (дендрограмма) | связывание, метрика расстояния |
Когда что использовать
- PCA: Первый проход по любой матрице криптодоходностей. Необходим для понимания факторной структуры.
- ICA: Когда вы подозреваете пересекающиеся нарративы (например, токен одновременно DeFi и L1).
- t-SNE / UMAP: Для визуального исследования и презентаций. UMAP предпочтителен для большей вселенной.
- k-Means: Когда ожидаются примерно сферические кластеры одинакового размера.
- DBSCAN: Когда кластеры имеют нерегулярную форму или нужно обнаружить токены-выбросы.
- GMM: Для определения режимов, где ценны мягкие назначения (вероятности).
- HRP: Для построения портфеля, когда ковариационная матрица зашумлена или сингулярна.
Раздел 4: Торговые приложения
4.1 Факторно-нейтральная торговля
Извлеките первые 3-5 компонент PCA из вселенной 50+ токенов. Постройте лонг-шорт портфель, нейтральный к этим факторам. Это изолирует альфу от сигналов, специфичных для токена (например, ончейн-метрики), хеджируя при этом широкие рыночные движения и секторные ротации.
4.2 Стратегии с учётом режимов
Используйте GMM для классификации каждого дня в один из K режимов (например, K=3: бычий, медвежий, боковой). Корректируйте размер позиции, кредитное плечо и выбор активов на основе обнаруженного режима. В режиме «доминирования BTC» уменьшайте вес альткоинов и увеличивайте вес BTC. В режиме «ралли альткоинов» увеличивайте экспозицию на DeFi/L1.
4.3 Парная торговля на основе кластеров
Кластеризуйте токены по поведению доходности с помощью DBSCAN. Внутри каждого кластера определите пары, которые временно разошлись (z-score > 2 на спреде). Торгуйте схождение. Кластеры гарантируют, что пары имеют подлинное статистическое сходство, а не поверхностные секторные метки.
4.4 Динамическая ребалансировка портфеля с HRP
Запускайте HRP ежемесячно на скользящей 90-дневной корреляционной матрице топ-30 токенов по рыночной капитализации. Дендрограмма естественным образом группирует коррелированные токены и назначает меньший вес избыточным позициям. Это позволяет избежать нестабильности оптимизации среднего-дисперсии, уважая иерархию корреляций.
4.5 Ротация нарративов через нагрузки PCA
Мониторьте нагрузки топ-5 главных компонент на скользящих окнах. Когда появляется новая компонента (например, внезапный фактор AI-нарратива с нагрузками на FET, RNDR, AGIX), ротируйте в токены с наибольшими нагрузками на формирующийся фактор. Это захватывает моментум нарратива до того, как он станет консенсусом.
Раздел 5: Реализация на Python
import numpy as npimport pandas as pdfrom pybit.unified_trading import HTTPimport yfinance as yffrom sklearn.decomposition import PCA, FastICAfrom sklearn.cluster import KMeans, DBSCAN, AgglomerativeClusteringfrom sklearn.mixture import GaussianMixturefrom sklearn.manifold import TSNEfrom sklearn.metrics import silhouette_scorefrom sklearn.preprocessing import StandardScalerfrom scipy.cluster.hierarchy import dendrogram, linkage, to_treefrom scipy.spatial.distance import squareformimport umap
class CryptoDataFetcher: """Получение данных о доходности криптовалют с Bybit и yfinance."""
def __init__(self): self.bybit = HTTP()
def fetch_bybit_klines(self, symbol: str, interval: str = "D", limit: int = 365) -> pd.Series: """Получить дневные цены закрытия с Bybit и вычислить логарифмические доходности.""" resp = self.bybit.get_kline( category="spot", symbol=symbol, interval=interval, limit=limit ) rows = resp["result"]["list"] df = pd.DataFrame(rows, 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 np.log(df["close"] / df["close"].shift(1)).dropna()
def build_return_matrix(self, symbols: list[str]) -> pd.DataFrame: """Построить матрицу доходностей T x N из символов Bybit.""" returns = {} for sym in symbols: try: returns[sym] = self.fetch_bybit_klines(sym) except Exception as e: print(f"Пропускаем {sym}: {e}") return pd.DataFrame(returns).dropna()
def fetch_yfinance_supplement(self, tickers: list[str], period: str = "1y") -> pd.DataFrame: """Получить дополнительные данные из yfinance (например, SPY, GLD).""" data = yf.download(tickers, period=period, auto_adjust=True) closes = data["Close"] return np.log(closes / closes.shift(1)).dropna()
class CryptoPCA: """PCA-декомпозиция матрицы криптодоходностей."""
def __init__(self, n_components: int = 5): self.n_components = n_components self.pca = PCA(n_components=n_components) self.scaler = StandardScaler()
def fit(self, returns: pd.DataFrame): scaled = self.scaler.fit_transform(returns) self.pca.fit(scaled) self.loadings = pd.DataFrame( self.pca.components_.T, index=returns.columns, columns=[f"PC{i+1}" for i in range(self.n_components)] ) self.explained_variance = self.pca.explained_variance_ratio_ return self
def get_eigenportfolios(self) -> pd.DataFrame: """Вернуть веса собственных портфелей (нормализованные собственные векторы).""" weights = self.loadings.copy() weights = weights.div(weights.abs().sum(axis=0), axis=1) return weights
def transform(self, returns: pd.DataFrame) -> pd.DataFrame: scaled = self.scaler.transform(returns) factors = self.pca.transform(scaled) return pd.DataFrame( factors, index=returns.index, columns=[f"PC{i+1}" for i in range(self.n_components)] )
class CryptoICA: """ICA для разделения независимых криптосигналов."""
def __init__(self, n_components: int = 5): self.n_components = n_components self.ica = FastICA(n_components=n_components, random_state=42) self.scaler = StandardScaler()
def fit_transform(self, returns: pd.DataFrame) -> pd.DataFrame: scaled = self.scaler.fit_transform(returns) sources = self.ica.fit_transform(scaled) return pd.DataFrame( sources, index=returns.index, columns=[f"IC{i+1}" for i in range(self.n_components)] )
def get_mixing_matrix(self, columns: list[str]) -> pd.DataFrame: return pd.DataFrame( self.ica.mixing_, index=columns, columns=[f"IC{i+1}" for i in range(self.n_components)] )
class TokenClusterer: """Кластеризация токенов по поведению доходности."""
def __init__(self, method: str = "kmeans", **kwargs): self.method = method if method == "kmeans": self.model = KMeans(n_clusters=kwargs.get("k", 5), random_state=42) elif method == "dbscan": self.model = DBSCAN( eps=kwargs.get("eps", 0.5), min_samples=kwargs.get("min_samples", 3) ) elif method == "hierarchical": self.model = AgglomerativeClustering( n_clusters=kwargs.get("k", 5), linkage=kwargs.get("linkage", "ward") )
def fit_predict(self, returns: pd.DataFrame) -> pd.Series: features = returns.T.values # Каждый токен — строка labels = self.model.fit_predict(features) return pd.Series(labels, index=returns.columns, name="cluster")
def evaluate(self, returns: pd.DataFrame, labels: pd.Series) -> float: features = returns.T.values return silhouette_score(features, labels)
class RegimeDetector: """Определение рыночных режимов с помощью моделей гауссовых смесей."""
def __init__(self, n_regimes: int = 3): self.n_regimes = n_regimes self.gmm = GaussianMixture( n_components=n_regimes, covariance_type="full", random_state=42 )
def fit(self, factor_returns: pd.DataFrame): self.gmm.fit(factor_returns.values) return self
def predict(self, factor_returns: pd.DataFrame) -> pd.Series: labels = self.gmm.predict(factor_returns.values) return pd.Series(labels, index=factor_returns.index, name="regime")
def predict_proba(self, factor_returns: pd.DataFrame) -> pd.DataFrame: probs = self.gmm.predict_proba(factor_returns.values) return pd.DataFrame( probs, index=factor_returns.index, columns=[f"regime_{i}" for i in range(self.n_regimes)] )
class ManifoldVisualizer: """Визуализация вселенной токенов с помощью t-SNE и UMAP."""
@staticmethod def tsne(returns: pd.DataFrame, perplexity: int = 15) -> pd.DataFrame: features = returns.T.values embedding = TSNE( n_components=2, perplexity=perplexity, random_state=42 ).fit_transform(features) return pd.DataFrame( embedding, index=returns.columns, columns=["dim1", "dim2"] )
@staticmethod def umap_embed(returns: pd.DataFrame, n_neighbors: int = 15, min_dist: float = 0.1) -> pd.DataFrame: features = returns.T.values reducer = umap.UMAP( n_neighbors=n_neighbors, min_dist=min_dist, random_state=42 ) embedding = reducer.fit_transform(features) return pd.DataFrame( embedding, index=returns.columns, columns=["dim1", "dim2"] )
class HierarchicalRiskParity: """Построение портфеля методом иерархического паритета рисков."""
def __init__(self, linkage_method: str = "single"): self.linkage_method = linkage_method
def fit(self, returns: pd.DataFrame) -> pd.Series: cov = returns.cov() corr = returns.corr() dist = np.sqrt(0.5 * (1 - corr)) dist_condensed = squareform(dist.values, checks=False) link = linkage(dist_condensed, method=self.linkage_method) sort_ix = self._quasi_diagonalize(link, len(returns.columns)) sorted_cols = [returns.columns[i] for i in sort_ix] weights = self._recursive_bisection(cov, sorted_cols) return weights
def _quasi_diagonalize(self, link, n): tree = to_tree(link) return self._get_leaves(tree)
def _get_leaves(self, node): if node.is_leaf(): return [node.id] return self._get_leaves(node.get_left()) + self._get_leaves(node.get_right())
def _recursive_bisection(self, cov, sorted_cols): weights = pd.Series(1.0, index=sorted_cols) clusters = [sorted_cols] 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_var(cov, left) right_var = self._cluster_var(cov, right) alpha = 1 - left_var / (left_var + right_var) weights[left] *= alpha weights[right] *= (1 - alpha) new_clusters.extend([left, right]) clusters = new_clusters return weights / weights.sum()
@staticmethod def _cluster_var(cov, assets): sub_cov = cov.loc[assets, assets] inv_var = 1.0 / np.diag(sub_cov) inv_var /= inv_var.sum() return float(np.dot(inv_var, np.dot(sub_cov.values, inv_var)))
# --- Пример использования ---if __name__ == "__main__": fetcher = CryptoDataFetcher() symbols = [ "BTCUSDT", "ETHUSDT", "SOLUSDT", "AVAXUSDT", "NEARUSDT", "AAVEUSDT", "UNIUSDT", "COMPUSDT", "DOGEUSDT", "SHIBUSDT", "LINKUSDT", "DOTUSDT", "MATICUSDT", "ATOMUSDT", "APTUSDT" ] returns = fetcher.build_return_matrix(symbols) print(f"Матрица доходностей: {returns.shape}")
# PCA pca = CryptoPCA(n_components=5) pca.fit(returns) print(f"Объяснённая дисперсия: {pca.explained_variance}") eigenportfolios = pca.get_eigenportfolios() print(f"Топ нагрузки PC1:\n{eigenportfolios['PC1'].sort_values()}")
# Кластеризация clusterer = TokenClusterer(method="kmeans", k=4) labels = clusterer.fit_predict(returns) score = clusterer.evaluate(returns, labels) print(f"Назначения кластеров:\n{labels}") print(f"Коэффициент силуэта: {score:.3f}")
# Определение режимов factors = pca.transform(returns) regime = RegimeDetector(n_regimes=3) regime.fit(factors) regimes = regime.predict(factors) print(f"Распределение режимов:\n{regimes.value_counts()}")
# HRP hrp = HierarchicalRiskParity() weights = hrp.fit(returns) print(f"Веса HRP:\n{weights.sort_values(ascending=False)}")Раздел 6: Реализация на Rust
use anyhow::Result;use nalgebra::{DMatrix, DVector, SymmetricEigen};use reqwest::Client;use serde::Deserialize;use std::collections::HashMap;
// --- Типы Bybit API ---
#[derive(Deserialize)]struct BybitResponse { result: BybitResult,}
#[derive(Deserialize)]struct BybitResult { list: Vec<Vec<String>>,}
// --- Загрузчик данных ---
pub struct CryptoDataFetcher { client: Client, base_url: String,}
impl CryptoDataFetcher { pub fn new() -> Self { Self { client: Client::new(), base_url: "https://api.bybit.com".to_string(), } }
pub async fn fetch_klines( &self, symbol: &str, interval: &str, limit: u32, ) -> Result<Vec<f64>> { let url = format!( "{}/v5/market/kline?category=spot&symbol={}&interval={}&limit={}", self.base_url, symbol, interval, limit ); let resp: BybitResponse = self.client.get(&url).send().await?.json().await?; let mut closes: Vec<f64> = resp .result .list .iter() .map(|row| row[4].parse::<f64>().unwrap()) .collect(); closes.reverse(); // хронологический порядок let returns: Vec<f64> = closes .windows(2) .map(|w| (w[1] / w[0]).ln()) .collect(); Ok(returns) }
pub async fn build_return_matrix( &self, symbols: &[&str], interval: &str, limit: u32, ) -> Result<(Vec<String>, DMatrix<f64>)> { let mut all_returns: Vec<Vec<f64>> = Vec::new(); let mut valid_symbols: Vec<String> = Vec::new();
for symbol in symbols { match self.fetch_klines(symbol, interval, limit).await { Ok(ret) => { valid_symbols.push(symbol.to_string()); all_returns.push(ret); } Err(e) => eprintln!("Пропускаем {}: {}", symbol, e), } }
let min_len = all_returns.iter().map(|r| r.len()).min().unwrap_or(0); let n_assets = valid_symbols.len(); let mut matrix = DMatrix::zeros(min_len, n_assets); for (j, ret) in all_returns.iter().enumerate() { for i in 0..min_len { matrix[(i, j)] = ret[ret.len() - min_len + i]; } } Ok((valid_symbols, matrix)) }}
// --- PCA ---
pub struct PcaDecomposition { pub eigenvalues: DVector<f64>, pub eigenvectors: DMatrix<f64>, pub explained_variance_ratio: Vec<f64>, pub n_components: usize,}
impl PcaDecomposition { pub fn fit(returns: &DMatrix<f64>, n_components: usize) -> Self { let n = returns.nrows() as f64; let mean = returns.column_mean(); let mut centered = returns.clone(); for mut row in centered.row_iter_mut() { row -= &mean.transpose(); } let cov = (¢ered.transpose() * ¢ered) / n; let eigen = SymmetricEigen::new(cov); let total_var: f64 = eigen.eigenvalues.iter().sum(); let evr: Vec<f64> = eigen .eigenvalues .iter() .rev() .take(n_components) .map(|v| v / total_var) .collect();
Self { eigenvalues: eigen.eigenvalues.clone(), eigenvectors: eigen.eigenvectors.clone(), explained_variance_ratio: evr, n_components, } }
pub fn get_loadings(&self) -> DMatrix<f64> { let n = self.eigenvectors.ncols(); let start = if n > self.n_components { n - self.n_components } else { 0 }; self.eigenvectors.columns(start, self.n_components).into() }
pub fn transform(&self, returns: &DMatrix<f64>) -> DMatrix<f64> { let mean = returns.column_mean(); let mut centered = returns.clone(); for mut row in centered.row_iter_mut() { row -= &mean.transpose(); } let loadings = self.get_loadings(); ¢ered * &loadings }}
// --- K-Means ---
pub struct KMeans { pub k: usize, pub max_iters: usize, pub centroids: Option<DMatrix<f64>>,}
impl KMeans { pub fn new(k: usize, max_iters: usize) -> Self { Self { k, max_iters, centroids: None } }
pub fn fit_predict(&mut self, data: &DMatrix<f64>) -> Vec<usize> { let n = data.nrows(); let d = data.ncols(); let mut centroids = DMatrix::zeros(self.k, d); for i in 0..self.k { centroids.set_row(i, &data.row(i * n / self.k)); } let mut labels = vec![0usize; n]; for _ in 0..self.max_iters { // Назначение for i in 0..n { let row = data.row(i); let mut best_dist = f64::MAX; for c in 0..self.k { let diff = &row - ¢roids.row(c); let dist = diff.dot(&diff); if dist < best_dist { best_dist = dist; labels[i] = c; } } } // Обновление let mut new_centroids = DMatrix::zeros(self.k, d); let mut counts = vec![0usize; self.k]; for i in 0..n { let c = labels[i]; new_centroids.set_row(c, &(&new_centroids.row(c) + &data.row(i))); counts[c] += 1; } for c in 0..self.k { if counts[c] > 0 { new_centroids.set_row(c, &(&new_centroids.row(c) / counts[c] as f64)); } } centroids = new_centroids; } self.centroids = Some(centroids); labels }}
// --- DBSCAN ---
pub struct Dbscan { pub eps: f64, pub min_samples: usize,}
impl Dbscan { pub fn new(eps: f64, min_samples: usize) -> Self { Self { eps, min_samples } }
pub fn fit_predict(&self, data: &DMatrix<f64>) -> Vec<i32> { let n = data.nrows(); let mut labels = vec![-1i32; n]; // -1 = шум let mut cluster_id = 0i32;
for i in 0..n { if labels[i] != -1 { continue; } let neighbors = self.region_query(data, i); if neighbors.len() < self.min_samples { continue; } labels[i] = cluster_id; let mut queue = neighbors.clone(); let mut qi = 0; while qi < queue.len() { let j = queue[qi]; if labels[j] == -1 { labels[j] = cluster_id; let j_neighbors = self.region_query(data, j); if j_neighbors.len() >= self.min_samples { for &nb in &j_neighbors { if !queue.contains(&nb) { queue.push(nb); } } } } qi += 1; } cluster_id += 1; } labels }
fn region_query(&self, data: &DMatrix<f64>, point: usize) -> Vec<usize> { let row = data.row(point); (0..data.nrows()) .filter(|&j| { let diff = &row - &data.row(j); diff.dot(&diff).sqrt() <= self.eps }) .collect() }}
// --- HRP ---
pub struct HierarchicalRiskParity;
impl HierarchicalRiskParity { pub fn compute_weights(returns: &DMatrix<f64>) -> Vec<f64> { let n = returns.ncols(); let cov = Self::covariance(returns); let corr = Self::correlation(returns); let dist = Self::correlation_distance(&corr); let order = Self::seriation(&dist); Self::recursive_bisection(&cov, &order) }
fn covariance(data: &DMatrix<f64>) -> DMatrix<f64> { let n = data.nrows() as f64; let mean = data.column_mean(); let mut centered = data.clone(); for mut row in centered.row_iter_mut() { row -= &mean.transpose(); } (¢ered.transpose() * ¢ered) / (n - 1.0) }
fn correlation(data: &DMatrix<f64>) -> DMatrix<f64> { let cov = Self::covariance(data); let n = cov.nrows(); let mut corr = DMatrix::zeros(n, n); for i in 0..n { for j in 0..n { corr[(i, j)] = cov[(i, j)] / (cov[(i, i)] * cov[(j, j)]).sqrt(); } } corr }
fn correlation_distance(corr: &DMatrix<f64>) -> DMatrix<f64> { corr.map(|r| (0.5 * (1.0 - r)).sqrt()) }
fn seriation(dist: &DMatrix<f64>) -> Vec<usize> { let n = dist.nrows(); let mut order = vec![0usize]; let mut remaining: Vec<usize> = (1..n).collect(); while !remaining.is_empty() { let last = *order.last().unwrap(); let nearest = remaining .iter() .copied() .min_by(|&a, &b| dist[(last, a)].partial_cmp(&dist[(last, b)]).unwrap()) .unwrap(); order.push(nearest); remaining.retain(|&x| x != nearest); } order }
fn recursive_bisection(cov: &DMatrix<f64>, order: &[usize]) -> Vec<f64> { let n = cov.nrows(); let mut weights = vec![1.0f64; n]; let mut clusters: Vec<Vec<usize>> = vec![order.to_vec()]; while !clusters.is_empty() { let mut next = Vec::new(); for cluster in &clusters { if cluster.len() <= 1 { continue; } let mid = cluster.len() / 2; let left = &cluster[..mid]; let right = &cluster[mid..]; let left_var = Self::cluster_variance(cov, left); let right_var = Self::cluster_variance(cov, right); let alpha = 1.0 - left_var / (left_var + right_var); for &i in left { weights[i] *= alpha; } for &i in right { weights[i] *= 1.0 - alpha; } next.push(left.to_vec()); next.push(right.to_vec()); } clusters = next; } let total: f64 = weights.iter().sum(); weights.iter().map(|w| w / total).collect() }
fn cluster_variance(cov: &DMatrix<f64>, assets: &[usize]) -> f64 { let inv_var: Vec<f64> = assets.iter().map(|&i| 1.0 / cov[(i, i)]).collect(); let sum: f64 = inv_var.iter().sum(); let norm: Vec<f64> = inv_var.iter().map(|v| v / sum).collect(); let mut var = 0.0; for (ai, &a) in assets.iter().enumerate() { for (bi, &b) in assets.iter().enumerate() { var += norm[ai] * norm[bi] * cov[(a, b)]; } } var }}
// --- Главный пример ---
#[tokio::main]async fn main() -> Result<()> { let fetcher = CryptoDataFetcher::new(); let symbols = &[ "BTCUSDT", "ETHUSDT", "SOLUSDT", "AVAXUSDT", "NEARUSDT", "AAVEUSDT", "UNIUSDT", "DOGEUSDT", "SHIBUSDT", "LINKUSDT", ];
let (names, returns) = fetcher.build_return_matrix(symbols, "D", 200).await?; println!("Матрица доходностей: {}x{}", returns.nrows(), returns.ncols());
// PCA let pca = PcaDecomposition::fit(&returns, 3); println!("Объяснённая дисперсия: {:?}", pca.explained_variance_ratio);
// K-Means let transposed = returns.transpose(); let mut kmeans = KMeans::new(3, 100); let labels = kmeans.fit_predict(&transposed); for (name, label) in names.iter().zip(labels.iter()) { println!("{}: кластер {}", name, label); }
// HRP let weights = HierarchicalRiskParity::compute_weights(&returns); for (name, w) in names.iter().zip(weights.iter()) { println!("{}: {:.4}", name, w); }
Ok(())}Структура проекта
ch13_unsupervised_crypto_structure/├── Cargo.toml├── src/│ ├── lib.rs│ ├── decomposition/│ │ ├── mod.rs│ │ ├── pca.rs│ │ └── ica.rs│ ├── clustering/│ │ ├── mod.rs│ │ ├── kmeans.rs│ │ └── dbscan.rs│ └── portfolio/│ ├── mod.rs│ └── hrp.rs└── examples/ ├── crypto_pca.rs ├── token_clustering.rs └── eigenportfolios.rsРаздел 7: Практические примеры
Пример 1: Факторная декомпозиция криптовалют
Мы запускаем PCA на вселенной из 30 токенов за 12 месяцев дневных доходностей с Bybit. Результаты выявляют чёткую факторную структуру:
Компонента Объяснённая дисперсия ИнтерпретацияPC1 62.3% Доминирование BTC (рыночная бета)PC2 8.7% Фактор DeFi (AAVE, UNI, COMP)PC3 5.1% Фактор L1 (SOL, AVAX, NEAR)PC4 3.4% Мем-фактор (DOGE, SHIB, PEPE)PC5 2.8% Фактор биржевых токенов (BNB, OKB)Кумулятивно 82.3%Первый собственный портфель (PC1) по сути является индексом, взвешенным по рыночной капитализации. PC2 имеет положительные нагрузки на DeFi blue chips и отрицательные на BTC, создавая естественную спред-сделку DeFi-vs-BTC. Во время ренессанса DeFi 2024 года длинная позиция по PC2 принесла +34% при рыночной нейтральности.
Пример 2: Визуализация вселенной токенов с UMAP
Мы применяем UMAP к транспонированной матрице доходностей (каждый токен — точка в T-мерном пространстве доходностей) с n_neighbors=20, min_dist=0.1. Двумерное вложение выявляет чёткие кластеры:
Кластер (визуальный) ТокеныDeFi Blue Chips AAVE, UNI, COMP, MKR, SNXПлатформы Layer-1 SOL, AVAX, NEAR, APT, SUIМемы / Спекулятивные DOGE, SHIB, PEPE, FLOKI, BONKЭкосистема BTC BTC, WBTC, STXЭкосистема ETH ETH, LDO, RPL, SSVИнфраструктура LINK, GRT, FIL, ARВыбросы / Без кластера HNT, RNDR (нарратив AI/DePIN)Токены AI/DePIN (RNDR, FET, AGIX) сформировали формирующийся подкластер в конце 2024 года, обнаруживаемый за 2-3 месяца до того, как нарратив стал консенсусом — предоставляя ранний сигнал для нарративного распределения.
Пример 3: Определение режимов с GMM
Мы подгоняем GMM с 3 компонентами к первым 3 PCA-факторам, вычисленным на скользящем 90-дневном окне криптодоходностей:
Режим Среднее PC1 Вол PC1 Частота Интерпретация0 +0.08% 1.2% 41% Risk-on альтсезон1 -0.15% 2.8% 28% Коррелированная просадка2 +0.03% 0.7% 31% Низковолатильное доминирование BTC
Матрица переходов: К R0 К R1 К R2Из R0 0.72 0.12 0.16Из R1 0.15 0.65 0.20Из R2 0.22 0.10 0.68Режимы «липкие» (диагональ > 0.65), но переходы в просадку (R1) кластеризуются вокруг макроэкономических рисковых событий (заседания ФРС, регуляторные действия). Стратегия, снижающая плечо на 50% при входе в R1, улучшила коэффициент Шарпа с 1.2 до 1.8 на бэктестах.
Раздел 8: Фреймворк бэктестирования
Компоненты
- Конвейер данных: Bybit API для крипто OHLCV, yfinance для бенчмарков (SPY, GLD для контекста корреляций).
- Генератор сигналов: Факторные экспозиции PCA, назначения кластеров, вероятности режимов, веса HRP.
- Конструктор портфеля: Веса HRP ребалансируются ежемесячно; факторно-нейтральные оверлеи ребалансируются еженедельно.
- Симулятор исполнения: Проскальзывание 10 бп, комиссия 5 бп на сторону, предполагаются рыночные ордера.
- Риск-менеджер: Макс. просадка 15%, макс. вес токена 20%, макс. вес сектора (кластера) 40%.
Метрики
| Метрика | Описание |
|---|---|
| CAGR | Среднегодовой темп роста |
| Коэффициент Шарпа | Доходность с поправкой на риск (годовая) |
| Коэффициент Сортино | Доходность с поправкой на риск снижения |
| Макс. просадка | Наибольшее падение от пика до дна |
| Коэффициент Калмара | CAGR / Макс. просадка |
| Оборот | Ежемесячный оборот портфеля |
| Стабильность кластеров | Сходство Жаккара назначений кластеров месяц-к-месяцу |
Примерные результаты бэктеста
Стратегия CAGR Шарп Макс DD ОборотРавные веса (базовая) 18.2% 0.61 -52.3% 5.2%HRP 23.7% 0.89 -38.1% 8.4%HRP + Фильтр режимов 28.4% 1.21 -26.7% 12.1%Спред собственных портф. (PC2) 15.6% 1.45 -18.2% 22.3%Факторно-нейтр. + Моментум 31.2% 1.38 -22.4% 28.6%
Период: 2022-01-01 — 2024-12-31Вселенная: Топ-30 токенов по рыночной капитализацииРебалансировка: Ежемесячно (HRP), Еженедельно (факторно-нейтральная)Раздел 9: Оценка производительности
Сравнение методов
| Критерий | PCA | k-Means | DBSCAN | GMM | HRP |
|---|---|---|---|---|---|
| Сложность настройки | Низкая | Низкая | Средняя | Средняя | Средняя |
| Вычислительная стоимость | Низкая | Низкая | Средняя | Средняя | Низкая |
| Интерпретируемость | Высокая | Высокая | Средняя | Средняя | Высокая |
| Стабильность во времени | Средняя | Низкая | Низкая | Средняя | Высокая |
| Учёт нестационарности | Нет | Нет | Частично | Частично | Частично |
| Применимость к портфелю | Да (eigenportfolios) | Косвенно | Косвенно | Косвенно | Напрямую |
Ключевые выводы
- PCA незаменим: Первые 5 компонент объясняют 80%+ поперечной дисперсии криптодоходностей. Любая систематическая стратегия должна учитывать эти скрытые факторы.
- HRP превосходит равные веса и среднее-дисперсию: Во вселенной 30+ токенов, где ковариационная матрица плохо обусловлена, HRP обеспечивает лучшую доходность с поправкой на риск при меньших просадках.
- Определение режимов добавляет ценность: Обусловливание на режимы, обнаруженные GMM, снижает просадки на 30-40% при умеренном влиянии на доходность.
- Назначения кластеров нестабильны: Метки k-Means существенно меняются от месяца к месяцу (Жаккар < 0.5). Используйте мягкие назначения (GMM) или скользящий консенсус для продакшн-систем.
- UMAP обеспечивает раннее обнаружение нарративов: Формирующиеся кластеры в пространстве UMAP опережают формирование нарратива в Twitter/медиа на 4-8 недель.
Ограничения
- PCA предполагает линейные связи; криптодоходности демонстрируют сильные нелинейные зависимости (совместное движение хвостов).
- Результаты кластеризации чувствительны к выбору окна доходностей (30д vs 90д vs 365д).
- Метки режимов GMM требуют ручной интерпретации; один и тот же статистический режим может иметь разное рыночное значение в разные периоды.
- HRP предполагает, что квази-диагональная структура отражает осмысленные экономические группировки, что не всегда выполняется.
- Все методы деградируют во время событий «чёрного лебедя», когда исторические корреляции разрушаются.
Раздел 10: Перспективные направления
-
Теория случайных матриц (RMT) для фильтрации собственных значений: Использование распределения Марченко-Пастура для отделения сигнальных собственных значений от шума, улучшая факторное извлечение на основе PCA в зашумлённой матрице криптодоходностей.
-
Факторные модели с изменяющимися во времени параметрами: Замена статического PCA на динамические факторные модели (DFM) или скользящий PCA для отслеживания быстрой эволюции факторной структуры крипторынка (например, появление и затухание фактора AI-нарратива).
-
Глубокая кластеризация для криптотокенов: Применение вариационных автоэнкодеров (VAE) или глубокой кластеризации вложений (DEC) для объединения обучения представлений с назначением кластеров, захватывая нелинейные связи между токенами.
-
Графовая кластеризация на основе ончейн-данных: Включение графов блокчейн-транзакций для кластеризации токенов по паттернам ончейн-взаимодействий (пулы ликвидности DEX, потоки мостов, совместные холдинги кошельков) вместо одной лишь корреляции доходностей.
-
Мультимодальное определение режимов: Комбинирование признаков GMM на основе доходностей с ончейн-метриками (TVL, активные адреса), ставками финансирования и подразумеваемой волатильностью опционов для более робастной классификации режимов.
-
Квантово-вдохновлённое снижение размерности: Исследование методов тензорных сетей и квантового PCA для сверхвысокоразмерных сценариев (например, тиковые данные для 500+ токенов), которые могут обеспечить экспоненциальное ускорение по сравнению с классическим PCA.
Ссылки
-
Jolliffe, I. T. (2002). Principal Component Analysis. Springer Series in Statistics, 2nd Edition.
-
Hyvärinen, A., & Oja, E. (2000). Independent Component Analysis: Algorithms and Applications. Neural Networks, 13(4-5), 411-430.
-
López de Prado, M. (2016). Building Diversified Portfolios that Outperform Out of Sample. Journal of Portfolio Management, 42(4), 59-69.
-
McInnes, L., Healy, J., & Melville, J. (2018). UMAP: Uniform Manifold Approximation and Projection for Dimension Reduction. arXiv preprint arXiv:1802.03426.
-
van der Maaten, L., & Hinton, G. (2008). Visualizing Data using t-SNE. Journal of Machine Learning Research, 9, 2579-2605.
-
Ester, M., Kriegel, H. P., Sander, J., & Xu, X. (1996). A Density-Based Algorithm for Discovering Clusters in Large Spatial Databases with Noise. KDD-96 Proceedings, 226-231.
-
Laloux, L., Cizeau, P., Bouchaud, J. P., & Potters, M. (1999). Noise Dressing of Financial Correlation Matrices. Physical Review Letters, 83(7), 1467-1470.