В данной статье я хочу поделиться опытом использования Python для расчета ключевых показателей доходности и риска биржевой торговли. Я рассмотрю как классические метрики, так и более продвинутые подходы, используемые в профессиональных инвестиционных компаниях и хедж-фондах.
Основы анализа доходности и риска
Прежде чем погружаться в технические детали и код, давайте выясним, что именно мы хотим измерять и почему это важно. Понимание фундаментальных концепций критически важно для корректной интерпретации результатов наших расчетов.
Доходность как многомерная величина
Доходность — это не просто процент прибыли. Это комплексный показатель, который нужно рассматривать в нескольких измерениях:
- Абсолютная доходность — прямое изменение стоимости актива;
- Относительная доходность — сравнение с бенчмарком или безрисковой ставкой;
- Временная структура доходности — как распределяется доходность во времени;
- Доходность с учетом риска — насколько оправдан риск, который мы берем.
Я часто сталкиваюсь с тем, что инвесторы и аналитики чрезмерно фиксируются на абсолютной доходности, игнорируя остальные измерения. Это приводит к искаженному восприятию успешности инвестиций и некорректным решениям.
Риск: что мы на самом деле измеряем
Риск — это вероятность отклонения фактического результата от ожидаемого. Однако, на практике мы сталкиваемся с разными типами рисков:
- Волатильность — краткосрочные колебания стоимости актива;
- Риск просадки — вероятность и величина значительного снижения стоимости;
- Риск хвостовых событий — вероятность экстремальных, редких событий;
- Систематический риск — зависимость от рыночных факторов;
- Специфический риск — риск, связанный с конкретным активом
Важно понимать, что классическая статистика, основанная на нормальном распределении, часто недооценивает реальные риски на финансовых рынках. В моей практике использование более сложных распределений и методов оценки риска показало гораздо более точные результаты.
Подготовка данных для анализа
Качество данных напрямую влияет на точность наших расчетов. Поэтому первым шагом всегда должна быть тщательная подготовка данных.
Давайте начнем с базовой настройки нашей рабочей среды и загрузки необходимых данных. Мы будем использовать библиотеки pandas, numpy, matplotlib, seaborn, scipy и Alpha Vantage API для получения реальных исторических данных по биржевым котировкам акций.
Не забудьте в строке alpha_vantage_api_key ввести свой API токен. Получить его можно по этой ссылке https://www.alphavantage.co/support/#api-key . А для определения тикеров нужных активов можно воспользоваться следующей ссылкой: https://www.alphavantage.co/query?function=SYMBOL_SEARCH&keywords=tesco&apikey=demo , нужно заменить tesco на интересующую вас акцию, бонд или фьючерс, а в apikey подставить свой ключ.
!pip install alpha_vantage
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from alpha_vantage.timeseries import TimeSeries
from scipy import stats
import warnings
# Настройки для более красивой визуализации
plt.style.use('fivethirtyeight')
warnings.filterwarnings('ignore')
sns.set_palette("colorblind")
API_KEY = '_____________' # Замените на свой API ключ
tickers = ['AAPL', 'MSFT', 'AMZN', 'GOOGL', 'META']
start_date = '2022-01-01'
end_date = '2025-05-01'
# Инициализация TimeSeries
ts = TimeSeries(key=API_KEY, output_format='pandas')
# Функция загрузки данных
def load_price_data(tickers, start_date, end_date):
data = pd.DataFrame()
for ticker in tickers:
print(f"Загрузка данных для {ticker}...")
df, meta = ts.get_daily(symbol=ticker, outputsize='full') # 'full' — получаем все доступные данные
df = df[['4. close']] # берем только закрытие
df.columns = [ticker] # переименовываем колонку в тикер
df.index = pd.to_datetime(df.index) # конвертируем индекс в datetime
df = df.sort_index() # сортировка по возрастанию даты
df = df.loc[start_date:end_date] # фильтрация по периоду
data = pd.concat([data, df], axis=1)
# Проверка на пропущенные значения
missing_data = data.isnull().sum()
if missing_data.sum() > 0:
print(f"Обнаружены пропущенные значения:\n{missing_data}")
# Решаем проблему пропущенных значений методом forward fill
data = data.fillna(method='ffill').fillna(method='bfill')
return data
# Запуск функции
prices = load_price_data(tickers, start_date, end_date)
print(prices.head())
AAPL MSFT AMZN GOOGL META
date
2022-01-03 182.01 334.75 3408.09 2899.83 338.54
2022-01-04 179.70 329.01 3350.44 2887.99 336.53
2022-01-05 174.92 316.38 3287.14 2755.50 324.17
2022-01-06 172.00 313.88 3265.08 2754.95 332.46
2022-01-07 172.17 314.04 3251.08 2740.34 331.79
После получения данных необходимо преобразовать абсолютные цены в доходности, что является стандартной практикой при анализе финансовых временных рядов:
# Расчет дневных доходностей
daily_returns = prices.pct_change().dropna()
# Расчет логарифмических доходностей
log_returns = np.log(prices / prices.shift(1)).dropna()
# Расчет кумулятивной доходности
cumulative_returns = (1 + daily_returns).cumprod() - 1
# Анализ распределения доходностей
plt.figure(figsize=(14, 8))
for i, ticker in enumerate(daily_returns.columns):
plt.subplot(2, 3, i+1)
sns.histplot(daily_returns[ticker], kde=True)
plt.axvline(daily_returns[ticker].mean(), color='r', linestyle='--')
plt.title(f'Распределение доходностей {ticker}', fontsize=12)
plt.xlim(-0.10, 0.10)
plt.tight_layout()
plt.show()
Рис. 1: Графики распределения доходностей акций техногигантов
Обратите внимание, что я использую как простые процентные доходности, так и логарифмические. Логарифмические доходности имеют ряд математических преимуществ, особенно при расчете многопериодных показателей и анализе распределений.
Традиционные показатели доходности и риска
Начнем с расчета базовых показателей, которые формируют основу для дальнейшего анализа. Несмотря на мою склонность к более продвинутым методам, понимание классических метрик необходимо для полноценного финансового анализа.
Базовые метрики доходности
Самые простые, но при этом фундаментальные метрики доходности включают:
def calculate_basic_return_metrics(returns, periods_per_year=252):
metrics = pd.DataFrame(index=returns.columns)
metrics['Daily Mean Return'] = returns.mean() # Средняя дневная доходность
metrics['Annual Return'] = (1 + metrics['Daily Mean Return']) ** periods_per_year - 1 # Годовая доходность (аннуализированная)
metrics['Total Return'] = (1 + returns).prod() - 1 # Кумулятивная доходность за весь период
metrics['Median Return'] = returns.median() # Медианная доходность
metrics['Min Return'] = returns.min() # Минимальная дневная доходность
metrics['Max Return'] = returns.max() # Максимальная дневная доходность
return metrics
# Расчет базовых метрик доходности
return_metrics = calculate_basic_return_metrics(daily_returns)
return_metrics
Рис. 2: Таблица сравнения доходностей акций (дневной, годовой, всего, медианной, минимальной, максимальной)
Эти показатели дают нам первичное представление о прибыльности активов. Однако, они не учитывают риск, что делает их недостаточными для полноценного анализа.
Метрики волатильности и риска
Волатильность — классическая мера риска в финансах. Однако современный подход требует более детального анализа разных аспектов риска:
def calculate_risk_metrics(returns, periods_per_year=252):
metrics = pd.DataFrame(index=returns.columns)
# Стандартное отклонение (дневное)
metrics['Daily Volatility'] = returns.std()
# Годовая волатильность
metrics['Annual Volatility'] = metrics['Daily Volatility'] * np.sqrt(periods_per_year)
# Отрицательная полуволатильность (только отрицательные доходности)
neg_returns = returns.copy()
neg_returns[returns > 0] = 0
metrics['Downside Volatility'] = neg_returns.std() * np.sqrt(periods_per_year)
# Value at Risk (95% и 99%)
metrics['VaR 95%'] = returns.quantile(0.05)
metrics['VaR 99%'] = returns.quantile(0.01)
# Conditional VaR (Expected Shortfall)
metrics['CVaR 95%'] = returns[returns <= metrics['VaR 95%']].mean()
metrics['CVaR 99%'] = returns[returns <= metrics['VaR 99%']].mean()
# Максимальная просадка
cumulative = (1 + returns).cumprod()
running_max = cumulative.cummax()
drawdown = (cumulative / running_max) - 1
metrics['Max Drawdown'] = drawdown.min()
return metrics
# Расчет метрик риска
risk_metrics = calculate_risk_metrics(daily_returns)
print(risk_metrics)
Рис. 3: Таблица сравнения волатильности акций (дневной, годовой, отрицательной, квантилей дисперсии, максимальной просадки)
Данная таблица позволяет оценить волатильность акций, т. е. колебания доходностей. Я особенно обращаю внимание на метрики хвостовых рисков (VaR и CVaR) и максимальной просадки, так как они часто дают более реалистичную картину риска, чем стандартное отклонение.
Соотношение риска и доходности
Комбинируя метрики доходности и риска, мы получаем более полное представление об эффективности инвестиций:
def calculate_risk_adjusted_returns(returns, risk_free_rate=0.02, periods_per_year=252):
metrics = pd.DataFrame(index=returns.columns)
# Средняя доходность и волатильность (годовые)
mean_return = returns.mean() * periods_per_year
volatility = returns.std() * np.sqrt(periods_per_year)
# Дневной безрисковый доход
daily_rf = (1 + risk_free_rate) ** (1 / periods_per_year) - 1
# Коэффициент Шарпа
metrics['Sharpe Ratio'] = (mean_return - daily_rf * periods_per_year) / volatility
# Коэффициент Сортино
downside_returns = returns.copy()
downside_returns[returns > daily_rf] = 0
downside_deviation = downside_returns.std() * np.sqrt(periods_per_year)
metrics['Sortino Ratio'] = (mean_return - daily_rf * periods_per_year) / downside_deviation
# Коэффициент Калмара
cumulative = (1 + returns).cumprod()
running_max = cumulative.cummax()
drawdown = (cumulative / running_max) - 1
max_drawdown = drawdown.min().abs()
metrics['Calmar Ratio'] = mean_return / max_drawdown
# Информационный коэффициент (используя NASDAQ-100 как бенчмарк)
if 'VCNIX' in returns.columns:
benchmark_returns = returns['VCNIX']
for col in returns.columns:
if col != 'VCNIX':
tracking_error = (returns[col] - benchmark_returns).std() * np.sqrt(periods_per_year)
metrics.loc[col, 'Information Ratio'] = (mean_return[col] - mean_return['SPY']) / tracking_error
return metrics
# Расчет показателей с учетом риска
risk_adjusted_metrics = calculate_risk_adjusted_returns(daily_returns)
print(risk_adjusted_metrics)
Sharpe Ratio Sortino Ratio Calmar Ratio
AAPL 0.242041 0.405648 0.272962
MSFT 0.326779 0.556930 0.322503
AMZN -0.315222 -0.361719 -0.189501
GOOGL -0.342991 -0.382362 -0.198265
META 0.531899 0.851083 0.378327
Коэффициенты Шарпа, Сортино и Калмара — это стандартные инструменты для сравнения эффективности инвестиций с учетом риска. Однако, как мы увидим далее, они имеют свои ограничения.
Расширенный анализ распределения доходностей
Классический финансовый анализ часто предполагает нормальное распределение доходностей. Однако реальность гораздо сложнее — доходности финансовых активов обычно имеют тяжелые хвосты и асимметрию. Давайте проанализируем это более детально:
Анализ асимметрии и эксцесса
Код ниже анализирует распределение доходностей с точки зрения отклонения от нормального распределения.
def analyze_return_distribution(returns):
metrics = pd.DataFrame(index=returns.columns)
# Асимметрия (Skewness)
metrics['Skewness'] = returns.skew()
# Эксцесс (Kurtosis)
metrics['Excess Kurtosis'] = returns.kurtosis()
# Тест Жарке-Бера на нормальность
jb_test = {}
p_values = {}
for col in returns.columns:
jb_value, p_value = stats.jarque_bera(returns[col].dropna())
jb_test[col] = jb_value
p_values[col] = p_value
metrics['JB Statistic'] = pd.Series(jb_test)
metrics['JB p-value'] = pd.Series(p_values)
metrics['Is Normal'] = metrics['JB p-value'] > 0.05
return metrics
# Анализ распределения доходностей
distribution_metrics = analyze_return_distribution(daily_returns)
print(distribution_metrics)
# Визуализация Q-Q графиков для проверки нормальности
plt.figure(figsize=(14, 10))
for i, ticker in enumerate(daily_returns.columns):
plt.subplot(2, 3, i+1)
stats.probplot(daily_returns[ticker].dropna(), dist="norm", plot=plt)
plt.title(f'Q-Q График для {ticker}', fontsize=13)
plt.tight_layout()
plt.show()
Skewness Excess Kurtosis JB Statistic JB p-value Is Normal
AAPL 0.536003 7.485320 1.959883e+03 0.000000e+00 False
MSFT 0.260479 2.977425 3.123052e+02 1.526792e-68 False
AMZN -14.924516 346.903670 4.162659e+06 0.000000e+00 False
GOOGL -17.353228 422.956146 6.183800e+06 0.000000e+00 False
META -0.226694 19.314374 1.280614e+04 0.000000e+00 False
Рис. 4: Сравнение Q-Q графиков для различных акций
Положительный эксцесс и отклонение от линии на Q-Q графике свидетельствуют о наличии тяжелых хвостов, что означает более высокую вероятность экстремальных событий, чем предполагает нормальное распределение.
Моделирование тяжелых хвостов
Для более точного моделирования экстремальных событий можно использовать теорию экстремальных значений (EVT) и распределение Парето. Описанная ниже функция моделирует хвосты распределения с помощью обобщенного распределения Парето.
def fit_generalized_pareto(returns, threshold_quantile=0.05):
results = {}
for col in returns.columns:
series = returns[col].dropna()
# Определяем пороговое значение как квантиль
threshold = np.quantile(series, threshold_quantile)
# Выбираем значения ниже порога для левого хвоста
exceedances = -(series[series < threshold] - threshold) if len(exceedances) > 20: # Нужно достаточно данных для надежной оценки
# Подгоняем обобщенное распределение Парето
shape, loc, scale = stats.genpareto.fit(exceedances)
results[col] = {
'shape': shape,
'location': loc,
'scale': scale,
'threshold': threshold,
'exceedances_count': len(exceedances)
}
return results
# Моделирование хвостов распределения
evt_models = fit_generalized_pareto(daily_returns)
# Визуализация подгонки модели для одного из активов
if evt_models and 'GOOGL' in evt_models:
ticker = 'GOOGL'
model = evt_models[ticker]
# Получаем данные для левого хвоста
series = daily_returns[ticker].dropna()
threshold = model['threshold']
exceedances = -(series[series < threshold] - threshold)
# Создаем график
plt.figure(figsize=(12, 6))
# Гистограмма эмпирических данных
plt.hist(exceedances, bins=30, density=True, alpha=0.6, label='Эмпирические данные')
# Функция плотности вероятности модели
x = np.linspace(0, exceedances.max(), 1000)
y = stats.genpareto.pdf(x, model['shape'], model['location'], model['scale'])
plt.plot(x, y, 'r-', lw=2, label='Модель Парето')
plt.title(f'Подгонка левого хвоста для {ticker}', fontsize=14)
plt.xlabel('Отклонение от порога')
plt.ylabel('Плотность')
plt.legend()
plt.show()
Рис. 5: Визуализация подгонки левого хвоста доходностей для акций Google
Использование обобщенного распределения Парето позволяет нам более точно оценивать вероятность экстремальных убытков, что критически важно для управления рисками.
Продвинутые методы оценки риска
Традиционные метрики риска имеют ряд ограничений. Давайте рассмотрим более продвинутые подходы, используемые в инвестбанках и хедж-фондах.
Расчет условной стоимости под риском (CVaR / Expected Shortfall)
CVaR показывает ожидаемые потери в случае реализации хвостового риска. Он дает более полную картину, чем классический VaR:
def calculate_advanced_var_cvar(returns, confidence_levels=[0.95, 0.99], methods=['historical', 'parametric', 'evt']):
results = {}
for ticker in returns.columns:
series = returns[ticker].dropna()
ticker_results = {}
for confidence in confidence_levels:
alpha = 1 - confidence
method_results = {}
# Исторический метод
if 'historical' in methods:
var_hist = np.percentile(series, alpha * 100)
cvar_hist = series[series <= var_hist].mean()
method_results['historical'] = {'VaR': var_hist, 'CVaR': cvar_hist}
# Параметрический метод (предполагает нормальное распределение)
if 'parametric' in methods:
mu = series.mean()
sigma = series.std()
var_param = stats.norm.ppf(alpha, mu, sigma)
# Расчет CVaR для нормального распределения
cvar_param = mu - sigma * stats.norm.pdf(stats.norm.ppf(alpha)) / alpha
method_results['parametric'] = {'VaR': var_param, 'CVaR': cvar_param}
# Метод на основе теории экстремальных значений
if 'evt' in methods and ticker in evt_models:
model = evt_models[ticker]
threshold = model['threshold']
# Вероятность того, что доходность ниже порога
prob_below_threshold = (series < threshold).mean()
# Для заданного уровня доверия находим уровень exceedance
# такой, что P(X < threshold) + P(X < threshold) * P(Y > u | X < threshold) = alpha
# где Y - распределение exceedances
# Это упрощенный подход, в реальности нужно решать уравнение
target_prob = alpha / prob_below_threshold
if target_prob < 1:
excess_var = stats.genpareto.ppf(
target_prob,
model['shape'],
model['location'],
model['scale']
)
var_evt = threshold - excess_var
# Расчет CVaR для EVT
# Используем формулу для условного математического ожидания GPD
if model['shape'] < 1: # Для существования матожидания
beta = model['scale']
xi = model['shape']
cvar_excess = (beta + xi * excess_var) / (1 - xi)
cvar_evt = threshold - cvar_excess
method_results['evt'] = {'VaR': var_evt, 'CVaR': cvar_evt}
ticker_results[f'{confidence:.0%}'] = method_results
results[ticker] = ticker_results
return results
# Расчет VaR и CVaR разными методами
advanced_risk_metrics = calculate_advanced_var_cvar(daily_returns)
# Вывод результатов для одного из активов
if 'GOOGL' in advanced_risk_metrics:
print("VaR и CVaR для GOOGL:")
print(pd.DataFrame(advanced_risk_metrics['GOOGL']['95%']))
VaR и CVaR для GOOGL:
historical parametric
VaR -0.033286 -0.064954
CVaR -0.068678 -0.081261
Как видно из результатов, разные методы дают разные оценки VaR и CVaR. Метод EVT обычно показывает более консервативные (высокие по модулю) оценки риска, что особенно важно в периоды рыночных кризисов.
Факторный анализ риска
Понимание факторов, влияющих на риск, позволяет более точно его моделировать и контролировать. Функция ниже вычисляет факторный анализ риска:
def perform_factor_risk_analysis(returns, factor_returns):
factor_exposures = {}
specific_returns = {}
factor_contribution = {}
for ticker in returns.columns:
if ticker in factor_returns.columns:
continue # Пропускаем, если тикер сам является фактором
# Регрессионный анализ для определения факторных экспозиций
y = returns[ticker].dropna()
X = factor_returns.loc[y.index].dropna()
if len(X) == len(y):
# Добавляем константу для альфы
X_with_const = sm.add_constant(X)
model = sm.OLS(y, X_with_const).fit()
# Сохраняем коэффициенты
factor_exposures[ticker] = model.params
# Специфические доходности (остатки)
specific_returns[ticker] = model.resid
# Вклад каждого фактора в риск
factor_cov = X.cov()
factor_betas = model.params[1:] # Исключаем константу
# Расчет систематического риска
systematic_risk = np.sqrt(factor_betas.dot(factor_cov).dot(factor_betas)) * np.sqrt(252)
# Расчет специфического риска
specific_risk = model.resid.std() * np.sqrt(252)
# Общий риск
total_risk = np.sqrt(systematic_risk**2 + specific_risk**2)
factor_contribution[ticker] = {
'Systematic Risk': systematic_risk,
'Specific Risk': specific_risk,
'Total Risk': total_risk,
'Systematic Risk %': systematic_risk / total_risk * 100,
'Specific Risk %': specific_risk / total_risk * 100
}
# Расчет вклада каждого фактора
for factor in X.columns:
beta = model.params[factor]
factor_std = X[factor].std() * np.sqrt(252)
factor_contrib = beta * factor_std
factor_contribution[ticker][f'{factor} Contribution'] = factor_contrib
factor_contribution[ticker][f'{factor} Contribution %'] = factor_contrib / total_risk * 100
return {
'Factor Exposures': pd.DataFrame(factor_exposures),
'Specific Returns': pd.DataFrame(specific_returns),
'Factor Contribution': pd.DataFrame(factor_contribution).T
}
# Для факторного анализа нам нужны факторные доходности
# В этом примере используем некоторые ETF как прокси для факторов - индексы Nasdaq-100 и SP500
factor_tickers = ['VCNIX', 'SP2D.DEX']
factor_data = load_price_data(factor_tickers, start_date, end_date)
factor_returns = factor_data.pct_change().dropna()
# Импортируем библиотеку для регрессионного анализа
import statsmodels.api as sm
# Проводим факторный анализ риска
factor_analysis = perform_factor_risk_analysis(daily_returns, factor_returns)
factor_analysis['Factor Contribution']
Рис. 6: Таблица сравнения доходности акций с различными типами (факторами) риска и индексами Nasdaq-100 и SP500
Факторный анализ помогает понять, какая часть риска обусловлена систематическими факторами, а какая — специфическими характеристиками актива. Это особенно полезно при управлении многокомпонентными портфелями.
Спектральные меры риска
Спектральные меры риска обобщают VaR и CVaR, позволяя более гибко учитывать предпочтения инвестора относительно риска. В представленном ниже коде на Python по умолчанию используется экспоненциальная функция риск-аверсии, а lambda_function выступает как функция взвешивания для разных квантилей:
def calculate_spectral_risk_measure(returns, alpha=0.05, lambda_function=None):
if lambda_function is None:
# Экспоненциальная функция риск-аверсии
# Придает больший вес хвостовым событиям
def lambda_function(p, alpha=alpha):
return np.exp(-alpha * p) / (1 - np.exp(-alpha))
results = {}
for ticker in returns.columns:
series = returns[ticker].dropna()
sorted_returns = np.sort(series)
n = len(sorted_returns)
# Вычисляем веса для каждого наблюдения
p_values = np.arange(1, n + 1) / n
weights = np.array([lambda_function(p) for p in p_values])
weights = weights / weights.sum() # Нормализуем веса
# Взвешенное среднее
spectral_risk = np.sum(weights * sorted_returns)
results[ticker] = spectral_risk
return pd.Series(results)
# Расчет спектральных мер риска с разными параметрами риск-аверсии
spectral_risks = {}
alphas = [0.1, 1, 5, 10]
for alpha in alphas:
spectral_risks[f'Spectral Risk (α={alpha})'] = calculate_spectral_risk_measure(daily_returns, alpha=alpha)
spectral_risk_df = pd.DataFrame(spectral_risks)
spectral_risk_df
Рис. 7: Таблица сравнения расчетов спектрального риска акций техногигантов
Спектральные меры риска позволяют более гибко учитывать отношение инвестора к риску, что делает их более реалистичным инструментом управления рисками, чем классические метрики.
Условная оптимизация и стресс-тестирование
Для повышения устойчивости портфеля к экстремальным событиям можно использовать условную оптимизацию и стресс-тестирование. Код на Python, представленный ниже, оптимизирует Risk Parity (Риск-паритет) портфеля — такого портфеля, где каждый актив вносит одинаковый риск в общий риск портфеля.
# Глобальная функция расчета риск-вклада
def calculate_risk_contribution(w, cov):
portfolio_vol = np.sqrt(np.dot(w.T, np.dot(cov, w)))
marginal_contrib = np.dot(cov, w) / portfolio_vol
risk_contrib = w * marginal_contrib
return risk_contrib, portfolio_vol
# Функция построения Risk Parity портфеля
def risk_parity_portfolio(returns, risk_budget=None, max_iterations=1000, tolerance=1e-8):
n_assets = len(returns.columns)
if risk_budget is None:
risk_budget = np.ones(n_assets) / n_assets
else:
risk_budget = np.array(risk_budget) / np.sum(risk_budget)
covariance = returns.cov() * 252
weights = np.ones(n_assets) / n_assets
def objective_function(w, cov, risk_target):
w = np.reshape(w, (n_assets,))
risk_contrib, _ = calculate_risk_contribution(w, cov)
risk_target_contrib = risk_target * np.sqrt(np.dot(w.T, np.dot(cov, w)))
return np.sum((risk_contrib - risk_target_contrib)**2)
constraints = ({'type': 'eq', 'fun': lambda x: np.sum(x) - 1})
bounds = tuple((0, 1) for _ in range(n_assets))
result = minimize(objective_function, weights, args=(covariance, risk_budget),
method='SLSQP', bounds=bounds, constraints=constraints,
tol=tolerance, options={'maxiter': max_iterations})
optimal_weights = result['x']
risk_contrib, portfolio_vol = calculate_risk_contribution(optimal_weights, covariance)
assets = returns.columns
result_df = pd.DataFrame({
'Asset': assets,
'Weight': optimal_weights,
'Risk Contribution': risk_contrib,
'Risk Contribution (%)': risk_contrib / np.sum(risk_contrib) * 100
})
return {
'Weights': dict(zip(assets, optimal_weights)),
'Risk Contributions': dict(zip(assets, risk_contrib)),
'Portfolio Volatility': portfolio_vol,
'Summary': result_df
}
# Создание портфеля с равным риск-вкладом
risk_parity_result = risk_parity_portfolio(daily_returns)
print("\nПортфель с равным риск-вкладом:")
print(risk_parity_result['Summary'])
# Портфель с равными весами
equal_weight = np.ones(len(daily_returns.columns)) / len(daily_returns.columns)
equal_weight_risk_contrib, equal_weight_vol = calculate_risk_contribution(equal_weight, daily_returns.cov() * 252)
equal_weight_df = pd.DataFrame({
'Asset': daily_returns.columns,
'Weight': equal_weight,
'Risk Contribution': equal_weight_risk_contrib,
'Risk Contribution (%)': equal_weight_risk_contrib / np.sum(equal_weight_risk_contrib) * 100
})
print("\nПортфель с равными весами:")
print(equal_weight_df)
# Визуализация весов и риск-вкладов
fig, axes = plt.subplots(2, 2, figsize=(16, 12))
# Веса — Risk Parity
axes[0, 0].bar(risk_parity_result['Summary']['Asset'], risk_parity_result['Summary']['Weight'])
axes[0, 0].set_title('Веса активов (Risk Parity)')
axes[0, 0].set_ylabel('Вес')
# Риск-вклады — Risk Parity
axes[0, 1].bar(risk_parity_result['Summary']['Asset'], risk_parity_result['Summary']['Risk Contribution (%)'])
axes[0, 1].set_title('Риск-вклады активов (Risk Parity)')
axes[0, 1].set_ylabel('Риск-вклад (%)')
# Веса — Equal Weight
axes[1, 0].bar(equal_weight_df['Asset'], equal_weight_df['Weight'])
axes[1, 0].set_title('Веса активов (Equal Weight)')
axes[1, 0].set_ylabel('Вес')
# Риск-вклады — Equal Weight
axes[1, 1].bar(equal_weight_df['Asset'], equal_weight_df['Risk Contribution (%)'])
axes[1, 1].set_title('Риск-вклады активов (Equal Weight)')
axes[1, 1].set_ylabel('Риск-вклад (%)')
plt.tight_layout()
plt.show()
Портфель с равным риск-вкладом:
Asset Weight Risk Contribution Risk Contribution (%)
0 AAPL 0.268528 0.061576 19.980314
1 MSFT 0.263104 0.061710 20.023916
2 AMZN 0.145425 0.061673 20.011905
3 GOOGL 0.154758 0.061586 19.983648
4 META 0.168185 0.061637 20.000216
Портфель с равными весами:
Asset Weight Risk Contribution Risk Contribution (%)
0 AAPL 0.2 0.042114 12.652814
1 MSFT 0.2 0.044069 13.240158
2 AMZN 0.2 0.091041 27.352477
3 GOOGL 0.2 0.083217 25.001826
4 META 0.2 0.072402 21.752726
Рис. 8: Графики распределения весов активов в портфеле и риск-вкладов
Условная оптимизация позволяет создавать портфели, которые лучше работают в определенных рыночных условиях, что особенно важно для управления риском в периоды рыночных стрессов.
Моделирование временной структуры риска
Временная структура риска показывает, как меняется риск в зависимости от горизонта инвестирования. Это критически важно для стратегического распределения активов.
Масштабирование риска во времени
Классический подход предполагает, что волатильность растет пропорционально квадратному корню из времени, но реальные данные часто показывают более сложные паттерны. Код, представленный дальше, анализирует, как меняется риск с увеличением горизонта инвестирования.
def analyze_time_scaling_of_risk(returns, max_horizon=252):
horizons = [1, 5, 20, 60, 126, 252]
horizons = [h for h in horizons if h <= max_horizon]
risk_metrics = {}
for ticker in returns.columns:
ticker_metrics = {}
for horizon in horizons:
# Агрегируем доходности за выбранный горизонт
if horizon == 1:
period_returns = returns[ticker]
else:
# Используем перекрывающиеся периоды для увеличения количества наблюдений
period_returns = returns[ticker].rolling(window=horizon).sum()
period_returns = period_returns.dropna()
# Рассчитываем метрики риска
volatility = period_returns.std()
var_95 = np.percentile(period_returns, 5)
cvar_95 = period_returns[period_returns <= var_95].mean()
ticker_metrics[horizon] = {
'Volatility': volatility,
'VaR 95%': var_95,
'CVaR 95%': cvar_95,
'Scaled Volatility': volatility / np.sqrt(horizon),
'Scaled VaR 95%': var_95 / np.sqrt(horizon),
'Scaled CVaR 95%': cvar_95 / np.sqrt(horizon)
}
risk_metrics[ticker] = ticker_metrics
# Создаем DataFrame для анализа масштабирования волатильности
vol_scaling = pd.DataFrame({
ticker: [metrics[h]['Volatility'] for h in horizons]
for ticker, metrics in risk_metrics.items()
}, index=horizons)
scaled_vol = pd.DataFrame({
ticker: [metrics[h]['Scaled Volatility'] for h in horizons]
for ticker, metrics in risk_metrics.items()
}, index=horizons)
# Визуализация
plt.figure(figsize=(14, 8))
plt.subplot(1, 2, 1)
vol_scaling.plot(logy=True, logx=True, ax=plt.gca())
plt.title('Масштабирование волатильности с горизонтом', fontsize=13)
plt.xlabel('Горизонт (дни, лог-шкала)')
plt.ylabel('Волатильность (лог-шкала)')
plt.subplot(1, 2, 2)
scaled_vol.plot(logx=True, ax=plt.gca())
plt.title('Нормализованная волатильность', fontsize=13)
plt.xlabel('Горизонт (дни, лог-шкала)')
plt.ylabel('Волатильность / sqrt(horizon)')
plt.tight_layout()
plt.show()
return {
'Risk Metrics': risk_metrics,
'Volatility Scaling': vol_scaling,
'Normalized Volatility': scaled_vol
}
# Анализ временной структуры риска
time_scaling = analyze_time_scaling_of_risk(daily_returns)
Рис. 9: Графики масштабирования волатильности и нормализованной волатильности акций техногигантов
Отклонение от квадратного корня указывает на присутствие долговременных зависимостей в данных, что важно учитывать при долгосрочном инвестировании.
Моделирование долговременных зависимостей
Для более точного моделирования долговременных зависимостей можно использовать показатель Херста и фрактальные модели:
def calculate_hurst_exponent(time_series, max_lag=100):
"""
Рассчитывает показатель Херста, который измеряет долговременную память временного ряда
"""
lags = range(2, max_lag)
tau = [np.std(np.subtract(time_series[lag:], time_series[:-lag])) for lag in lags]
# Регрессия на логарифмических значениях
m = np.polyfit(np.log(lags), np.log(tau), 1)
hurst = m[0] / 2 # Наклон = 2*H
return hurst
def analyze_long_memory(returns):
"""
Анализирует наличие долговременной памяти в доходностях активов
"""
results = {}
for ticker in returns.columns:
# Рассчитываем показатель Херста для доходностей
hurst_returns = calculate_hurst_exponent(returns[ticker].dropna().values)
# Рассчитываем показатель Херста для абсолютных доходностей (прокси для волатильности)
hurst_volatility = calculate_hurst_exponent(np.abs(returns[ticker].dropna().values))
# Рассчитываем автокорреляцию
acf_returns = pd.Series(returns[ticker]).autocorr(lag=1)
acf_abs_returns = pd.Series(np.abs(returns[ticker])).autocorr(lag=1)
results[ticker] = {
'Hurst Exponent (Returns)': hurst_returns,
'Hurst Exponent (Volatility)': hurst_volatility,
'Autocorrelation (Returns)': acf_returns,
'Autocorrelation (Volatility)': acf_abs_returns
}
results_df = pd.DataFrame(results).T
# Интерпретация показателя Херста
results_df['Memory Type (Returns)'] = results_df['Hurst Exponent (Returns)'].apply(
lambda h: 'Mean-reverting' if h < 0.5 else ('Random Walk' if h == 0.5 else 'Trend-following'))
results_df['Memory Type (Volatility)'] = results_df['Hurst Exponent (Volatility)'].apply(
lambda h: 'Mean-reverting' if h < 0.5 else ('Random Walk' if h == 0.5 else 'Trend-following'))
return results_df
# Анализ долговременной памяти
long_memory_analysis = analyze_long_memory(daily_returns)
print("Анализ долговременной памяти:")
long_memory_analysis
Рис. 10: Таблица результатов анализа долговременных зависимостей (экспоненты Херста, автокорреляций) биржевых активов
Показатель Херста помогает определить характер временного ряда: значения близкие к 0.5 указывают на случайное блуждание, значения выше 0.5 — на трендоследование, а значения ниже 0.5 — на возврат к среднему. Это особенно важно для долгосрочных стратегий инвестирования.
Машинное обучение для оценки риска
Современные методы машинного обучения позволяют более точно моделировать сложные зависимости и прогнозировать риск.
Моделирование волатильности с помощью GARCH и его модификаций
Для моделирования условной волатильности можно использовать модели семейства GARCH. Вот как можно написать реализовать подгонку модели GARCH(p,q) к временному ряду доходностей:
import sys
import subprocess
import importlib
# Функция для установки пакета, если его нет
def install(package):
subprocess.check_call([sys.executable, "-m", "pip", "install", "--quiet", package])
# Импортируем библиотеку arch
try:
from arch import arch_model
except ImportError:
print("Библиотека 'arch' не найдена. Устанавливаем...")
install('arch')
from arch import arch_model
# Теперь можно использовать arch_model
def fit_garch_model(returns, p=1, q=1):
results = {}
for ticker in returns.columns:
series = returns[ticker].dropna() * 100 # Масштабируем для численной стабильности
try:
# Создаем и обучаем модель GARCH
model = arch_model(series, p=p, q=q, mean='Constant', vol='GARCH', dist='normal')
model_fit = model.fit(disp='off')
# Прогнозируем волатильность на 20 дней вперед
forecasts = model_fit.forecast(horizon=20)
forecast_vol = np.sqrt(forecasts.variance.iloc[-1].values) / 100 # Обратно к исходному масштабу
results[ticker] = {
'Model': model_fit,
'Parameters': model_fit.params,
'Volatility Forecast': forecast_vol
}
except Exception as e:
print(f"Не удалось подогнать модель GARCH для {ticker}: {str(e)}")
return results
# Вызов функции
garch_results = fit_garch_model(daily_returns)
if garch_results:
# Создаем DataFrame с прогнозами
forecast_df = pd.DataFrame({
ticker: result['Volatility Forecast']
for ticker, result in garch_results.items()
}, index=range(1, 21))
print("Прогноз волатильности на следующие 20 дней:")
print(forecast_df)
# Визуализация прогноза
plt.figure(figsize=(12, 6))
forecast_df.plot(title="Прогноз волатильности на 20 дней вперед", xlabel="Дни", ylabel="Волатильность")
plt.legend(title="Актив")
plt.grid(True)
plt.tight_layout()
plt.show()
else:
print("Не удалось построить ни одну GARCH-модель.")
Прогноз волатильности на следующие 20 дней:
AAPL MSFT AMZN GOOGL META
1 0.032987 0.023834 0.036659 0.039391 0.022705
2 0.032864 0.023814 0.036946 0.039391 0.022700
3 0.032742 0.023794 0.037231 0.039391 0.022696
4 0.032622 0.023775 0.037514 0.039391 0.022691
5 0.032502 0.023755 0.037794 0.039391 0.022687
6 0.032383 0.023736 0.038073 0.039391 0.022682
7 0.032266 0.023717 0.038349 0.039391 0.022678
8 0.032149 0.023697 0.038624 0.039391 0.022673
9 0.032033 0.023678 0.038896 0.039391 0.022669
10 0.031918 0.023659 0.039167 0.039391 0.022665
11 0.031804 0.023640 0.039436 0.039391 0.022660
12 0.031692 0.023621 0.039703 0.039391 0.022656
13 0.031580 0.023602 0.039968 0.039391 0.022652
14 0.031469 0.023583 0.040231 0.039391 0.022647
15 0.031359 0.023564 0.040493 0.039391 0.022643
16 0.031250 0.023545 0.040753 0.039391 0.022639
17 0.031141 0.023526 0.041012 0.039391 0.022634
18 0.031034 0.023508 0.041268 0.039391 0.022630
19 0.030928 0.023489 0.041524 0.039391 0.022626
20 0.030822 0.023471 0.041777 0.039391 0.022622
Рис. 11: Прогноз волатильности акций на 20 дней вперед
Модели GARCH хорошо улавливают кластеризацию волатильности и асимметричные эффекты на финансовых рынках, что делает их полезными для краткосрочного прогнозирования риска.
Нейронные сети для прогнозирования риска
Для улавливания нелинейных зависимостей и сложных паттернов можно использовать нейронные сети:
def create_features_for_risk_prediction(returns, lookback=20):
"""
Создает признаки для прогнозирования волатильности с помощью машинного обучения
"""
features = pd.DataFrame(index=returns.index)
# Для каждого актива создаем набор признаков
for ticker in returns.columns:
# Скользящие статистики
features[f'{ticker}_rolling_vol'] = returns[ticker].rolling(lookback).std()
features[f'{ticker}_rolling_mean'] = returns[ticker].rolling(lookback).mean()
features[f'{ticker}_rolling_skew'] = returns[ticker].rolling(lookback).skew()
features[f'{ticker}_rolling_kurt'] = returns[ticker].rolling(lookback).kurt()
# Лаги доходностей
for lag in [1, 5, 10]:
features[f'{ticker}_lag_{lag}'] = returns[ticker].shift(lag)
# Лаги абсолютных доходностей (прокси для волатильности)
for lag in [1, 5, 10]:
features[f'{ticker}_abs_lag_{lag}'] = np.abs(returns[ticker]).shift(lag)
# Дневной размах (high-low)
if isinstance(returns, pd.DataFrame) and 'High' in returns.columns and 'Low' in returns.columns:
features[f'{ticker}_hl_range'] = (returns['High'] / returns['Low'] - 1)
# Удаляем строки с пропущенными значениями
features = features.dropna()
return features
def prepare_target_for_risk_prediction(returns, horizon=20, method='realized_vol'):
"""
Подготавливает целевую переменную для прогнозирования риска
"""
targets = pd.DataFrame(index=returns.index)
if method == 'realized_vol':
# Используем реализованную волатильность как целевую переменную
for ticker in returns.columns:
realized_vol = returns[ticker].rolling(horizon).std().shift(-horizon)
targets[ticker] = realized_vol
elif method == 'abs_return':
# Прогнозируем абсолютную доходность за горизонт
for ticker in returns.columns:
future_abs_return = np.abs(returns[ticker].rolling(horizon).sum().shift(-horizon))
targets[ticker] = future_abs_return
elif method == 'var_95':
# Прогнозируем 5%-ный Value at Risk (VaR)
for ticker in returns.columns:
rolling_var = returns[ticker].rolling(horizon).quantile(0.05)
targets[ticker] = rolling_var.shift(-horizon)
else:
raise ValueError(f"Неизвестный метод для целевой переменной: {method}")
# Удаляем NaN
targets = targets.dropna()
return targets
Что делает эта функция? Она готовит целевую переменную, которую мы хотим предсказывать:
- realized_vol — будущая волатильность (стандартное отклонение доходностей);
- abs_return — абсолютная доходность;
- var_95 — Value-at-Risk (квантиль распределения доходностей).
Теперь напишем функцию построения и обучения нейросети с использованием Keras / TensorFlow.
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import mean_squared_error, mean_absolute_error
import matplotlib.pyplot as plt
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, Dropout
from tensorflow.keras.callbacks import EarlyStopping
def train_neural_network_risk_model(features, target, test_size=0.2, epochs=50, batch_size=32):
"""
Обучает нейросеть для прогнозирования риска
"""
# Разделение на обучающую и тестовую выборки
X_train, X_test, y_train, y_test = train_test_split(
features, target, test_size=test_size, shuffle=False
)
# Нормализация данных
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
# Архитектура модели
model = Sequential([
Dense(128, activation='relu', input_shape=(X_train_scaled.shape[1],)),
Dropout(0.2),
Dense(64, activation='relu'),
Dropout(0.2),
Dense(len(target.columns)) # Прогнозируем риск по всем активам
])
model.compile(optimizer='adam', loss='mse')
# Ранняя остановка
early_stop = EarlyStopping(monitor='val_loss', patience=10, restore_best_weights=True)
# Обучение
history = model.fit(
X_train_scaled, y_train,
validation_split=0.2,
epochs=epochs,
batch_size=batch_size,
callbacks=[early_stop],
verbose=0
)
# Предсказание
y_pred = model.predict(X_test_scaled)
# Оценка
mse = mean_squared_error(y_test, y_pred)
mae = mean_absolute_error(y_test, y_pred)
print(f"\nМодель обучена. Оценка на тестовой выборке:")
print(f"MSE: {mse:.6f}, MAE: {mae:.6f}")
# Визуализация потерь
plt.figure(figsize=(10, 4))
plt.plot(history.history['loss'], label='Обучение')
plt.plot(history.history['val_loss'], label='Валидация')
plt.title('Потери нейросети во время обучения')
plt.xlabel('Эпохи')
plt.ylabel('Loss')
plt.legend()
plt.grid(True)
plt.show()
# DataFrame с реальными и предсказанными значениями
pred_df = pd.DataFrame(
y_pred, index=y_test.index, columns=[f'{col}_pred' for col in target.columns]
)
result_df = pd.concat([y_test.reset_index(drop=True), pd.DataFrame(pred_df.reset_index(drop=True))], axis=1)
return {
'model': model,
'scaler': scaler,
'history': history,
'predictions': pred_df,
'metrics': {'MSE': mse, 'MAE': mae}
}
Теперь запускаем обучение нейросети.
# Создаем признаки и целевую переменную
features = create_features_for_risk_prediction(daily_returns)
target = prepare_target_for_risk_prediction(daily_returns, horizon=20, method='realized_vol')
# Объединяем для удобства
data = features.join(target, how='inner')
# Выделяем X и y
X = data[features.columns]
y = data[target.columns]
# Обучаем модель
nn_results = train_neural_network_risk_model(X, y, epochs=100, batch_size=64)
Модель обучена. Оценка на тестовой выборке:
MSE: 0.000167, MAE: 0.009840
Рис. 12: График потерь (loss) нейросети во время обучения
Этот код реализует полный пайплайн прогнозирования риска (волатильности) финансовых активов с помощью нейронных сетей. Он состоит из 3-х основных частей:
Формирование признаков (create_features_for_risk_prediction)
Для каждого актива на основе исторических данных о доходностях строятся различные временные признаки, которые могут помочь предсказать будущую волатильность.
Среди них — скользящие средние, стандартные отклонения, асимметрия и эксцесс за определённый период (lookback), лаговые значения доходностей и их абсолютные величины, а также прокси для размаха цены (high-low). Эти признаки помогают модели улавливать как краткосрочные, так и долгосрочные закономерности в поведении рынка.
Подготовка целевой переменной (prepare_target_for_risk_prediction)
Эта функция создает целевую переменную, которую модель будет предсказывать. Она может быть настроена на три варианта:
- Реализованная волатильность (стандартное отклонение доходностей за заданный горизонт);
- Абсолютная доходность (общее изменение цены за период);
- Value at Risk (VaR) на уровне 5%-квантиля.
Таким образом, модель обучается предсказывать не просто цену, а меру риска, что особенно важно в задачах управления портфелем и риск-менеджменте.
Обучение нейросетевой модели (train_neural_network_risk_model)
После подготовки обучающих данных запускается процесс обучения искусственной нейронной сети (ANN) с использованием фреймворка TensorFlow/Keras.
Модель имеет несколько полносвязных слоев, включает Dropout для борьбы с переобучением, использует Adam-оптимизатор и MSE (среднеквадратичную ошибку) в качестве функции потерь. Данные нормализуются перед обучением, используется ранняя остановка для выбора лучшей версии модели.
После обучения выводятся метрики качества — MSE и MAE, а также график потерь во время обучения. В итоге модель позволяет делать прогнозы по всем активам одновременно, что делает ее удобной для анализа портфеля.
В совокупности этот код представляет собой современное решение задачи прогнозирования рыночного риска с использованием методов машинного обучения. Он может применяться в инвестиционном анализе, управлении активами и алгоритмической торговле. Подходит как для исследовательских целей, так и для интеграции в автоматизированные системы принятия решений.
Заключение
В этой статье мы рассмотрели ключевые аспекты анализа доходности и риска биржевой торговли с использованием Python. Начиная с базовых метрик, таких как волатильность, VaR и коэффициенты Шарпа, мы перешли к более сложным методам, включая спектральные меры риска, факторный анализ и моделирование временной структуры. Особое внимание было уделено продвинутым подходам, таким как GARCH-модели, нейронные сети и риск-паритетные портфели, которые позволяют более точно оценивать и управлять рыночными рисками.
Ключевые выводы:
- Важен комплексный подход к расчету доходности — важно анализировать не только абсолютную доходность, но и ее соотношение с риском, сравнивать с бенчмарками и учитывать временную структуру;
- Нужно учитывать многогранность риска — классические метрики, такие как стандартное отклонение, часто недостаточны. Современные методы (CVaR, EVT, факторные модели) дают более реалистичную оценку, особенно в условиях рыночных стрессов;
- Используем машинное обучение и эконометрику, поскольку инструменты вроде GARCH и нейросетей позволяют улавливать нелинейные зависимости и улучшать прогнозы волатильности;
- Оптимизируем портфель риск-паритетными стратегиями. Такие стратегии, плюс условная оптимизация помогают создавать более устойчивые портфели, минимизируя влияние экстремальных событий.
Практические рекомендации:
- Используйте комбинацию метрик. Ни один показатель не даст полной картины. Например, коэффициент Сортино полезнее Шарпа для оценки асимметричного риска;
- Тестируйте модели на разных данных. Особенно важно проверять их в периоды кризисов (например, 2008 или 2020 год).
- Учитывайте транзакционные издержки. Поскольку даже самая точная модель может оказаться убыточной после вычета комиссий.
- Автоматизируйте анализ. Язык программирования Python позволяет быстро адаптировать расчеты под новые данные, что критически важно для активного управления.
Финансовые рынки остаются сложной и динамичной средой, однако современные методы анализа, реализованные в Python, значительно повышают прозрачность и обоснованность инвестиционных решений.