Робастная оптимизация портфеля: методы улучшения диверсификации

В мире инвестиций неопределенность — это, пожалуй, единственная константа. За годы работы с количественными моделями и алгоритмами я не раз убеждался, что классические подходы к формированию инвестиционных портфелей часто разбиваются о реальность рынка.

Причина проста: равное разделение активов снижает доходность, но не снижает риски, в то время как классические методы оптимизации, такие как модель Марковица, крайне чувствительны к ошибкам в оценках входных параметров. Незначительные изменения в ожидаемой доходности активов могут радикально изменить структуру «оптимального» портфеля, превращая научно обоснованную стратегию в азартную игру.

Именно поэтому робастная оптимизация портфеля становится не просто академическим интересом, а необходимым инструментом для профессиональных управляющих активами и алгоритмических трейдеров. В этой статье я подробно разберу концепцию робастности применительно к портфельным инвестициям, покажу математические основы современных подходов и продемонстрирую их реализацию на Python с использованием актуальных данных.

Проблема классической оптимизации портфеля

Традиционная теория портфельной оптимизации, предложенная Гарри Марковицем, опирается на два ключевых параметра: ожидаемую доходность и ковариационную матрицу активов. На бумаге все выглядит стройно и элегантно — находим веса активов, максимизирующие соотношение доходности к риску. Тем не менее, при применении этого подхода на практике возникает целый ряд проблем.

Высокая чувствительность к входным данным

Основная проблема классической оптимизации по Марковицу заключается в ее экстремальной чувствительности к оценкам ожидаемой доходности и ковариационной матрицы. Исследования показывают, что даже небольшие ошибки в оценке параметров могут привести к существенным отклонениям в распределении весов портфеля. Это особенно заметно при оптимизации портфелей с большим количеством активов.

Приведу пример: допустим, мы оцениваем ожидаемую годовую доходность акций компании А как 8%, а в реальности она составляет 7.5%. Казалось бы, ошибка невелика, но при оптимизации портфеля из 50 активов это может привести к тому, что вес этого актива в оптимальном портфеле будет завышен на 20-30%, а веса других активов, соответственно, занижены. Умножьте эту проблему на все активы в портфеле, и вы получите решение, которое на практике может быть далеко от оптимального.

Бывает так, что теория Марковица рекомендует экстремальные веса для отдельных активов: 0% для большинства инструментов и непропорционально высокие веса для нескольких «фаворитов». Такая экстремальная концентрация противоречит базовому принципу диверсификации и часто является результатом ошибок в оценке параметров, а не отражением реальных инвестиционных возможностей.

Проблема оценки ожидаемой доходности

Оценка ожидаемой доходности активов — это, пожалуй, самый слабый элемент в классическом подходе. Простое использование исторической средней доходности приводит к известной проблеме «шумного максимума» (noisy maximizer) — оптимизатор присваивает наибольшие веса активам с наивысшей исторической доходностью, игнорируя тот факт, что эта доходность часто является результатом случайного шума, а не устойчивого преимущества.

Даже использование более сложных методов прогнозирования доходности, таких как CAPM или многофакторные модели, не решает проблему полностью. Ошибки в оценках сохраняются, и оптимизатор продолжает «максимизировать ошибки», как метко заметил Ричард Мичо.

Нестабильность ковариационной матрицы

Второй важный параметр — ковариационная матрица активов — также подвержен проблемам при оценке. Для портфеля из N активов необходимо оценить N(N+1)/2 элементов матрицы. Для портфеля из 100 активов это уже 5050 параметров! Очевидно, что точно оценить такое количество параметров на исторических данных практически невозможно.

Более того, структура корреляций между активами не является статичной — она меняется со временем, особенно в периоды рыночных стрессов. Корреляции, оцененные в «спокойные» периоды, могут радикально измениться во время кризиса, когда диверсификация нужна больше всего. Это явление известно как «корреляционный пробой» (correlation breakdown).

Я наблюдал подобные эффекты во время рыночных турбулентностей 2008, 2020 и 2022 годов. Активы, которые исторически показывали низкую корреляцию, вдруг начинали двигаться синхронно, нивелируя эффект диверсификации в самый неподходящий момент.

Что такое робастная оптимизация портфеля?

Робастная оптимизация портфеля — это подход, разработанный для преодоления недостатков классической теории. Вместо того чтобы принимать точечные оценки параметров как данность, робастный подход явно учитывает неопределенность в этих оценках и стремится создать портфель, устойчивый к ошибкам прогнозирования.

Концепция неопределенности в оценках параметров

В центре робастной оптимизации лежит идея о том, что мы не можем точно знать истинные значения ожидаемой доходности и ковариации активов. Вместо использования точечных оценок мы работаем с множествами неопределенности (uncertainty sets) — наборами возможных значений параметров.

Например, вместо утверждения «ожидаемая доходность актива А составляет 8%» мы говорим «ожидаемая доходность актива А находится в диапазоне от 6% до 10%». Аналогичным образом, мы определяем диапазоны возможных значений для всех элементов ковариационной матрицы.

Робастная оптимизация затем ищет портфель, который максимизирует наихудший возможный результат в рамках этих множеств неопределенности. Другими словами, мы оптимизируем для наиболее пессимистичного сценария из допустимого диапазона параметров. Этот принцип известен как «max-min» оптимизация.

Типы робастных подходов

В области робастной оптимизации портфеля выделяются несколько основных подходов:

  • Байесовская робастная оптимизация — использует байесовские методы для учета неопределенности в оценках параметров, интегрируя по всему диапазону возможных значений с учетом их вероятностных распределений;
  • Оптимизация с ограничениями на множествах неопределенности — определяет множества возможных значений параметров (обычно в виде эллипсоидов или многогранников) и находит решение, оптимальное для наихудшего случая;
  • Shrinkage-оценки — использует методы регуляризации для «сжатия» экстремальных оценок к более консервативным значениям, уменьшая влияние выбросов и шума в данных;
  • Ресэмплинг и имитационные методы — генерирует множество возможных сценариев на основе исторических данных и ищет портфель, робастный к этим сценариям.

Наиболее эффективны комбинированные подходы, сочетающие элементы разных методов. Нет универсального решения — выбор конкретного метода зависит от характеристик инвестиционной вселенной, горизонта инвестирования и специфических требований к портфелю.

👉🏻  Расчет показателей доходности и риска биржевой торговли на Python

Математические основы робастной оптимизации

Для глубокого понимания робастной оптимизации портфеля необходимо разобраться в ее математических основах. В этом разделе я рассмотрю формализацию проблемы и основные математические подходы к ее решению.

Формула классической задачи оптимизации портфеля

Классическая задача оптимизации портфеля по Марковицу формулируется следующим образом:

Максимизировать:

w^T * μ − λ * w^T * Σ * w

при ограничениях:

  • w^T * 1 = 1 (сумма весов равна 1);
  • w ≥ 0 (если разрешены только длинные позиции).

где:

  • w — вектор весов активов в портфеле;
  • μ — вектор ожидаемых доходностей;
  • Σ — ковариационная матрица доходностей;
  • λ — параметр неприятия риска.

Формула робастной задачи оптимизации

В робастной оптимизации мы модифицируем задачу, явно учитывая неопределенность в параметрах. Один из распространенных подходов — определить множества неопределенности U_mu и U_Sigma для ожидаемых доходностей и ковариационной матрицы соответственно.

Тогда робастная задача формулируется как:

Максимизировать:

min по μ из U_mu и по Σ из U_Sigma функции { w^T * μ − λ * w^T * Σ * w }

при тех же ограничениях на веса:

  • w^T * 1 = 1 (сумма весов равна 1);
  • w ≥ 0 (если разрешены только длинные позиции).

где:

  • w — вектор весов активов в портфеле;
  • μ — вектор ожидаемых доходностей;
  • Σ — ковариационная матрица доходностей;
  • λ — параметр неприятия риска;
  • U_mu — множество неопределенности для μ;
  • U_Sigma — множество неопределенности для Σ.

Эта формула соответствует принципу max-min: мы ищем такой портфель, который максимизирует функцию полезности в наихудшем случае из допустимого множества параметров.

Определение множеств неопределенности

Ключевой вопрос робастной оптимизации — как определить множества неопределенности U_mu и U_Sigma? Вот основные подходы:

1. Эллипсоидальные множества

Определяются как:

U_mu = { μ : (μ − μ^)’ * Σ_mu^(-1) * (μ − μ^) ≤ κ^2 }

где:

  • μ^ — точечная оценка ожидаемых доходностей;
  • Σ_mu — матрица ковариации ошибок оценки;
  • κ (каппа) — параметр, определяющий размер множества.

Это множество включает все векторы μ, которые лежат в эллипсоиде с центром в μ^, размер которого задается параметром κ.

2. Множества-коробки (box uncertainties)

Задают индивидуальные верхние и нижние границы для каждого параметра:

U_mu = { μ : μ^ − δ ≤ μ ≤ μ^ + δ }

где δ — вектор допустимых отклонений для каждого элемента μ. То есть каждое значение μ располагается в интервале вокруг своей оценки μ^.

3. Многогранные множества

Более общий случай, где множество неопределенности описывается как многогранник в пространстве параметров (например, с помощью системы линейных неравенств).

Выбор формы множества неопределенности влияет на сложность решения задачи и трактовку результатов. Эллипсоидальные множества часто используются на практике, так как отражают статистическую природу ошибок оценивания и приводят к задачам, которые эффективно решаются численно.

Методы решения робастной задачи

В зависимости от выбранной формы множеств неопределенности, робастная задача оптимизации может быть преобразована в задачу выпуклой оптимизации, которую можно решить стандартными методами.

Например, для эллипсоидального множества неопределенности U_μ робастная задача может быть переписана как:

Максимизировать при стандартных ограничениях на веса:

w^T μ̂ − κ * sqrt(w^T Σ_μ w) − λ * w^T Σ w

где:

  • w — вектор весов активов;
  • μ̂ — оценка вектора ожидаемых доходностей;
  • Σ_μ — ковариационная матрица неопределенности в оценках доходностей;
  • Σ — ковариационная матрица доходностей;
  • κ — параметр неопределенности (уровень консерватизма);
  • λ — коэффициент регуляризации (риск-премии);
  • sqrt(·) — операция извлечения квадратного корня;
  • ^T — операция транспонирования.

Заметим, что дополнительный член κ * sqrt(w^T Σ_μ w) представляет собой «штраф» за неопределенность, пропорциональный волатильности оценок ожидаемых доходностей. Это приводит к более консервативной аллокации, особенно для активов с высокой неопределенностью в оценках.

Аналогичным образом можно переформулировать задачу для неопределенности в ковариационной матрице Σ, хотя математические выкладки становятся более сложными.

Практическая реализация: робастная оптимизация на Python

Теперь перейдем от теории к практике и реализуем робастную оптимизацию портфеля на Python. Я покажу, как использовать различные подходы к робастной оптимизации и сравню их результаты с классическим подходом Марковица.

Подготовка данных и настройка окружения

Начнем с импорта необходимых библиотек и загрузки данных для нашего анализа:

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import yfinance as yf
import cvxpy as cp
from scipy import stats
from sklearn.model_selection import KFold
import seaborn as sns
from datetime import datetime, timedelta

# Настройка стиля графиков
plt.style.use('seaborn-v0_8-whitegrid')
sns.set_palette("dark")

# Задаем параметры
end_date = datetime.now()
start_date = end_date - timedelta(days=3*365)  # 3 года данных
tickers = ['GS', 'JPM', 'MS', 'BAC', 'C', 'WFC', 'BLK', 'BX', 'KKR', 'BK', 
           'SCHW', 'FCX', 'NEM', 'RIO', 'VALE', 'BHP', 'XOM', 'CVX', 'COP',
           'BP', 'SLB', 'HAL', 'EOG']

# Загрузка данных
data = yf.download(tickers, start=start_date, end=end_date)
prices = data['Close']

# Проверка и обработка пропущенных данных
if isinstance(prices.columns, pd.MultiIndex):
    prices.columns = prices.columns.droplevel(0)
    
prices = prices.dropna(axis=1, how='any')  # Удаляем тикеры с пропущенными данными
tickers = list(prices.columns)

# Расчет логарифмических доходностей
returns = np.log(prices / prices.shift(1)).dropna()

# Визуализация корреляционной матрицы
plt.figure(figsize=(12, 10))
sns.heatmap(returns.corr(), cmap='YlGnBu', annot=False, center=0)
plt.title('Корреляционная матрица доходностей активов')
plt.tight_layout()
plt.show()

# Расчет годовых статистик
annual_returns = returns.mean() * 252
annual_cov = returns.cov() * 252

Корреляционная матрица дневных логарифмических доходностей финансовых активов за 3 года. Более темные оттенки соответствуют более сильным положительным корреляциям. Матрица демонстрирует кластеризацию активов внутри схожих секторов (банковский, горнодобывающий, энергетический)

Рис. 1: Корреляционная матрица дневных логарифмических доходностей финансовых активов за 3 года. Более темные оттенки соответствуют более сильным положительным корреляциям. Матрица демонстрирует кластеризацию активов внутри схожих секторов (банковский, горнодобывающий, энергетический)

В этом коде я загружаю ежедневные цены закрытия для 23 тикеров из различных секторов: финансового, сырьевого и энергетического. Затем рассчитываю логарифмические доходности и годовые статистики, которые будут использоваться для оптимизации портфеля.

Визуализация корреляционной матрицы помогает понять структуру взаимосвязей между активами и оценить потенциал диверсификации.

Реализация классической оптимизации по Марковицу

Для сравнения сначала реализуем классическую оптимизацию по Марковицу:

def markowitz_optimization(returns, cov, target_return=None, risk_aversion=1):
    """
    Классическая оптимизация портфеля по Марковицу
    
    Параметры:
    - returns: вектор ожидаемых доходностей
    - cov: ковариационная матрица
    - target_return: целевая доходность портфеля (для задачи минимизации риска)
    - risk_aversion: коэффициент неприятия риска (для задачи максимизации полезности)
    
    Возвращает:
    - weights: оптимальные веса активов
    """
    n = len(returns)
    weights = cp.Variable(n)
    
    # Определение функции цели и ограничений
    constraints = [cp.sum(weights) == 1, weights >= 0]
    
    if target_return is not None:
        # Задача минимизации риска при заданной целевой доходности
        risk = cp.quad_form(weights, cov)
        objective = cp.Minimize(risk)
        constraints.append(returns @ weights >= target_return)
    else:
        # Задача максимизации полезности (доходность - риск)
        utility = returns @ weights - risk_aversion * cp.quad_form(weights, cov)
        objective = cp.Maximize(utility)
    
    # Решение задачи оптимизации
    problem = cp.Problem(objective, constraints)
    problem.solve()
    
    if problem.status != 'optimal':
        raise ValueError(f"Задача не имеет оптимального решения. Статус: {problem.status}")
    
    return weights.value

# Применение классической оптимизации
markowitz_weights = markowitz_optimization(annual_returns.values, annual_cov.values)

# Визуализация результатов
plt.figure(figsize=(12, 6))
plt.bar(tickers, markowitz_weights, color='darkgray')
plt.title('Распределение весов в портфеле (классический подход Марковица)')
plt.xlabel('Активы')
plt.ylabel('Веса')
plt.xticks(rotation=90)
plt.tight_layout()
plt.show()

# Расчет характеристик портфеля
markowitz_return = np.dot(annual_returns, markowitz_weights)
markowitz_risk = np.sqrt(np.dot(markowitz_weights, np.dot(annual_cov, markowitz_weights)))
markowitz_sharpe = markowitz_return / markowitz_risk

print(f"Ожидаемая доходность портфеля: {markowitz_return:.2%}")
print(f"Ожидаемый риск портфеля: {markowitz_risk:.2%}")
print(f"Коэффициент Шарпа: {markowitz_sharpe:.2f}")

Бар-чарт распределения весов в портфеле в соответствии с классическим подходом Марковица

Рис. 2: Бар-чарт распределения весов в портфеле в соответствии с классическим подходом Марковица

Ожидаемая доходность портфеля: 33.90%
Ожидаемый риск портфеля: 23.58%
Коэффициент Шарпа: 1.44

Функция markowitz_optimization реализует классическую оптимизацию портфеля с использованием библиотеки cvxpy для решения задачи выпуклой оптимизации. Она поддерживает два варианта задачи: минимизацию риска при заданной целевой доходности и максимизацию функции полезности (доходность минус риск, взвешенный коэффициентом неприятия риска).

👉🏻  Доверительная вероятность и уровень значимости в финансовом Data Science

После оптимизации я визуализирую полученные веса и рассчитываю ключевые характеристики портфеля: ожидаемую доходность, риск и коэффициент Шарпа.

Реализация оптимизации с эллипсоидальным множеством неопределенности

Теперь реализуем робастную оптимизацию с учетом неопределенности в оценках ожидаемой доходности:

def robust_optimization_ellipsoidal(returns, cov, returns_cov=None, kappa=1, risk_aversion=1):
"""
Робастная оптимизация портфеля с эллипсоидальным множеством неопределенности
Параметры:
- returns: вектор ожидаемых доходностей
- cov: ковариационная матрица доходностей
- returns_cov: ковариационная матрица ошибок оценки ожидаемых доходностей
- kappa: параметр, определяющий размер множества неопределенности
- risk_aversion: коэффициент неприятия риска
Возвращает:
- weights: оптимальные веса активов
"""
n = len(returns)
weights = cp.Variable(n)
# Если матрица ковариации ошибок не задана, используем диагональную матрицу
if returns_cov is None:
returns_std = np.abs(returns) / np.sqrt(252)
returns_cov = np.diag(returns_std**2)
# Используем второй порядок конуса (SOC) 
# Создаем вспомогательную переменную для штрафа за неопределенность
t = cp.Variable()
# Ограничение второго порядка конуса: ||L^T * weights|| <= t # где L - разложение Холецкого матрицы returns_cov try: L = np.linalg.cholesky(returns_cov) uncertainty_constraint = cp.SOC(t, L.T @ weights) except np.linalg.LinAlgError: # Если разложение Холецкого не удалось, используем собственные векторы eigenvals, eigenvecs = np.linalg.eigh(returns_cov) eigenvals = np.maximum(eigenvals, 1e-8) # Избегаем отрицательных собственных значений L = eigenvecs @ np.diag(np.sqrt(eigenvals)) uncertainty_constraint = cp.SOC(t, L.T @ weights) # Робастная функция полезности portfolio_risk = risk_aversion * cp.quad_form(weights, cov) objective = cp.Maximize(returns @ weights - kappa * t - portfolio_risk) constraints = [ cp.sum(weights) == 1, weights >= 0,
uncertainty_constraint
]
# Решение задачи оптимизации
problem = cp.Problem(objective, constraints)
try:
problem.solve(solver=cp.CLARABEL)  # Пробуем CLARABEL
except:
try:
problem.solve(solver=cp.SCS)  # Если не получилось, пробуем SCS
except:
problem.solve()  # Используем стандартный решатель
if problem.status not in ['optimal', 'optimal_inaccurate']:
print(f"Предупреждение: Задача не имеет точного оптимального решения. Статус: {problem.status}")
# Возвращаем равновесный портфель как запасной вариант
return np.ones(n) / n
return weights.value
def estimate_returns_covariance(returns, block_size=21, n_samples=500):
"""
Оценка ковариационной матрицы ошибок ожидаемых доходностей
с использованием блочного бутстрапа
"""
T, n = returns.shape
bootstrap_means = np.zeros((n_samples, n))
np.random.seed(42)  # Для воспроизводимости результатов
for i in range(n_samples):
# Генерация случайных индексов блоков
n_blocks = int(np.ceil(T / block_size))
block_indices = np.random.randint(0, max(1, T - block_size + 1), n_blocks)
# Формирование бутстрап-выборки
sample_indices = []
for idx in block_indices:
sample_indices.extend(range(idx, min(idx + block_size, T)))
sample_indices = sample_indices[:T]  # Обрезаем до исходной длины
# Расчет средней доходности на бутстрап-выборке
bootstrap_means[i] = returns.iloc[sample_indices].mean(axis=0) * 252
# Расчет ковариационной матрицы бутстрап-оценок
returns_cov = np.cov(bootstrap_means, rowvar=False)
# Регуляризация матрицы для обеспечения положительной определенности
eigenvals, eigenvecs = np.linalg.eigh(returns_cov)
eigenvals = np.maximum(eigenvals, 1e-6)
returns_cov = eigenvecs @ np.diag(eigenvals) @ eigenvecs.T
return returns_cov
# Оценка ковариационной матрицы ошибок
print("Оценка ковариационной матрицы ошибок...")
returns_cov = estimate_returns_covariance(returns)
# Применение робастной оптимизации с различными значениями kappa
kappa_values = [0, 0.5, 1, 2, 3]
robust_weights = {}
print("Выполнение робастной оптимизации...")
for kappa in kappa_values:
print(f"Оптимизация для kappa = {kappa}")
try:
robust_weights[kappa] = robust_optimization_ellipsoidal(
annual_returns.values, annual_cov.values, 
returns_cov=returns_cov, kappa=kappa
)
except Exception as e:
print(f"Ошибка при kappa={kappa}: {e}")
robust_weights[kappa] = np.ones(len(annual_returns)) / len(annual_returns)
# Визуализация результатов для разных значений kappa
plt.figure(figsize=(15, 10))
for i, kappa in enumerate(kappa_values):
plt.subplot(len(kappa_values), 1, i+1)
plt.bar(range(len(tickers)), robust_weights[kappa], color='darkgray')
plt.title(f'Распределение весов в портфеле (робастная оптимизация, kappa={kappa})')
plt.ylabel('Веса')
plt.xticks(range(len(tickers)), tickers, rotation=90)
plt.xlabel('Активы')
plt.tight_layout()
plt.show()
# Сравнение характеристик портфелей
print("\n" + "="*60)
print("СРАВНЕНИЕ ХАРАКТЕРИСТИК ПОРТФЕЛЕЙ")
print("="*60)
portfolios = {f'Робастный (kappa={k})': w for k, w in robust_weights.items()}
for name, weights in portfolios.items():
if weights is not None and not np.any(np.isnan(weights)):
portfolio_return = np.dot(annual_returns, weights)
portfolio_risk = np.sqrt(np.dot(weights, np.dot(annual_cov, weights)))
sharpe_ratio = portfolio_return / portfolio_risk if portfolio_risk > 0 else 0
print(f"\n{name}:")
print(f"  Ожидаемая доходность: {portfolio_return:.2%}")
print(f"  Ожидаемый риск:       {portfolio_risk:.2%}")
print(f"  Коэффициент Шарпа:    {sharpe_ratio:.3f}")
print(f"  Максимальный вес:     {np.max(weights):.2%}")
print(f"  Энтропия портфеля:    {-np.sum(weights * np.log(weights + 1e-10)):.3f}")
# Анализ диверсификации
plt.figure(figsize=(12, 8))
diversification_metrics = []
portfolio_names = []
for name, weights in portfolios.items():
if weights is not None and not np.any(np.isnan(weights)):
# Индекс Херфиндаля (чем меньше, тем более диверсифицирован)
hhi = np.sum(weights**2)
diversification_metrics.append(1/hhi)  # Эффективное количество позиций
portfolio_names.append(name)
plt.bar(portfolio_names, diversification_metrics, color='steelblue')
plt.title('Эффективное количество позиций в портфеле\n(выше = более диверсифицирован)')
plt.ylabel('Эффективное количество позиций')
plt.xticks(rotation=45, ha='right')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print("\nОптимизация завершена успешно!")
Оценка ковариационной матрицы ошибок...
Выполнение робастной оптимизации...
Оптимизация для kappa = 0
Оптимизация для kappa = 0.5
Оптимизация для kappa = 1
Оптимизация для kappa = 2
Оптимизация для kappa = 3

Оптимальные веса портфеля при эллипсоидальной робастной оптимизации для различных параметров неопределенности κ. При κ=0 - классическое решение Марковица без корректировки на робастность. При возрастании κ от 0,5 до 3 происходит более консервативное распределение весов как защита от параметрической неопределенности

Рис. 3: Оптимальные веса портфеля при эллипсоидальной робастной оптимизации для различных параметров неопределенности κ. При κ=0 — классическое решение Марковица без корректировки на робастность. При возрастании κ от 0,5 до 3 происходит более консервативное распределение весов как защита от параметрической неопределенности

============================================================
СРАВНЕНИЕ ХАРАКТЕРИСТИК ПОРТФЕЛЕЙ
============================================================
Робастный (kappa=0):
Ожидаемая доходность: 33.90%
Ожидаемый риск:       23.58%
Коэффициент Шарпа:    1.438
Максимальный вес:     77.87%
Энтропия портфеля:    0.682
Робастный (kappa=0.5):
Ожидаемая доходность: 33.12%
Ожидаемый риск:       22.28%
Коэффициент Шарпа:    1.487
Максимальный вес:     70.69%
Энтропия портфеля:    0.605
Робастный (kappa=1):
Ожидаемая доходность: 32.94%
Ожидаемый риск:       22.06%
Коэффициент Шарпа:    1.494
Максимальный вес:     64.66%
Энтропия портфеля:    0.649
Робастный (kappa=2):
Ожидаемая доходность: 31.42%
Ожидаемый риск:       21.23%
Коэффициент Шарпа:    1.480
Максимальный вес:     56.95%
Энтропия портфеля:    0.848
Робастный (kappa=3):
Ожидаемая доходность: 28.07%
Ожидаемый риск:       19.86%
Коэффициент Шарпа:    1.414
Максимальный вес:     47.61%
Энтропия портфеля:    1.178
Оптимизация завершена успешно!

Эффективное количество позиций для различных спецификаций робастной оптимизации. Чем больше позиций, тем больше диверсификация портфеля

Рис. 4: Эффективное количество позиций для различных спецификаций робастной оптимизации. Чем больше позиций, тем больше диверсификация портфеля

В этом коде я реализовал робастную оптимизацию с эллипсоидальным множеством неопределенности для ожидаемых доходностей. Ключевые компоненты:

  1. Функция estimate_returns_covariance оценивает ковариационную матрицу ошибок ожидаемых доходностей с использованием блочного бутстрапа. Этот метод позволяет учесть автокорреляцию в финансовых временных рядах;
  2. Функция robust_optimization_ellipsoidal решает задачу робастной оптимизации с учетом неопределенности;
  3. В функции robust_optimization_ellipsoidal я добавил штраф за неопределенность в виде слагаемого kappa * cp.sqrt(cp.quad_form(weights, returns_cov)). Параметр kappa контролирует уровень консерватизма: чем больше kappa, тем более консервативным будет портфель. При kappa = 0 мы получаем классическую оптимизацию Марковица.
👉🏻  Закон больших чисел в портфельной теории

Важно отметить эффект увеличения параметра kappa: с ростом kappa распределение весов становится более равномерным. Это происходит потому, что робастный оптимизатор «не доверяет» экстремальным оценкам ожидаемой доходности и стремится диверсифицировать портфель, чтобы защититься от ошибок в этих оценках.

Реализация робастной оптимизации с shrinkage-оценками

Другой популярный подход к робастной оптимизации — использование shrinkage-оценок для ожидаемых доходностей и ковариационной матрицы. Этот метод «сжимает» выборочные оценки к более структурированным и устойчивым целевым значениям:

import numpy as np
import cvxpy as cp
def shrinkage_estimates(returns, target_returns=None, target_cov=None, 
shrinkage_intensity_returns=0.5, shrinkage_intensity_cov=0.5):
"""
Вычисление shrinkage-оценок для ожидаемых доходностей и ковариационной матрицы
Параметры:
- returns: матрица исторических доходностей
- target_returns: целевой вектор доходностей (если None, используется единый целевой возврат)
- target_cov: целевая ковариационная матрица (если None, используется диагональная матрица)
- shrinkage_intensity_returns: интенсивность сжатия для доходностей (от 0 до 1)
- shrinkage_intensity_cov: интенсивность сжатия для ковариационной матрицы (от 0 до 1)
Возвращает:
- shrunk_returns: shrinkage-оценка ожидаемых доходностей
- shrunk_cov: shrinkage-оценка ковариационной матрицы
"""
# Выборочные оценки
sample_returns = returns.mean(axis=0) * 252
sample_cov = returns.cov() * 252
# Shrinkage для ожидаемых доходностей
if target_returns is None:
# Используем среднюю доходность по всем активам как целевое значение
target_returns = np.ones_like(sample_returns) * sample_returns.mean()
shrunk_returns = (1 - shrinkage_intensity_returns) * sample_returns + \
shrinkage_intensity_returns * target_returns
# Shrinkage для ковариационной матрицы
if target_cov is None:
# Используем диагональную матрицу с выборочными дисперсиями
target_cov = np.diag(np.diag(sample_cov))
shrunk_cov = (1 - shrinkage_intensity_cov) * sample_cov + \
shrinkage_intensity_cov * target_cov
return shrunk_returns, shrunk_cov
def markowitz_optimization(returns, cov, risk_aversion=1):
# Преобразуем в numpy arrays если необходимо
if hasattr(returns, 'values'):
returns = returns.values
if hasattr(cov, 'values'):
cov = cov.values
n = len(returns)
weights = cp.Variable(n)
# Целевая функция: максимизация полезности (доходность - штраф за риск)
portfolio_return = returns @ weights
portfolio_risk = cp.quad_form(weights, cov)
objective = cp.Maximize(portfolio_return - risk_aversion * portfolio_risk)
# Ограничения
constraints = [
cp.sum(weights) == 1,  # Веса должны суммироваться к 1
weights >= 0           # Запрет на короткие продажи
]
# Решение задачи оптимизации
problem = cp.Problem(objective, constraints)
try:
problem.solve(solver=cp.CLARABEL)
except:
try:
problem.solve(solver=cp.SCS)
except:
problem.solve()
if problem.status not in ['optimal', 'optimal_inaccurate']:
print(f"Предупреждение: Задача не имеет точного оптимального решения. Статус: {problem.status}")
return np.ones(n) / n
return weights.value
# Применение shrinkage-оценок
print("Применение shrinkage-оценок...")
shrunk_returns, shrunk_cov = shrinkage_estimates(
returns, 
shrinkage_intensity_returns=0.5, 
shrinkage_intensity_cov=0.5
)
# Оптимизация с использованием shrinkage-оценок
shrinkage_weights = markowitz_optimization(shrunk_returns, shrunk_cov)
# Визуализация весов
plt.figure(figsize=(12, 6))
plt.bar(range(len(tickers)), shrinkage_weights, color='darkgray')
plt.title('Распределение весов в портфеле (оптимизация с shrinkage-оценками)')
plt.xlabel('Активы')
plt.ylabel('Веса')
plt.xticks(range(len(tickers)), tickers, rotation=90)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Расчет характеристик портфеля с shrinkage-оценками
# Используем оригинальные данные для оценки характеристик
shrinkage_return = np.dot(annual_returns.values, shrinkage_weights)
shrinkage_risk = np.sqrt(np.dot(shrinkage_weights, np.dot(annual_cov.values, shrinkage_weights)))
shrinkage_sharpe = shrinkage_return / shrinkage_risk if shrinkage_risk > 0 else 0
print(f"\nХарактеристики портфеля с shrinkage-оценками:")
print(f"Ожидаемая доходность: {shrinkage_return:.2%}")
print(f"Ожидаемый риск:       {shrinkage_risk:.2%}")
print(f"Коэффициент Шарпа:    {shrinkage_sharpe:.2f}")
# Сравнение с обычными выборочными оценками
print("\nСравнение с классическим подходом:")
classical_weights = markowitz_optimization(annual_returns.values, annual_cov.values)
classical_return = np.dot(annual_returns.values, classical_weights)
classical_risk = np.sqrt(np.dot(classical_weights, np.dot(annual_cov.values, classical_weights)))
classical_sharpe = classical_return / classical_risk if classical_risk > 0 else 0
print(f"Классический подход:")
print(f"Ожидаемая доходность: {classical_return:.2%}")
print(f"Ожидаемый риск:       {classical_risk:.2%}")
print(f"Коэффициент Шарпа:    {classical_sharpe:.2f}")
# Визуализация сравнения
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 6))
# График весов shrinkage
ax1.bar(range(len(tickers)), shrinkage_weights, color='darkblue', alpha=0.7)
ax1.set_title('Shrinkage-оптимизация')
ax1.set_ylabel('Веса')
ax1.set_xticks(range(len(tickers)))
ax1.set_xticklabels(tickers, rotation=90)
ax1.grid(True, alpha=0.3)
# График весов классического подхода
ax2.bar(range(len(tickers)), classical_weights, color='darkred', alpha=0.7)
ax2.set_title('Классическая оптимизация Марковица')
ax2.set_ylabel('Веса')
ax2.set_xticks(range(len(tickers)))
ax2.set_xticklabels(tickers, rotation=90)
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Анализ различий в диверсификации
shrinkage_hhi = np.sum(shrinkage_weights**2)
classical_hhi = np.sum(classical_weights**2)
print(f"\nАнализ диверсификации:")
print(f"Shrinkage - эффективное количество позиций:  {1/shrinkage_hhi:.2f}")
print(f"Классический - эффективное количество позиций: {1/classical_hhi:.2f}")
print(f"Максимальный вес (shrinkage):    {np.max(shrinkage_weights):.2%}")
print(f"Максимальный вес (классический): {np.max(classical_weights):.2%}")
# Тестирование различных уровней shrinkage
shrinkage_levels = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0]
results = []
print(f"\nАнализ различных уровней shrinkage:")
print("Level\tReturn\tRisk\tSharpe\tMax Weight")
print("-" * 50)
for level in shrinkage_levels:
shrunk_ret, shrunk_cov_temp = shrinkage_estimates(
returns, 
shrinkage_intensity_returns=level, 
shrinkage_intensity_cov=level
)
weights_temp = markowitz_optimization(shrunk_ret, shrunk_cov_temp)
ret_temp = np.dot(annual_returns.values, weights_temp)
risk_temp = np.sqrt(np.dot(weights_temp, np.dot(annual_cov.values, weights_temp)))
sharpe_temp = ret_temp / risk_temp if risk_temp > 0 else 0
max_weight_temp = np.max(weights_temp)
print(f"{level:.1f}\t{ret_temp:.1%}\t{risk_temp:.1%}\t{sharpe_temp:.2f}\t{max_weight_temp:.1%}")
results.append((level, ret_temp, risk_temp, sharpe_temp, max_weight_temp))
print("\nОптимизация с shrinkage-оценками завершена!")
Применение shrinkage-оценок...
Характеристики портфеля с shrinkage-оценками:
Ожидаемая доходность: 33.14%
Ожидаемый риск:       22.80%
Коэффициент Шарпа:    1.45
Сравнение с классическим подходом:
Классический подход:
Ожидаемая доходность: 33.90%
Ожидаемый риск:       23.58%
Коэффициент Шарпа:    1.44

Оптимальные веса портфеля, полученные методом shrinkage-оценок с коэффициентами сжатия α = β = 0,5 для ожидаемых доходностей и ковариационной матрицы

Рис. 5: Оптимальные веса портфеля, полученные методом shrinkage-оценок с коэффициентами сжатия α = β = 0,5 для ожидаемых доходностей и ковариационной матрицы

Сравнение распределения весов портфеля при использовании shrinkage-оценок и классического подхода Марковица с выборочными оценками параметров. Shrinkage-оптимизация демонстрирует более равномерное распределение весов и сниженную концентрацию в отдельных активах по сравнению с классическим подходом, что указывает на повышенную робастность к ошибкам оценивания параметров

Рис. 6: Сравнение распределения весов портфеля при использовании shrinkage-оценок и классического подхода Марковица с выборочными оценками параметров. Shrinkage-оптимизация демонстрирует более равномерное распределение весов и сниженную концентрацию в отдельных активах по сравнению с классическим подходом, что указывает на повышенную робастность к ошибкам оценивания параметров

Анализ диверсификации:
Shrinkage - эффективное количество позиций:  2.66
Классический - эффективное количество позиций: 1.58
Максимальный вес (shrinkage):    49.39%
Максимальный вес (классический): 77.87%
Анализ различных уровней shrinkage:
Level	Return	Risk	Sharpe	Max Weight
--------------------------------------------------
0.0	33.9%	23.6%	1.44	77.9%
0.2	33.5%	23.0%	1.46	62.6%
0.4	33.3%	22.8%	1.46	53.9%
0.6	32.8%	22.8%	1.44	44.2%
0.8	30.8%	22.9%	1.35	31.4%
1.0	16.2%	20.6%	0.78	6.7%
Оптимизация с shrinkage-оценками завершена!

Функция shrinkage_estimates реализует линейную комбинацию выборочных оценок и целевых значений. Для ожидаемых доходностей в качестве целевого значения используется средняя доходность по всем активам, а для ковариационной матрицы — диагональная матрица с выборочными дисперсиями. Параметры shrinkage_intensity_returns и shrinkage_intensity_cov контролируют степень «сжатия» к целевым значениям.

Метод shrinkage особенно эффективен при работе с портфелями, содержащими большое число активов относительно длины исторического периода. В таких случаях выборочные оценки ковариационной матрицы часто содержат значительный шум, и shrinkage помогает улучшить обусловленность матрицы и стабильность решения.

Реализация оптимизации с использованием ресэмплинга (Michaud Resampled Efficiency)

Подход Ричарда Мичо (Resampled Efficiency) использует Монте-Карло симуляции для генерации множества возможных сценариев доходностей и рисков, а затем усредняет оптимальные портфели по этим сценариям:

def michaud_resampled_optimization(returns, cov, rf_rate=0.02, n_samples=1000, risk_aversion=1):
"""
Робастная оптимизация портфеля по методу Michaud Resampled Efficiency
Параметры:
- returns: вектор ожидаемых доходностей
- cov: ковариационная матрица
- rf_rate: безрисковая ставка (годовая)
- n_samples: количество ресэмплированных сценариев
- risk_aversion: коэффициент неприятия риска
Возвращает:
- weights: оптимальные веса активов
"""
n = len(returns)
resampled_weights = np.zeros((n_samples, n))
# Используем многомерное нормальное распределение для генерации сценариев
mvn = stats.multivariate_normal(mean=returns, cov=cov)
for i in range(n_samples):
# Генерация ресэмплированных параметров
sample_returns = mvn.rvs()
# Оптимизация для текущего сценария
sample_weights = markowitz_optimization(
sample_returns, cov, risk_aversion=risk_aversion
)
resampled_weights[i] = sample_weights
# Усреднение весов по всем сценариям
avg_weights = resampled_weights.mean(axis=0)
# Нормализация весов (для обеспечения суммы 1)
avg_weights = avg_weights / avg_weights.sum()
return avg_weights
# Применение метода Michaud Resampled Efficiency
michaud_weights = michaud_resampled_optimization(
annual_returns.values, annual_cov.values, n_samples=1000
)
# Визуализация весов
plt.figure(figsize=(12, 6))
plt.bar(tickers, michaud_weights, color='darkgray')
plt.title('Распределение весов в портфеле (метод Michaud Resampled Efficiency)')
plt.xlabel('Активы')
plt.ylabel('Веса')
plt.xticks(rotation=90)
plt.tight_layout()
plt.show()
# Расчет характеристик портфеля
michaud_return = np.dot(annual_returns, michaud_weights)
michaud_risk = np.sqrt(np.dot(michaud_weights, np.dot(annual_cov, michaud_weights)))
michaud_sharpe = michaud_return / michaud_risk
print(f"Ожидаемая доходность портфеля (Michaud): {michaud_return:.2%}")
print(f"Ожидаемый риск портфеля (Michaud): {michaud_risk:.2%}")
print(f"Коэффициент Шарпа: {michaud_sharpe:.2f}")
Ожидаемая доходность портфеля (Michaud): 23.23%
Ожидаемый риск портфеля (Michaud): 22.69%
Коэффициент Шарпа: 1.02

Оптимальные веса портфеля, полученные методом Michaud Resampled Efficiency с использованием N = 1000 ресэмплированных сценариев

Рис. 7: Оптимальные веса портфеля, полученные методом Michaud Resampled Efficiency с использованием N = 1000 ресэмплированных сценариев

Метод Мичо элегантно решает проблему «шумного максимума» путем усреднения результатов оптимизации по множеству возможных сценариев. Метод основан на генерации множественных сценариев доходностей из многомерного нормального распределения с последующим усреднением оптимальных портфелей по всем сценариям.

👉🏻  Сравнение временных финансовых рядов: методы, метрики и примеры на Python

Такой подход снижает чувствительность к ошибкам выборочного оценивания и обеспечивает более стабильное портфельное решение по сравнению с детерминистическими методами оптимизации. Однако коэффициент Шарпа в этом методе почти всегда приносится «в жертву». Поэтому я использую метод Мичо нечасто, хотя многие инвесторы его любят за относительно простую интерпретируемость, которая зачастую важнее сложных математических формул.

Сравнение различных подходов и оценка эффективности на out-of-sample данных

Теперь сравним различные подходы к оптимизации портфеля на исторических out-of-sample данных:

def evaluate_portfolio_performance(weights, returns_data, window_size=126, step_size=21):
"""
Оценка эффективности портфеля на out-of-sample данных
Параметры:
- weights: словарь с весами портфелей для разных методов
- returns_data: матрица исторических доходностей
- window_size: размер окна для out-of-sample оценки (в днях)
- step_size: шаг для перебалансировки портфеля (в днях)
Возвращает:
- performance: DataFrame с результатами для разных методов
"""
# Определяем количество периодов
n_periods = (len(returns_data) - window_size) // step_size
# Словарь для хранения доходностей портфелей
portfolio_returns = {method: [] for method in weights.keys()}
# Для каждого периода
for i in range(n_periods):
# Определяем границы тренировочного и тестового периодов
train_start = i * step_size
train_end = train_start + window_size
test_start = train_end
test_end = min(test_start + step_size, len(returns_data))
# Тестовые доходности
test_returns = returns_data.iloc[test_start:test_end]
# Расчет доходностей портфелей для каждого метода
for method, w in weights.items():
# Расчет доходностей портфеля в тестовом периоде
method_returns = test_returns.dot(w)
portfolio_returns[method].extend(method_returns.tolist())
# Преобразование результатов в DataFrame
performance = pd.DataFrame(portfolio_returns)
# Расчет кумулятивной доходности
cumulative_returns = (1 + performance).cumprod()
# Расчет метрик эффективности
annual_return = (1 + performance.mean()) ** 252 - 1
annual_volatility = performance.std() * np.sqrt(252)
sharpe_ratio = annual_return / annual_volatility
max_drawdown = (cumulative_returns / cumulative_returns.cummax() - 1).min()
# Создание DataFrame с результатами
results = pd.DataFrame({
'Годовая доходность': annual_return,
'Годовая волатильность': annual_volatility,
'Коэффициент Шарпа': sharpe_ratio,
'Максимальная просадка': max_drawdown
})
return results, cumulative_returns
# Создание словаря с весами для разных методов
all_weights = {
'Марковиц': markowitz_weights,
'Робастный (kappa=1)': robust_weights[1],
'Shrinkage': shrinkage_weights,
'Michaud': michaud_weights
}
# Оценка эффективности портфелей
performance_results, cumulative_returns = evaluate_portfolio_performance(
all_weights, returns, window_size=126, step_size=21
)
# Вывод результатов
print("\nСравнение эффективности различных методов:")
print(performance_results)
# Визуализация кумулятивной доходности
plt.figure(figsize=(12, 6))
cumulative_returns.plot(figsize=(12, 6), color=['black', 'blue', 'green', 'red'])
plt.title('Кумулятивная доходность портфелей')
plt.xlabel('Время')
plt.ylabel('Кумулятивная доходность')
plt.grid(True)
plt.legend()
plt.tight_layout()
plt.show()
# Визуализация распределения весов для разных методов
methods = list(all_weights.keys())
n_methods = len(methods)
plt.figure(figsize=(15, 12))
for i, method in enumerate(methods):
plt.subplot(n_methods, 1, i+1)
plt.bar(tickers, all_weights[method], color='darkgray')
plt.title(f'Распределение весов в портфеле ({method})')
plt.ylabel('Веса')
if i == n_methods - 1:
plt.xlabel('Активы')
plt.xticks(rotation=90)
plt.subplots_adjust(hspace=0.5)
plt.tight_layout()
plt.show()
Сравнение эффективности различных методов:
Годовая доходность  Годовая волатильность  Коэффициент Шарпа  Максимальная просадка
Марковиц                  0.391494               0.229629           1.704900            -0.258444  
Робастный (kappa=1)       0.366562               0.214156           1.711658            -0.228311
Shrinkage                 0.377983               0.221312           1.707915            -0.247389  
Michaud                   0.206638               0.216384           0.954961            -0.236251

Кумулятивная доходность портфелей, построенных с использованием различных методов оптимизации, на основе out-of-sample тестирования с окном оценивания 126 торговых дней и периодом ребалансировки 21 день. Анализ охватывает классический подход Марковица (черная линия), робастную эллипсоидальную оптимизацию с κ=1 (синяя линия), shrinkage-оценки (зеленая линия) и метод Michaud Resampled Efficiency (красная линия)

Рис. 8: Кумулятивная доходность портфелей, построенных с использованием различных методов оптимизации, на основе out-of-sample тестирования с окном оценивания 126 торговых дней и периодом ребалансировки 21 день. Анализ охватывает классический подход Марковица (черная линия), робастную эллипсоидальную оптимизацию с κ=1 (синяя линия), shrinkage-оценки (зеленая линия) и метод Michaud Resampled Efficiency (красная линия)

Распределение весов активов для различных методов портфельной оптимизации

Рис. 9: Распределение весов активов для различных методов портфельной оптимизации

Функция evaluate_portfolio_performance оценивает эффективность различных методов оптимизации на out-of-sample данных с использованием скользящего окна. Это позволяет получить более реалистичную оценку, чем простое сравнение характеристик портфелей на in-sample данных.

Давайте так же сравним методы через скользящий коэффициент Шарпа и динамику просадок.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
def calculate_rolling_sharpe(returns, window=63, rf_rate=0.02):
"""
Расчет скользящего коэффициента Шарпа
Параметры:
- returns: серия доходностей портфеля
- window: размер скользящего окна (в днях)
- rf_rate: безрисковая ставка (годовая)
Возвращает:
- rolling_sharpe: серия скользящих коэффициентов Шарпа
"""
daily_rf_rate = rf_rate / 252
excess_returns = returns - daily_rf_rate
rolling_mean = excess_returns.rolling(window=window).mean()
rolling_std = excess_returns.rolling(window=window).std()
rolling_sharpe = (rolling_mean / rolling_std) * np.sqrt(252)
return rolling_sharpe
def calculate_drawdown(cumulative_returns):
"""
Расчет просадок портфеля
Параметры:
- cumulative_returns: кумулятивные доходности
Возвращает:
- drawdown: серия просадок
"""
peak = cumulative_returns.cummax()
drawdown = (cumulative_returns - peak) / peak
return drawdown
# Расчет скользящих коэффициентов Шарпа со сглаживанием в 5 дней
rolling_sharpe = pd.DataFrame()
for method in all_weights.keys():
daily_returns = cumulative_returns[method].pct_change().dropna()
# Простое скользящее среднее для сглаживания (окно 5 дней = неделя)
smoothed_returns = daily_returns.rolling(window=5).mean()
rolling_sharpe[method] = calculate_rolling_sharpe(smoothed_returns.dropna(), window=63)
# Расчет просадок с сглаживанием
drawdowns = pd.DataFrame()
for method in all_weights.keys():
# Простое скользящее среднее кумулятивных доходностей для сглаживания
smoothed_cumret = cumulative_returns[method].rolling(window=5).mean()
drawdowns[method] = calculate_drawdown(smoothed_cumret.dropna())
# Графики скользящих коэффициентов Шарпа
methods = list(all_weights.keys())
colors = ['black', 'blue', 'green', 'red']
fig, axes = plt.subplots(2, 2, figsize=(16, 10))
axes = axes.flatten()
for i, method in enumerate(methods):
ax = axes[i]
ax.plot(rolling_sharpe.index, rolling_sharpe[method], 
color=colors[i], linewidth=2.5, alpha=0.8)
ax.set_title(f'Скользящий коэффициент Шарпа: {method}', fontsize=12, fontweight='bold')
ax.set_xlabel('Время')
ax.set_ylabel('Коэффициент Шарпа')
ax.grid(True, alpha=0.3)
ax.axhline(y=0, color='gray', linestyle='--', alpha=0.5)
plt.tight_layout()
plt.show()
# Графики просадок
fig, axes = plt.subplots(2, 2, figsize=(16, 10))
axes = axes.flatten()
for i, method in enumerate(methods):
ax = axes[i]
# Строим график просадок
ax.fill_between(drawdowns.index, drawdowns[method] * 100, 0, 
color=colors[i], alpha=0.4)
ax.plot(drawdowns.index, drawdowns[method] * 100, 
color=colors[i], linewidth=2.5)
ax.set_title(f'Динамика просадок: {method}', fontsize=12, fontweight='bold')
ax.set_ylabel('Просадка (%)')
ax.grid(True, alpha=0.3)
# Размещаем ось X наверху
ax.xaxis.tick_top()
ax.xaxis.set_label_position('top')
ax.set_xlabel('Время')
ax.set_ylim(ax.get_ylim()[0], 0)  
plt.tight_layout()
plt.show()

Сравнение динамики скользящего коэффициента Шарпа для разных методов портфельной оптимизации

Рис. 10: Сравнение динамики скользящего коэффициента Шарпа для разных методов портфельной оптимизации

Сравнение динамики просадок

Рис. 11: Сравнение динамики просадок

С выбранными нами активами и горизонтом анализа Марковиц смотрится не хуже робастных методов. Хотя, в большинстве случаев, робастные методы обычно превосходят классический подход Марковица на out-of-sample данных по таким метрикам, как коэффициент Шарпа и максимальная просадка.

👉🏻  Копулы в финансовом моделировании: зависимости между случайными величинами

Продвинутые методы робастной оптимизации

Давайте теперь рассмотрим несколько продвинутых методов робастной оптимизации, которые я иногда использую в своей практике.

Оптимизация с байесовским подходом

Байесовский подход к робастной оптимизации портфеля явно учитывает неопределенность в оценках параметров через апостериорные распределения:

def bayesian_portfolio_optimization(returns, prior_mu=None, prior_cov=None, 
prior_strength=1, risk_aversion=1):
"""
Байесовская оптимизация портфеля
Параметры:
- returns: матрица исторических доходностей
- prior_mu: приор для ожидаемых доходностей
- prior_cov: приор для ковариационной матрицы
- prior_strength: сила приора (эквивалентное количество наблюдений)
- risk_aversion: коэффициент неприятия риска
Возвращает:
- weights: оптимальные веса активов
"""
# Выборочные оценки
n_obs, n_assets = returns.shape
sample_mu = returns.mean(axis=0) * 252
sample_cov = returns.cov() * 252
# Если приоры не заданы, используем нейтральные приоры
if prior_mu is None:
# Приор на основе CAPM
market_return = returns.mean(axis=1) * 252  # Аппроксимация рыночной доходности
betas = np.array([np.cov(returns.iloc[:, i], market_return)[0, 1] / 
np.var(market_return) for i in range(n_assets)])
market_premium = market_return.mean() - 0.02  # Предполагаемая безрисковая ставка 2%
prior_mu = 0.02 + betas * market_premium
if prior_cov is None:
# Диагональная матрица с консервативными оценками дисперсии
prior_cov = np.diag(np.diag(sample_cov))
# Байесовские апостериорные оценки
posterior_mu = (n_obs * sample_mu + prior_strength * prior_mu) / (n_obs + prior_strength)
posterior_cov = ((n_obs - 1) * sample_cov + prior_strength * prior_cov) / (n_obs + prior_strength - 1)
# Оптимизация с использованием апостериорных оценок
weights = markowitz_optimization(posterior_mu, posterior_cov, risk_aversion=risk_aversion)
return weights
# Применение байесовской оптимизации
bayesian_weights = bayesian_portfolio_optimization(returns, prior_strength=252, risk_aversion=1)
# Визуализация весов
plt.figure(figsize=(12, 6))
plt.bar(tickers, bayesian_weights, color='darkgray')
plt.title('Распределение весов в портфеле (байесовская оптимизация)')
plt.xlabel('Активы')
plt.ylabel('Веса')
plt.xticks(rotation=90)
plt.tight_layout()
plt.show()
# Добавление в словарь с весами
all_weights['Байесовский'] = bayesian_weights
# Обновление оценки эффективности
performance_results, cumulative_returns = evaluate_portfolio_performance(
all_weights, returns, window_size=126, step_size=21
)
print("\nСравнение эффективности с добавлением байесовского подхода:")
print(performance_results)

Распределение весов в портфеле (байесовская оптимизация)

Рис. 12: Распределение весов в портфеле (байесовская оптимизация)

Сравнение эффективности с добавлением байесовского подхода:
Годовая доходность  Годовая волатильность  Коэффициент Шарпа  Максимальная просадка
Марковиц                  0.391494               0.229629           1.704900            -0.258444 
Робастный (kappa=1)       0.366562               0.214156           1.711658            -0.228311
Shrinkage                 0.377983               0.221312           1.707915            -0.247389  
Michaud                   0.206638               0.216384           0.954961            -0.236251
Байесовский               0.382739               0.222480           1.720327            -0.249656

Как мы видим, байесовский подход по показателям коэффициента Шарпа обогнал все остальные методы, а по уровню годовой доходности занял 2-е место в нашем исследовании, уступив лишь классической модели Марковица.

Байесовский подход к портфельной оптимизации показывает наибольшую эффективность, когда у нас есть обоснованные предположения о параметрах (например, основанные на экономической теории или экспертных оценках), которые мы хотим интегрировать с историческими данными. Параметр prior_strength позволяет контролировать, насколько сильно мы «доверяем» приорам по сравнению с выборочными оценками.

Иерархическая кластеризация активов

Иерархическая кластеризация активов может помочь улучшить оценку ковариационной матрицы, особенно для портфелей с большим количеством активов.

Метод Hierarchical Risk Parity (HRP) Лопеса де Прадо использует древовидную структуру для группировки схожих активов и последующего распределения риска между кластерами. Этот подход решает проблемы нестабильности классической оптимизации Марковица, обеспечивая более робастное и диверсифицированное портфельное решение без необходимости обращения ковариационной матрицы.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.cluster.hierarchy import linkage, dendrogram
from scipy.spatial.distance import squareform
def hierarchical_risk_parity(returns_data):
"""
Метод Hierarchical Risk Parity (Lopez de Prado)
Параметры:
- returns_data: матрица исторических доходностей
Возвращает:
- weights: оптимальные веса активов
"""
# Расчет корреляционной матрицы
corr = returns_data.corr()
# Преобразование корреляционной матрицы в расстояния
distance = np.sqrt(0.5 * (1 - corr))
# Преобразование в condensed distance matrix для scipy
distance_condensed = squareform(distance.values, checks=False)
# Иерархическая кластеризация
link = linkage(distance_condensed, method='single')
# Получение порядка активов из дендрограммы
clusters = dendrogram(link, no_plot=True)['leaves']
# Расчет ковариационной матрицы
cov = returns_data.cov() * 252
# Инициализация весов равномерно
weights = pd.Series(1.0, index=returns_data.columns)
# Функция для рекурсивного распределения весов
def recursive_bisection(cov_matrix, items):
"""
Рекурсивно разделяет кластер и распределяет веса
"""
if len(items) == 1:
return pd.Series([1.0], index=items)
# Разделение на два подкластера
mid = len(items) // 2
left_items = items[:mid]
right_items = items[mid:]
# Расчет обратной волатильности для подкластеров
left_cov = cov_matrix.loc[left_items, left_items]
right_cov = cov_matrix.loc[right_items, right_items]
# Веса по обратной волатильности внутри кластеров
left_ivol = 1 / np.sqrt(np.diag(left_cov))
right_ivol = 1 / np.sqrt(np.diag(right_cov))
left_weights = left_ivol / left_ivol.sum()
right_weights = right_ivol / right_ivol.sum()
# Расчет кластерной волатильности
left_vol = np.sqrt(left_weights.dot(left_cov).dot(left_weights))
right_vol = np.sqrt(right_weights.dot(right_cov).dot(right_weights))
# Распределение весов между кластерами (обратно пропорционально волатильности)
total_vol = left_vol + right_vol
left_cluster_weight = right_vol / total_vol  # обратная пропорция
right_cluster_weight = left_vol / total_vol
# Рекурсивное применение к подкластерам
left_final = recursive_bisection(cov_matrix, left_items)
right_final = recursive_bisection(cov_matrix, right_items)
# Объединение результатов
result = pd.Series(dtype=float)
result = pd.concat([
left_final * left_cluster_weight,
right_final * right_cluster_weight
])
return result
# Применение рекурсивного алгоритма к упорядоченным активам
ordered_assets = [returns_data.columns[i] for i in clusters]
final_weights = recursive_bisection(cov, ordered_assets)
# Восстановление исходного порядка активов
final_weights = final_weights.reindex(returns_data.columns)
return final_weights.values
def plot_dendrogram(returns_data):
"""
Построение дендрограммы для визуализации кластерной структуры
"""
corr = returns_data.corr()
distance = np.sqrt(0.5 * (1 - corr))
distance_condensed = squareform(distance.values, checks=False)
link = linkage(distance_condensed, method='single')
plt.figure(figsize=(12, 8))
dendrogram(link, labels=returns_data.columns, orientation='top')
plt.title('Дендрограмма активов (Hierarchical Risk Parity)')
plt.xlabel('Активы')
plt.ylabel('Расстояние')
plt.xticks(rotation=90)
plt.tight_layout()
plt.show()
# Построение дендрограммы для визуализации
plot_dendrogram(returns)
# Применение метода Hierarchical Risk Parity
hrp_weights = hierarchical_risk_parity(returns)
# Визуализация весов
plt.figure(figsize=(12, 6))
plt.bar(range(len(tickers)), hrp_weights, color='darkgray')
plt.title('Распределение весов в портфеле (Hierarchical Risk Parity)')
plt.xlabel('Активы')
plt.ylabel('Веса')
plt.xticks(range(len(tickers)), tickers, rotation=90)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Добавление в словарь с весами
all_weights['HRP'] = hrp_weights
# Расчет характеристик HRP портфеля
hrp_return = np.dot(annual_returns.values, hrp_weights)
hrp_risk = np.sqrt(np.dot(hrp_weights, np.dot(annual_cov.values, hrp_weights)))
hrp_sharpe = hrp_return / hrp_risk if hrp_risk > 0 else 0
print(f"\nХарактеристики HRP портфеля:")
print(f"Ожидаемая доходность: {hrp_return:.2%}")
print(f"Ожидаемый риск:       {hrp_risk:.2%}")
print(f"Коэффициент Шарпа:    {hrp_sharpe:.2f}")
# Анализ диверсификации
hrp_hhi = np.sum(hrp_weights**2)
print(f"Эффективное количество позиций: {1/hrp_hhi:.2f}")
print(f"Максимальный вес: {np.max(hrp_weights):.2%}")

Дендрограмма активов, построенная на основе корреляционных расстояний d = √(0.5 × (1 - ρ)) с использованием метода single linkage. Высота ветвей отражает степень различия между кластерами активов. Четко выделяются секторальные группировки: банковские институты, сырьевые компании и энергетические активы. Данная иерархическая структура служит основой для последующего распределения весов по принципу равного риска между кластерами

Рис. 13: Дендрограмма активов, построенная на основе корреляционных расстояний d = √(0.5 × (1 — ρ)) с использованием метода single linkage. Высота ветвей отражает степень различия между кластерами активов. Четко выделяются секторальные группировки: банковские институты, сырьевые компании и энергетические активы. Данная иерархическая структура служит основой для последующего распределения весов по принципу равного риска между кластерами

Оптимальные веса активов в портфеле HRP, полученные методом рекурсивного распределения на основе иерархической кластерной структуры. Алгоритм обеспечивает равномерное распределение риска между кластерами при одновременной диверсификации внутри каждой группы активов. В отличие от классической оптимизации Марковица, метод HRP не требует обращения ковариационной матрицы, что обеспечивает большую численную стабильность и робастность портфельного решения

Рис. 14: Оптимальные веса активов в портфеле HRP, полученные методом рекурсивного распределения на основе иерархической кластерной структуры. Алгоритм обеспечивает равномерное распределение риска между кластерами при одновременной диверсификации внутри каждой группы активов. В отличие от классической оптимизации Марковица, метод HRP не требует обращения ковариационной матрицы, что обеспечивает большую численную стабильность и робастность портфельного решения

Характеристики HRP портфеля:
Ожидаемая доходность: 14.97%
Ожидаемый риск:       21.21%
Коэффициент Шарпа:    0.71
Эффективное количество позиций: 20.81
Максимальный вес: 7.77%

Метод Hierarchical Risk Parity (HRP), предложенный Маркосом Лопесом де Прадо, не требует инверсии ковариационной матрицы и работает с неполными данными, что делает его особенно полезным для портфелей с большим количеством активов. HRP считается одним из лучших методов для диверсификации рисков и зачастую более эффективен на out-of-sample выборках в сравнении с классическими методами.

👉🏻  Портфель минимальной волатильности (Minimum Variance Portfolio)

Практические рекомендации

На основе моего опыта работы с робастными оптимизациями, я бы выделил несколько практических рекомендаций, которые помогут вам эффективно применять эти методы в реальных инвестиционных стратегиях.

Выбор метода

Выбор конкретного метода робастной оптимизации зависит от нескольких факторов:

  • Для портфелей с небольшим числом активов (до 20-30) хорошо работают методы с эллипсоидальными множествами неопределенности и shrinkage-оценками;
  • Для портфелей с большим числом активов (50+) предпочтительны методы, не требующие инверсии ковариационной матрицы, такие как Hierarchical Risk Parity.

Горизонт инвестирования:

  • Для краткосрочных стратегий (недели) важнее точно оценить ковариационную структуру, поэтому подходы с робастными оценками ковариационной матрицы (shrinkage, факторные модели) более предпочтительны;
  • Для долгосрочных стратегий (месяцы, годы) неопределенность в ожидаемых доходностях становится ключевым фактором, и методы, явно учитывающие эту неопределенность (байесовский подход, ресэмплинг), дают лучшие результаты.

Торговые издержки и ограничения:

Если торговые издержки высоки или существуют значительные ограничения на ликвидность, предпочтительны методы, приводящие к более стабильным весам при ребалансировке (например, робастная оптимизация с высоким значением параметра консерватизма).

Доступность данных и вычислительных ресурсов:

Методы, требующие большого количества симуляций (ресэмплинг, метод Монте-Карло), могут быть вычислительно затратными для портфелей с большим числом активов. В условиях ограниченных исторических данных более предпочтительны байесовские методы с информативными приорами.

Настройка параметров робастности

Настройка уровня робастности (например, параметра kappa в эллипсоидальном подходе или интенсивности сжатия в shrinkage) — это баланс между защитой от ошибок в оценках и эффективностью портфеля.

Слишком высокий уровень робастности может привести к чрезмерно консервативным портфелям с низкой ожидаемой доходностью. Мой подход к настройке этих параметров включает:

  • Кросс-валидация: Разделение исторических данных на обучающий и тестовый периоды и поиск параметров, максимизирующих показатели эффективности на тестовых данных;
  • Адаптивная настройка: Изменение параметров робастности в зависимости от рыночных условий. В периоды высокой волатильности и неопределенности (например, во время кризисов) увеличение уровня робастности может защитить портфель от экстремальных потерь;
  • Экспертная оценка: В некоторых случаях экспертная оценка может быть более эффективной, чем чисто статистические методы. Например, если есть основания полагать, что исторические данные не репрезентативны для будущего (смена режима рынка, изменения в монетарной политике и т.д.), параметры робастности могут быть скорректированы на основе экспертного мнения.

Интеграция с многопериодной оптимизацией

Большинство подходов к робастной оптимизации являются однопериодными, то есть оптимизируют портфель на один шаг вперед. Однако в реальности инвесторы часто имеют многопериодный горизонт с промежуточными ребалансировками. Интеграция робастной оптимизации с многопериодным подходом может дать дополнительные преимущества:

  1. Многопериодная оптимизация учитывает будущие ребалансировки и транзакционные издержки, что особенно важно для инвесторов с долгосрочным горизонтом;
  2. Она также позволяет явно моделировать изменение параметров робастности во времени, например, увеличивая консерватизм в периоды высокой неопределенности.

Ограничения и недостатки робастной оптимизации

За снижение риска за счет оптимизации приходится платить. Выбирая робастную оптимизацию портфеля вам следует быть готовым к следующему:

  1. Чрезмерная консервативность: Робастные портфели могут быть слишком консервативными, особенно при высоких значениях параметров робастности. Это может привести к низкой ожидаемой доходности в «нормальных» рыночных условиях;
  2. Сложность настройки: Выбор конкретного метода робастной оптимизации и настройка его параметров требуют значительного опыта и могут быть неинтуитивными для неспециалистов;
  3. Вычислительная сложность: Некоторые методы робастной оптимизации (например, многопериодная оптимизация или методы с большим количеством симуляций) могут быть вычислительно затратными;
  4. Зависимость от предположений: Хотя робастная оптимизация менее чувствительна к ошибкам в оценках параметров, она все еще зависит от некоторых предположений, таких как форма множеств неопределенности или выбор целевой функции.

В своей практике я стараюсь компенсировать эти недостатки комбинированием различных подходов и регулярным мониторингом эффективности портфеля с возможностью адаптации методологии в зависимости от меняющихся рыночных условий.

Заключение

Робастная оптимизация портфеля представляет собой мощный инструмент для управления инвестициями в условиях неопределенности. В отличие от классической теории портфеля, робастный подход явно учитывает неопределенность в оценках параметров и стремится создать портфели, устойчивые к ошибкам прогнозирования.

Сегодня робастные методы становятся стандартом для тех, кто серьезно относится к управлению риском в условиях фундаментальной неопределенности финансовых рынков. Преимущества этих оптимизационных методов особенно заметны при out-of-sample тестировании и в периоды рыночных стрессов, когда точность прогнозов снижается, а корреляции между активами резко изменяются. Вот почему многие институциональные инвесторы и хедж-фонды все чаще внедряют эти подходы в свой инвестиционный процесс.