Ad hoc задачи в финансовой аналитике

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

Ad hoc можно перевести с латинского как «для этого», «для данного случая». То есть это прикладной и точечный анализ. В финансовой аналитике такие задачи возникают практически ежедневно. Вот несколько типичных ситуаций, с которыми я сталкиваюсь:

  1. Анализ аномального поведения акций компании после публикации неожиданных финансовых результатов;
  2. Оценка влияния редкого макроэкономического события на портфель инвестиций;
  3. Расследование необычных паттернов в торговых алгоритмах;
  4. Прогнозирование влияния регуляторных изменений на ликвидность рынка;
  5. Моделирование последствий слияния компаний для оценки их рыночной капитализации.

Важность ad hoc задач сложно переоценить:

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

При этом ad hoc анализ ставит перед аналитиком ряд серьезных вызовов. Это и необходимость быстро погружаться в новые предметные области, и умение работать с неструктурированными и неполными данными, и способность разрабатывать методологию «на лету», и навык убедительно представлять результаты даже при наличии высокой степени неопределенности.

Методологические подходы к решению ad hoc задач

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

Правильная формулировка задачи

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

  1. Выявить истинную бизнес-потребность, стоящую за запросом. Часто клиенты или руководители формулируют вопрос в терминах доступных им инструментов, а не реальной проблемы;
  2. Декомпозировать общую задачу на подзадачи, каждая из которых имеет четкие критерии успеха;
  3. Определить требуемый уровень точности и достоверности результатов;
  4. Согласовать временные и ресурсные ограничения;
  5. Уточнить формат представления результатов и ключевые метрики.

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

Заметьте разницу — вторая формулировка содержит конкретный инструмент измерения (VIX), временной горизонт (30 минут), методологический подход (сентимент-анализ) и контекстные факторы. Такая задача уже поддается структурированному решению.

Исследовательский анализ данных (EDA)

После формулировки задачи необходимо провести тщательный исследовательский анализ доступных данных. В контексте ad hoc задач этот этап особенно важен, поскольку мы часто работаем с новыми, необычными или комбинированными источниками данных.

Эффективный EDA включает:

  1. Проверку качества и полноты данных, выявление выбросов и аномалий;
  2. Анализ распределений ключевых переменных и их взаимозависимостей;
  3. Визуализацию данных с фокусом на выявление неочевидных паттернов;
  4. Проверку гипотез о структуре и свойствах данных;
  5. Создание производных признаков, которые могут обладать большей прогностической силой.

Вот пример простого, но эффективного EDA-скрипта, который я использую для первичного анализа финансовых временных рядов:

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import yfinance as yf
from scipy import stats
import statsmodels.api as sm
from statsmodels.graphics.tsaplots import plot_acf, plot_pacf
pd.set_option('display.expand_frame_repr', False)

# Загрузка данных
ticker = "RIG"  # Transocean Ltd., компания в нефтегазовом секторе
start_date = "2022-07-01"
end_date = "2025-07-01"

# Получение данных
df = yf.download(ticker, start=start_date, end=end_date)

# Проверка на наличие MultiIndex и правильное упрощение структуры
if isinstance(df.columns, pd.MultiIndex):
    # Сохраняем названия колонок из первого уровня MultiIndex
    df.columns = df.columns.get_level_values(0)

# Проверяем, что данные не пустые
if df.empty:
    raise ValueError(f"Данные для тикера {ticker} не найдены")

# Ищем колонку с ценой закрытия
close_column = None
for col in df.columns:
    if 'close' in col.lower():
        close_column = col
        break

if close_column is None:
    # Если не нашли Close, попробуем взять первую числовую колонку
    numeric_columns = df.select_dtypes(include=[np.number]).columns
    if len(numeric_columns) > 0:
        close_column = numeric_columns[0]
    else:
        raise ValueError("Не найдена подходящая колонка с ценами")

# Базовая статистика
returns = df[close_column].pct_change().dropna()
log_returns = np.log(df[close_column]).diff().dropna()

# Создадим датафрейм для анализа
analysis_df = pd.DataFrame(index=df.index)
analysis_df['Price'] = df[close_column]
analysis_df['Returns'] = returns
analysis_df['Log_Returns'] = log_returns
analysis_df['Volatility'] = returns.rolling(21).std() * np.sqrt(252)  # Годовая волатильность на 21-дневном окне

# Исследование основных статистик
stats_df = analysis_df.describe().T
stats_df['skew'] = analysis_df.skew()
stats_df['kurtosis'] = analysis_df.kurtosis()

# Безопасное вычисление статистики Харке-Бера
def safe_jarque_bera(series):
    try:
        clean_series = series.dropna()
        if len(clean_series) < 3:  # Минимум данных для теста
            return np.nan, np.nan
        return stats.jarque_bera(clean_series)
    except:
        return np.nan, np.nan

stats_df['JB_stat'] = [safe_jarque_bera(analysis_df[col])[0] for col in analysis_df.columns]
stats_df['JB_pvalue'] = [safe_jarque_bera(analysis_df[col])[1] for col in analysis_df.columns]

# Проверка стационарности для временных рядов
def adf_test(series):
    try:
        clean_series = series.dropna()
        if len(clean_series) < 10:  # Минимум данных для теста
            return pd.Series({
                'ADF_Stat': np.nan,
                'P-Value': np.nan,
                'Is_Stationary': False
            })
        result = sm.tsa.stattools.adfuller(clean_series)
        return pd.Series({
            'ADF_Stat': result[0],
            'P-Value': result[1],
            'Is_Stationary': result[1] < 0.05 }) except: return pd.Series({ 'ADF_Stat': np.nan, 'P-Value': np.nan, 'Is_Stationary': False }) # Применяем тест ADF к каждому ряду stationarity_results = pd.DataFrame({ col: adf_test(analysis_df[col]) for col in analysis_df.columns }).T # Визуализация plt.figure(figsize=(14, 10)) # График цены и волатильности ax1 = plt.subplot(2, 2, 1) ax1.plot(df.index, df[close_column], color='black', linewidth=1.5) ax1.set_title(f'{ticker} Price', fontsize=12) ax1.set_ylabel('Price') ax1_2 = ax1.twinx() volatility_clean = analysis_df['Volatility'].dropna() ax1_2.plot(volatility_clean.index, volatility_clean, color='darkgray', linestyle='--') ax1_2.set_ylabel('Volatility (Annualized)') # Распределение доходностей ax2 = plt.subplot(2, 2, 2) sns.histplot(log_returns, kde=True, color='black', ax=ax2) ax2.set_title('Log Returns Distribution', fontsize=12) x = np.linspace(log_returns.min(), log_returns.max(), 100) ax2.plot(x, stats.norm.pdf(x, log_returns.mean(), log_returns.std()), color='darkgray', linestyle='--') ax2.text(0.05, 0.95, f'Skew: {log_returns.skew():.2f}\nKurtosis: {log_returns.kurtosis():.2f}', transform=ax2.transAxes, verticalalignment='top') # Автокорреляция доходностей ax3 = plt.subplot(2, 2, 3) if len(log_returns) > 40:
    plot_acf(log_returns, lags=40, alpha=0.05, ax=ax3)
else:
    plot_acf(log_returns, lags=min(len(log_returns)//4, 10), alpha=0.05, ax=ax3)
ax3.set_title('Log Returns Autocorrelation', fontsize=12)

# Автокорреляция квадратов доходностей (волатильность)
ax4 = plt.subplot(2, 2, 4)
squared_returns = log_returns**2
if len(squared_returns) > 40:
    plot_acf(squared_returns, lags=40, alpha=0.05, ax=ax4)
else:
    plot_acf(squared_returns, lags=min(len(squared_returns)//4, 10), alpha=0.05, ax=ax4)
ax4.set_title('Squared Log Returns Autocorrelation', fontsize=12)

plt.tight_layout()
plt.show()

# Вывод результатов статистик
print("\nБазовая статистика:")
print(stats_df)
print("\nРезультаты теста на стационарность:")
print(stationarity_results)

Анализ временного ряда акций Transocean Ltd. (RIG) за последние 3 года: (а) динамика цены и годовая волатильность, (б) распределение логарифмических доходностей в сравнении с нормальным распределением, (в) автокорреляционная функция логарифмических доходностей, (г) автокорреляционная функция квадратов логарифмических доходностей, демонстрирующая кластеризацию волатильности

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

Базовая статистика:
             count      mean       std       min       25%       50%       75%       max      skew  kurtosis     JB_stat     JB_pvalue
Price        751.0  5.078695  1.663699  2.130000  3.730000  5.150000  6.240000  8.800000  0.202295 -0.815046   26.019729  2.238142e-06
Returns      750.0  0.000384  0.037976 -0.202206 -0.022685 -0.002445  0.022209  0.161491  0.150846  1.942192  118.186490  2.168352e-26
Log_Returns  750.0 -0.000335  0.037974 -0.225905 -0.022946 -0.002448  0.021966  0.149704 -0.084508  2.361768  171.718452  5.150056e-38
Volatility   730.0  0.569400  0.183870  0.291336  0.444966  0.534845  0.647039  1.227058  1.317783  2.054832  336.074940  1.052577e-73

Результаты теста на стационарность:
             ADF_Stat   P-Value Is_Stationary
Price       -1.498295  0.534354         False
Returns     -6.499776       0.0          True
Log_Returns  -6.54401       0.0          True
Volatility  -4.179225  0.000714          True

Этот код выполняет несколько ключевых аналитических операций:

  1. Загружает данные для тикера RIG (Transocean Ltd.) с помощью библиотеки yfinance и проверяет структуру индексов;
  2. Рассчитывает обычные и логарифмические доходности, а также оценивает волатильность;
  3. Проводит статистический анализ, включая расчет асимметрии, эксцесса и тест Жарке-Бера на нормальность;
  4. Выполняет тест Дики-Фуллера для проверки стационарности временных рядов;
  5. Строит информативные визуализации, включая графики цены и волатильности, распределения доходностей и автокорреляционные функции
👉🏻  Как анализировать финансовые коэффициенты для выбора перспективных активов?

Результаты такого анализа уже дают серьезную пищу для размышлений. Анализ акций Transocean Ltd. (RIG) демонстрирует типичное поведение биржевых временных рядов. Цена демонстрирует нестационарное поведение (ADF p-value = 0.534), подтверждая гипотезу почти случайного блуждания цен, в то время как доходности являются стационарными процессами. Средняя дневная логарифмическая доходность практически нулевая (-0.0003%), что согласуется с теорией эффективного рынка, однако высокая дневная волатильность 3.8% отражает специфические риски энергетического сектора.

Распределение доходностей значительно отклоняется от нормального (статистика Харке-Бера 171.7 с p-value ≈ 0), демонстрируя отрицательную асимметрию (-0.085) и избыточный эксцесс (2.36). Это указывает на повышенную вероятность резких падений по сравнению с ростом и существенно более частые экстремальные события, чем предсказывает гауссова модель. Годовая волатильность в среднем составляет 57% с выраженной кластеризацией, что характерно для высокорисковых активов нефтегазового сектора.

Полученные результаты имеют важные практические последствия для риск-менеджмента и портфельного управления:

  • Стандартные модели, основанные на предположении нормальности, будут систематически недооценивать хвостовые риски, особенно вероятность значительных потерь;
  • Наличие кластеризации волатильности и толстых хвостов делает целесообразным применение GARCH-моделей для прогнозирования условной волатильности;
  • Высокая волатильность RIG создает как существенные риски для консервативных инвесторов, так и потенциальные возможности для активных торговых стратегий, способных извлекать выгоду из ценовых дисбалансов в периоды повышенной неопределенности на энергетических рынках.

Выбор подходящих методов и моделей

После тщательного исследования данных необходимо выбрать наиболее подходящие методы анализа. В контексте ad hoc задач это требует гораздо более гибкого подхода, чем при решении стандартных проблем. Вместо того, чтобы сразу применять популярные методы вроде ARIMA или линейной регрессии, я рекомендую:

  1. Провести бенчмаркинг нескольких принципиально разных методологических подходов;
  2. Оценить не только точность моделей, но и их интерпретируемость в контексте задачи;
  3. Рассмотреть возможность комбинирования различных моделей;
  4. Адаптировать модели под специфику имеющихся данных.

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

Техники обработки и подготовки данных для ad hoc анализа

В основе любого качественного финансового анализа лежит правильная обработка и подготовка данных. Для ad hoc задач этот этап приобретает особую важность из-за нестандартного характера исходных данных.

Работа с неструктурированными финансовыми данными

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

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

import pandas as pd
import numpy as np
import requests
from bs4 import BeautifulSoup
import re
import nltk
from nltk.corpus import stopwords
from nltk.tokenize import word_tokenize
from nltk.stem import WordNetLemmatizer
import yfinance as yf
# Загружаем необходимые ресурсы NLTK
try:
nltk.download('punkt')
nltk.download('punkt_tab')
nltk.download('stopwords')
nltk.download('wordnet')
nltk.download('omw-1.4')
except Exception as e:
print(f"Ошибка загрузки NLTK ресурсов: {e}")
class FinancialTextProcessor:
def __init__(self):
try:
self.stop_words = set(stopwords.words('english'))
except LookupError:
# Если NLTK стоп-слова недоступны, используем базовый набор
self.stop_words = {'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 
'from', 'has', 'he', 'in', 'is', 'it', 'its', 'of', 'on', 
'that', 'the', 'to', 'was', 'were', 'will', 'with'}
self.additional_stops = {'company', 'quarter', 'year', 'million', 'billion', 'dollar'}
self.stop_words.update(self.additional_stops)
try:
self.lemmatizer = WordNetLemmatizer()
except LookupError:
self.lemmatizer = None
def clean_text(self, text):
"""Очистка и нормализация текста"""
if not isinstance(text, str):
return ""
# Удаление HTML-тегов
text = BeautifulSoup(text, "html.parser").get_text()
# Удаление специальных символов и цифр
text = re.sub(r'[^\w\s]', '', text)
text = re.sub(r'\d+', '', text)
# Токенизация с fallback на простое разбиение
try:
tokens = word_tokenize(text.lower())
except LookupError:
tokens = text.lower().split()
# Удаление стоп-слов и лемматизация
filtered_tokens = []
for word in tokens:
if word not in self.stop_words and len(word) > 2:
if self.lemmatizer:
try:
word = self.lemmatizer.lemmatize(word)
except:
pass  # Если лемматизация не работает, оставляем слово как есть
filtered_tokens.append(word)
return ' '.join(filtered_tokens)
def extract_financial_entities(self, text):
"""Извлечение финансовых сущностей из текста"""
# Улучшенные паттерны для поиска финансовых показателей
revenue_pattern = r'revenue.{0,50}(\$\s*\d+\.?\d*\s*(million|billion))'
eps_pattern = r'earnings per share.{0,30}(\$\s*\d+\.\d+)'
guidance_pattern = r'guidance.{0,50}(\$\s*\d+\.?\d*\s*(million|billion))'
beat_pattern = r'beat.{0,30}(\$\s*\d+\.\d+)'
estimate_pattern = r'estimate.{0,30}(\$\s*\d+\.\d+)'
# Поиск всех упоминаний денежных сумм
money_pattern = r'\$\s*(\d+\.?\d*)\s*(million|billion|\.?\d+)?'
revenue_matches = re.findall(revenue_pattern, text.lower())
eps_matches = re.findall(eps_pattern, text.lower())
guidance_matches = re.findall(guidance_pattern, text.lower())
beat_matches = re.findall(beat_pattern, text.lower())
estimate_matches = re.findall(estimate_pattern, text.lower())
all_money_mentions = re.findall(money_pattern, text.lower())
return {
'revenue_mentions': [match[0] if isinstance(match, tuple) else match for match in revenue_matches],
'eps_mentions': eps_matches,
'guidance_mentions': [match[0] if isinstance(match, tuple) else match for match in guidance_matches],
'beat_mentions': beat_matches,
'estimate_mentions': estimate_matches,
'all_money_mentions': [f"${match[0]} {match[1]}" if match[1] else f"${match[0]}" 
for match in all_money_mentions][:10]  # Ограничиваем до 10 первых
}
def sentiment_analysis_simple(self, text):
"""Простой анализ тональности на основе ключевых слов"""
positive_words = {'strong', 'growth', 'increase', 'positive', 'beat', 'exceed', 
'outperform', 'robust', 'solid', 'improved', 'optimistic'}
negative_words = {'weak', 'decline', 'decrease', 'negative', 'miss', 'below', 
'underperform', 'challenging', 'difficult', 'concern', 'pessimistic'}
words = text.lower().split()
positive_count = sum(1 for word in words if word in positive_words)
negative_count = sum(1 for word in words if word in negative_words)
sentiment_score = (positive_count - negative_count) / max(len(words), 1)
if sentiment_score > 0.01:
sentiment = 'positive'
elif sentiment_score < -0.01: sentiment = 'negative' else: sentiment = 'neutral' return { 'sentiment': sentiment, 'sentiment_score': sentiment_score, 'positive_words_count': positive_count, 'negative_words_count': negative_count } def process_earnings_call(self, transcript_text, ticker): """Обработка текста конференц-звонка по квартальным результатам""" clean_transcript = self.clean_text(transcript_text) financial_entities = self.extract_financial_entities(transcript_text) sentiment_analysis = self.sentiment_analysis_simple(transcript_text) try: # Загрузка исторических данных для контекста ticker_data = yf.download(ticker, period="2y", progress=False) # Исправление проблемы с MultiIndex if isinstance(ticker_data.columns, pd.MultiIndex): ticker_data.columns = ticker_data.columns.get_level_values(0) # Проверка наличия нужных колонок if 'Close' in ticker_data.columns and len(ticker_data) > 30:
returns = ticker_data['Close'].pct_change().dropna()
pre_call_volatility = returns.tail(30).std() * np.sqrt(252)
if 'Volume' in ticker_data.columns:
avg_volume = ticker_data['Volume'].tail(30).mean()
else:
avg_volume = np.nan
# Дополнительные метрики
recent_return = (ticker_data['Close'].iloc[-1] / ticker_data['Close'].iloc[-30] - 1) * 100
max_price_30d = ticker_data['Close'].tail(30).max()
min_price_30d = ticker_data['Close'].tail(30).min()
else:
pre_call_volatility = np.nan
avg_volume = np.nan
recent_return = np.nan
max_price_30d = np.nan
min_price_30d = np.nan
except Exception as e:
print(f"Ошибка при загрузке данных для {ticker}: {e}")
ticker_data = pd.DataFrame()
pre_call_volatility = np.nan
avg_volume = np.nan
recent_return = np.nan
max_price_30d = np.nan
min_price_30d = np.nan
return {
'clean_text': clean_transcript,
'financial_entities': financial_entities,
'sentiment_analysis': sentiment_analysis,
'pre_call_volatility': pre_call_volatility,
'avg_volume': avg_volume,
'recent_return': recent_return,
'max_price_30d': max_price_30d,
'min_price_30d': min_price_30d,
'ticker_data': ticker_data,
'word_count': len(clean_transcript.split()),
'original_length': len(transcript_text)
}
# Пример использования
processor = FinancialTextProcessor()
# Пример конференц-звонка
sample_transcript = """
Q2 2025 Earnings Call Transcript
Transocean Ltd. (RIG)
Good day, and welcome to the Transocean Ltd. Q2 2025 earnings conference call. 
Our revenue came in at $1.23 billion for the quarter, representing strong growth compared to prior year.
Earnings per share were $0.45, beating analyst estimates by $0.32.
We've seen robust demand in our deep-water segment, and we're optimistic about 2025 guidance of $5.2 billion in revenue.
The challenging market conditions in Q1 have improved, and we expect positive momentum to continue.
Our backlog remains solid, and we're confident in our ability to outperform industry benchmarks.
However, we do face some headwinds from supply chain concerns and volatile oil prices.
"""
results = processor.process_earnings_call(sample_transcript, "RIG")
# Вывод результатов
print("=== АНАЛИЗ КОНФЕРЕНЦ-ЗВОНКА ===")
print(f"Очищенный текст (первые 150 символов):")
print(results['clean_text'][:150] + "...")
print(f"\nКоличество слов после обработки: {results['word_count']}")
print(f"Исходная длина текста: {results['original_length']} символов")
print(f"\n=== ФИНАНСОВЫЕ ПОКАЗАТЕЛИ ===")
for key, value in results['financial_entities'].items():
print(f"{key}: {value}")
print(f"\n=== АНАЛИЗ ТОНАЛЬНОСТИ ===")
sentiment_data = results['sentiment_analysis']
print(f"Общая тональность: {sentiment_data['sentiment']}")
print(f"Оценка тональности: {sentiment_data['sentiment_score']:.4f}")
print(f"Позитивные слова: {sentiment_data['positive_words_count']}")
print(f"Негативные слова: {sentiment_data['negative_words_count']}")
print(f"\n=== РЫНОЧНЫЕ ДАННЫЕ ===")
if not np.isnan(results['pre_call_volatility']):
print(f"Волатильность до звонка (годовая): {results['pre_call_volatility']:.2%}")
if not np.isnan(results['avg_volume']):
print(f"Средний объем торгов (30 дней): {results['avg_volume']:,.0f}")
if not np.isnan(results['recent_return']):
print(f"Доходность за последние 30 дней: {results['recent_return']:.2f}%")
print(f"Диапазон цен (30 дней): ${results['min_price_30d']:.2f} - ${results['max_price_30d']:.2f}")
else:
print("Рыночные данные недоступны")
=== АНАЛИЗ КОНФЕРЕНЦ-ЗВОНКА ===
Очищенный текст (первые 150 символов):
earnings call transcript transocean ltd rig good day welcome transocean ltd earnings conference call revenue came representing strong growth compared ...
Количество слов после обработки: 61
Исходная длина текста: 714 символов
=== ФИНАНСОВЫЕ ПОКАЗАТЕЛИ ===
revenue_mentions: ['$1.23 billion']
eps_mentions: ['$0.45']
guidance_mentions: ['$5.2 billion']
beat_mentions: ['$0.32']
estimate_mentions: ['$0.32']
all_money_mentions: ['$1.23 billion', '$0.45', '$0.32', '$5.2 billion']
=== АНАЛИЗ ТОНАЛЬНОСТИ ===
Общая тональность: positive
Оценка тональности: 0.0455
Позитивные слова: 6
Негативные слова: 1
=== РЫНОЧНЫЕ ДАННЫЕ ===
Волатильность до звонка (годовая): 55.93%
Средний объем торгов (30 дней): 41,892,115
Доходность за последние 30 дней: -2.23%
Диапазон цен (30 дней): $2.55 - $3.32

Этот код создает систему анализа финансовых конференц-звонков (earnings calls), которая объединяет обработку естественного языка с финансовыми данными. Вот что он делает:

👉🏻  Фьючерсы: назначение, виды контрактов, сроки, маржа

1. Обработка текста конференц-звонков

  • Очищает текст от HTML-тегов, специальных символов и цифр;
  • Удаляет стоп-слова (the, and, company, quarter и т.д.);
  • Выполняет лемматизацию (приводит слова к базовой форме).

2. Извлечение финансовых показателей

  • Автоматически находит в тексте упоминания выручки, прибыли на акцию (EPS), прогнозов;
  • Ищет фразы типа «revenue $1.23 billion», «earnings per share $0.45»;
  • Выделяет информацию о превышении ожиданий («beat estimates by $0.32»).

3. Анализ тональности

  • Определяет общее настроение текста (позитивное/негативное/нейтральное);
  • Подсчитывает позитивные слова (strong, growth, robust) и негативные (challenging, decline);
  • Вычисляет числовую оценку тональности.

4. Интеграция с рыночными данными

  • Загружает исторические данные акций через Yahoo Finance;
  • Рассчитывает волатильность за 30 дней до звонка;
  • Анализирует объемы торгов, доходность и ценовые диапазоны.

Этот инструмент может использоваться для:

  1. Автоматического анализа корпоративных отчетов;
  2. Предсказания реакции рынка на основе тональности;
  3. Мониторинга ключевых финансовых метрик компаний;
  4. Исследований связи между языком менеджмента и движением цен акций.

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

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

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

Обнаружение и обработка аномалий в финансовых временных рядах

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

Рассмотрим пример реализации системы обнаружения аномалий на основе изоляционного леса (Isolation Forest) — одного из наиболее эффективных алгоритмов для обнаружения выбросов:

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import yfinance as yf
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import StandardScaler
from datetime import datetime, timedelta
import pymc as pm
import arviz as az
# Загрузка данных
ticker = "RIG"  # Transocean Ltd.
start_date = "2022-07-01"
end_date = "2025-07-01"
# Получение данных
df = yf.download(ticker, start=start_date, end=end_date)
# Исправление проблемы с MultiIndex
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
# Проверка структуры данных
print("Доступные колонки:", df.columns.tolist())
print("Размер данных:", df.shape)
# Поиск колонки с ценой закрытия
close_column = None
for col in df.columns:
if 'close' in col.lower():
close_column = col
break
if close_column is None:
# Берем первую числовую колонку
numeric_cols = df.select_dtypes(include=[np.number]).columns
if len(numeric_cols) > 0:
close_column = numeric_cols[0]
else:
raise ValueError("Не найдена подходящая колонка с ценами")
print(f"Используем колонку '{close_column}' для анализа")
# Находим остальные нужные колонки
volume_column = None
high_column = None
low_column = None
for col in df.columns:
if 'volume' in col.lower():
volume_column = col
elif 'high' in col.lower():
high_column = col
elif 'low' in col.lower():
low_column = col
# Расчет признаков для детектирования аномалий
df['Returns'] = df[close_column].pct_change()
df['Log_Returns'] = np.log(df[close_column]).diff()
df['Volatility_21d'] = df['Returns'].rolling(21).std()
if volume_column is not None:
df['Volume_Change'] = df[volume_column].pct_change()
else:
df['Volume_Change'] = np.random.normal(0, 0.1, len(df))  # Заглушка
print("Колонка Volume не найдена, используется синтетическая")
if high_column is not None and low_column is not None:
df['Price_Range'] = (df[high_column] - df[low_column]) / df[close_column]
else:
df['Price_Range'] = df['Returns'].abs()  # Заглушка
print("Колонки High/Low не найдены, используется альтернативная метрика")
# Подготовка данных для модели
features = ['Returns', 'Log_Returns', 'Volatility_21d', 'Volume_Change', 'Price_Range']
X = df[features].dropna()
print(f"Количество наблюдений для анализа: {len(X)}")
# Стандартизация данных
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
# Применение Isolation Forest
model = IsolationForest(
n_estimators=100,
max_samples='auto',
contamination=0.02,  # Ожидаемая доля аномалий
random_state=42
)
# Обучение модели и получение оценок
df['Anomaly_Score'] = np.nan
df.loc[X.index, 'Anomaly_Score'] = model.fit_predict(X_scaled)
df['Is_Anomaly'] = df['Anomaly_Score'] == -1
# Подсчет аномалий
anomaly_count = df['Is_Anomaly'].sum()
print(f"Обнаружено аномалий: {anomaly_count}")
# Визуализация результатов
plt.figure(figsize=(15, 10))
# График цены с отмеченными аномалиями
ax1 = plt.subplot(2, 1, 1)
ax1.plot(df.index, df[close_column], color='black', linewidth=1.5, label='Price')
if anomaly_count > 0:
ax1.scatter(df[df['Is_Anomaly']].index, df.loc[df['Is_Anomaly'], close_column], 
color='darkred', s=50, label=f'Anomalies ({anomaly_count})')
ax1.set_title(f'{ticker} Price with Detected Anomalies', fontsize=14, fontweight='bold')
ax1.set_ylabel('Price ($)')
ax1.legend()
ax1.grid(True, alpha=0.3)
# График доходности с отмеченными аномалиями
ax2 = plt.subplot(2, 1, 2)
ax2.plot(df.index, df['Returns'], color='darkgrey', alpha=0.7, linewidth=1)
if anomaly_count > 0:
ax2.scatter(df[df['Is_Anomaly']].index, df.loc[df['Is_Anomaly'], 'Returns'], 
color='darkred', s=50, label=f'Anomalies ({anomaly_count})')
ax2.set_title('Daily Returns with Detected Anomalies', fontsize=14, fontweight='bold')
ax2.set_ylabel('Daily Returns')
ax2.set_xlabel('Date')
ax2.legend()
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Детальный анализ аномалий
anomaly_dates = df[df['Is_Anomaly']].index
if len(anomaly_dates) > 0:
print(f"\nДаты аномалий:")
for date in anomaly_dates:
price = df.loc[date, close_column]
return_val = df.loc[date, 'Returns']
print(f"{date.strftime('%Y-%m-%d')}: Цена ${price:.2f}, Доходность {return_val:.2%}")
# Статистика аномалий
anomaly_returns = df.loc[df['Is_Anomaly'], 'Returns'].dropna()
normal_returns = df.loc[~df['Is_Anomaly'], 'Returns'].dropna()
print(f"\nСтатистика аномальных дней:")
print(f"Средняя доходность аномалий: {anomaly_returns.mean():.2%}")
print(f"Стандартное отклонение аномалий: {anomaly_returns.std():.2%}")
print(f"Средняя доходность обычных дней: {normal_returns.mean():.2%}")
print(f"Стандартное отклонение обычных дней: {normal_returns.std():.2%}")
# Байесовское моделирование для оценки изменения параметров распределения
# Выбираем окно в 30 дней вокруг первой обнаруженной аномалии
anomaly_date = anomaly_dates[0]
start_window = max(df.index[0], anomaly_date - timedelta(days=15))
end_window = min(df.index[-1], anomaly_date + timedelta(days=15))
window_data = df.loc[start_window:end_window, 'Returns'].dropna().values
if len(window_data) > 5:  # Минимум данных для байесовского анализа
print(f"\nВыполняется байесовский анализ для периода {start_window.strftime('%Y-%m-%d')} - {end_window.strftime('%Y-%m-%d')}")
try:
# Байесовское моделирование для оценки изменения параметров распределения
with pm.Model() as bayesian_model:
# Предполагаем, что доходности следуют t-распределению
nu = pm.Exponential('nu', 1/10)  # Степени свободы
sigma = pm.Exponential('sigma', 1/0.02)  # Масштаб
mu = pm.Normal('mu', 0, 0.01)  # Среднее
# Определяем правдоподобие
likelihood = pm.StudentT('returns', nu=nu, mu=mu, sigma=sigma, observed=window_data)
# Семплирование
trace = pm.sample(1000, tune=1000, return_inferencedata=True, progressbar=False)
# Анализ результатов
summary = az.summary(trace)
print("\nБайесовская оценка параметров распределения доходностей вокруг аномалии:")
print(summary)
# Визуализация апостериорных распределений
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
az.plot_posterior(trace, var_names=['mu'], ax=axes[0])
az.plot_posterior(trace, var_names=['sigma'], ax=axes[1]) 
az.plot_posterior(trace, var_names=['nu'], ax=axes[2])
plt.suptitle('Posterior Distributions of Return Parameters Around Anomaly', fontsize=14)
plt.tight_layout()
plt.show()
except Exception as e:
print(f"Ошибка в байесовском моделировании: {e}")
else:
print("Недостаточно данных для байесовского анализа")
else:
print("Аномалии не обнаружены")
# Общая статистика
print(f"\nОБЩАЯ СТАТИСТИКА")
print(f"Анализируемый период: {df.index[0].strftime('%Y-%m-%d')} - {df.index[-1].strftime('%Y-%m-%d')}")
print(f"Общее количество торговых дней: {len(df)}")
print(f"Количество обнаруженных аномалий: {anomaly_count}")
print(f"Доля аномалий: {anomaly_count/len(df)*100:.2f}%")
if anomaly_count > 0:
print(f"Максимальная аномальная доходность: {df.loc[df['Is_Anomaly'], 'Returns'].max():.2%}")
print(f"Минимальная аномальная доходность: {df.loc[df['Is_Anomaly'], 'Returns'].min():.2%}")
Доступные колонки: ['Close', 'High', 'Low', 'Open', 'Volume']
Размер данных: (751, 5)
Используем колонку 'Close' для анализа
Количество наблюдений для анализа: 730
Обнаружено аномалий: 15
Даты аномалий:
2022-08-02: Цена $3.74, Доходность 16.15%
2022-11-03: Цена $4.16, Доходность 15.24%
2022-11-11: Цена $4.42, Доходность 10.78%
2023-02-22: Цена $6.10, Доходность -10.82%
2023-03-15: Цена $5.82, Доходность -8.92%
2023-04-03: Цена $7.04, Доходность 10.69%
2024-04-30: Цена $5.22, Доходность -10.31%
2024-07-25: Цена $5.82, Доходность 10.65%
2025-04-03: Цена $2.72, Доходность -13.92%
2025-04-04: Цена $2.17, Доходность -20.22%
2025-04-07: Цена $2.37, Доходность 9.22%
2025-04-08: Цена $2.19, Доходность -7.59%
2025-04-09: Цена $2.41, Доходность 10.05%
2025-04-10: Цена $2.17, Доходность -9.96%
2025-05-01: Цена $2.34, Доходность 9.86%

Цена акций Transocean Ltd. с обнаруженными аномалиями и дневные доходности акций с обнаруженными аномалиями

Рис. 2: Цена акций Transocean Ltd. с обнаруженными аномалиями и дневные доходности акций с обнаруженными аномалиями

Статистика аномальных дней:
Средняя доходность аномалий: 0.72%
Стандартное отклонение аномалий: 12.46%
Средняя доходность обычных дней: 0.02%
Стандартное отклонение обычных дней: 3.43%
Выполняется байесовский анализ для периода 2022-07-18 - 2022-08-17
Байесовская оценка параметров распределения доходностей вокруг аномалии:
mean     sd  hdi_3%  hdi_97%  mcse_mean  mcse_sd  ess_bulk  ess_tail   r_hat 
mu     0.004  0.007  -0.010    0.018      0.000    0.000    1201.0    1260.0   1.00
nu     9.739  8.772   0.565   26.500      0.244    0.302     474.0     161.0   1.00
sigma  0.049  0.011   0.030    0.072      0.001    0.001     406.0     144.0   1.01
ОБЩАЯ СТАТИСТИКА
Анализируемый период: 2022-07-01 - 2025-06-30
Общее количество торговых дней: 751
Количество обнаруженных аномалий: 15
Доля аномалий: 2.00%
Максимальная аномальная доходность: 16.15%
Минимальная аномальная доходность: -20.22%

Апостериорные распределения параметров t-распределения доходностей в окрестности аномального события. (а) среднее значение доходности μ, (б) параметр масштаба σ, (в) степени свободы ν. Байесовское моделирование выполнено для 30-дневного окна вокруг первой обнаруженной аномалии методом MCMC с 1000 итерациями после 1000 разогревающих шагов

Рис. 3: Апостериорные распределения параметров t-распределения доходностей в окрестности аномального события. (а) среднее значение доходности μ, (б) параметр масштаба σ, (в) степени свободы ν. Байесовское моделирование выполнено для 30-дневного окна вокруг первой обнаруженной аномалии методом MCMC с 1000 итерациями после 1000 разогревающих шагов

Этот код иллюстрирует комплексный подход к обнаружению и анализу аномалий в финансовых временных рядах:

  1. Сначала мы загружаем данные для тикера RIG (Transocean Ltd.) и рассчитываем различные признаки, характеризующие поведение цены и объема;
  2. Затем применяем алгоритм IsolationForest для автоматического обнаружения аномалий на основе этих признаков;
  3. Визуализируем обнаруженные аномалии на графиках цены и доходности;
  4. Для более глубокого анализа выбранной аномалии используем байесовское моделирование с помощью библиотеки pymc;
  5. Получаем апостериорные распределения параметров t-распределения (среднее, волатильность и степени свободы) для периода вокруг аномалии.
👉🏻  Библиотека sktime для анализа временных рядов

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

Байесовское моделирование особенно полезно в контексте ad hoc задач, поскольку оно позволяет:

  1. Работать с малыми выборками данных (что часто встречается при анализе редких событий);
  2. Инкорпорировать экспертные знания через априорные распределения;
  3. Получать не точечные оценки, а полные распределения параметров, что дает более полную картину неопределенности.

Продвинутые методы анализа для ad hoc финансовых задач

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

Факторные модели и альтернативные данные

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

import pandas as pd
import numpy as np
import yfinance as yf
import statsmodels.api as sm
from sklearn.decomposition import PCA
from sklearn.preprocessing import StandardScaler
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
def fix_multiindex_columns(data):
if isinstance(data.columns, pd.MultiIndex):
data.columns = data.columns.get_level_values(0)
return data
def safe_download(ticker, start_date, end_date):
try:
data = yf.download(ticker, start=start_date, end=end_date, progress=False)
data = fix_multiindex_columns(data)
# Поиск колонки Close
close_col = None
for col in data.columns:
if 'close' in col.lower():
close_col = col
break
if close_col is None:
# Берем первую числовую колонку
numeric_cols = data.select_dtypes(include=[np.number]).columns
if len(numeric_cols) > 0:
close_col = numeric_cols[0]
else:
return None
return data[close_col]
except Exception as e:
print(f"Ошибка при загрузке {ticker}: {e}")
return None
# Загрузка данных для портфеля энергетических компаний
tickers = ["XOM", "CVX", "COP", "EOG", "SLB", "VLO", "MPC", "BTU", "RIG", "CNX"]
start_date = "2022-07-01"
end_date = "2025-07-01"
# Загрузка данных о ценах акций
prices_df = pd.DataFrame()
successful_tickers = []
print("Загружаем данные для энергетических компаний...")
for ticker in tickers:
price_data = safe_download(ticker, start_date, end_date)
if price_data is not None:
prices_df[ticker] = price_data
successful_tickers.append(ticker)
print(f"✓ {ticker} - успешно загружен")
else:
print(f"✗ {ticker} - ошибка загрузки")
print(f"\nУспешно загружено {len(successful_tickers)} из {len(tickers)} компаний")
# Загрузка рыночного индекса
print("Загружаем рыночный индекс SPY...")
spy_data = safe_download("SPY", start_date, end_date)
if spy_data is not None:
prices_df['SPY'] = spy_data
print("✓ SPY - успешно загружен")
# Загрузка альтернативных данных: цены на нефть и природный газ
print("Загружаем товарные данные...")
oil_data = safe_download("CL=F", start_date, end_date)
if oil_data is not None:
prices_df['Oil'] = oil_data
print("✓ Нефть (CL=F) - успешно загружена")
gas_data = safe_download("NG=F", start_date, end_date)
if gas_data is not None:
prices_df['NatGas'] = gas_data
print("✓ Природный газ (NG=F) - успешно загружен")
# Расчет доходностей
returns_df = prices_df.pct_change().dropna()
print(f"\nРазмер итогового датасета: {returns_df.shape}")
print(f"Доступные активы: {list(returns_df.columns)}")
# Заполнение пропущенных значений
returns_df = returns_df.fillna(method='ffill').fillna(method='bfill')
# Создание лаговых переменных для товарных активов
if 'Oil' in returns_df.columns and 'NatGas' in returns_df.columns:
print("\nСоздаем лаговые переменные...")
for col in ['Oil', 'NatGas']:
for lag in [1, 2, 3, 5]:
returns_df[f'{col}_lag_{lag}'] = returns_df[col].shift(lag)
# Выделение главных компонент секторальных доходностей
if len(successful_tickers) >= 3:
print("Выполняем PCA анализ...")
scaler = StandardScaler()
energy_returns = returns_df[successful_tickers].dropna()
if len(energy_returns) > 0:
energy_returns_scaled = scaler.fit_transform(energy_returns)
n_components = min(3, len(successful_tickers))
pca = PCA(n_components=n_components)
pca_result = pca.fit_transform(energy_returns_scaled)
# Добавляем главные компоненты в датафрейм
for i in range(n_components):
returns_df[f'PC{i+1}'] = np.nan
returns_df.loc[energy_returns.index, f'PC{i+1}'] = pca_result[:, i]
print(f"Объясненная дисперсия первых {n_components} компонент: {pca.explained_variance_ratio_}")
print(f"Кумулятивная объясненная дисперсия: {pca.explained_variance_ratio_.cumsum()}")
# Анализ факторных влияний для конкретной компании
company = "RIG"  # Transocean Ltd.
if company in returns_df.columns:
print(f"\nВыполняем факторный анализ для {company}...")
# Определение доступных факторов для модели
potential_factors = ['SPY', 'Oil', 'NatGas', 'Oil_lag_1', 'NatGas_lag_1', 'PC1', 'PC2']
available_factors = [f for f in potential_factors if f in returns_df.columns]
print(f"Доступные факторы: {available_factors}")
if len(available_factors) > 0:
X = returns_df[available_factors].dropna()
y = returns_df.loc[X.index, company]
# Построение факторной модели
X_sm = sm.add_constant(X)
model = sm.OLS(y, X_sm)
results = model.fit()
# Анализ событийного окна
event_date = '2025-03-15'
window_start = pd.to_datetime(event_date) - pd.Timedelta(days=30)
window_end = pd.to_datetime(event_date) + pd.Timedelta(days=30)
# Проверяем, есть ли данные в нужном периоде
available_dates = returns_df.index
if window_start <= available_dates.max() and window_end >= available_dates.min():
event_window = returns_df.loc[window_start:window_end].copy()
if len(event_window) > 0:
# Расчет аномальных доходностей в событийном окне
X_event = event_window[available_factors].dropna()
if len(X_event) > 0:
X_event_sm = sm.add_constant(X_event)
event_window_aligned = event_window.loc[X_event.index]
expected_returns = results.predict(X_event_sm)
event_window_aligned['Expected_Return'] = expected_returns
event_window_aligned['Abnormal_Return'] = (
event_window_aligned[company] - event_window_aligned['Expected_Return']
)
event_window_aligned['Cumulative_Abnormal_Return'] = (
event_window_aligned['Abnormal_Return'].cumsum()
)
# Визуализация результатов
plt.figure(figsize=(15, 12))
# График 1: Аномальные доходности
ax1 = plt.subplot(3, 1, 1)
ax1.bar(event_window_aligned.index, event_window_aligned['Abnormal_Return'], 
color='darkgray', alpha=0.7, width=0.8)
ax1.axvline(pd.to_datetime(event_date), color='darkred', linestyle='--', 
linewidth=2, label='Event Date')
ax1.axhline(0, color='black', linestyle='-', linewidth=0.8)
ax1.set_title(f'Abnormal Returns for {company} Around Event Date', 
fontsize=14, fontweight='bold')
ax1.set_ylabel('Abnormal Return')
ax1.legend()
ax1.grid(True, alpha=0.3)
# График 2: Кумулятивные аномальные доходности
ax2 = plt.subplot(3, 1, 2)
ax2.plot(event_window_aligned.index, 
event_window_aligned['Cumulative_Abnormal_Return'], 
color='black', linewidth=2.5, marker='o', markersize=4)
ax2.axvline(pd.to_datetime(event_date), color='darkred', linestyle='--', 
linewidth=2, label='Event Date')
ax2.axhline(0, color='darkgray', linestyle='-', linewidth=0.8)
ax2.fill_between(event_window_aligned.index, 
event_window_aligned['Cumulative_Abnormal_Return'], 
0, alpha=0.3, color='darkgray')
ax2.set_title(f'Cumulative Abnormal Returns (CAR) for {company}', 
fontsize=14, fontweight='bold')
ax2.set_ylabel('CAR')
ax2.legend()
ax2.grid(True, alpha=0.3)
# График 3: Декомпозиция факторов
ax3 = plt.subplot(3, 1, 3)
# Выбираем несколько дней вокруг события для детального анализа
event_idx = event_window_aligned.index.get_indexer([pd.to_datetime(event_date)], 
method='nearest')[0]
start_idx = max(0, event_idx - 3)
end_idx = min(len(event_window_aligned), event_idx + 4)
selected_period = event_window_aligned.iloc[start_idx:end_idx]
if len(selected_period) > 0:
# Вычисляем вклад каждого фактора
factor_contributions = pd.DataFrame(index=selected_period.index)
for factor in available_factors:
if factor in X_event_sm.columns:
factor_contributions[factor] = (
X_event_sm.loc[selected_period.index, factor] * 
results.params[factor]
)
factor_contributions['Intercept'] = results.params['const']
factor_contributions['Abnormal'] = selected_period['Abnormal_Return']
# Столбчатая диаграмма вкладов факторов
bottom = np.zeros(len(factor_contributions))
colors = plt.cm.Set3(np.linspace(0, 1, len(factor_contributions.columns)))
for i, col in enumerate(factor_contributions.columns):
if col != 'Abnormal':
ax3.bar(range(len(factor_contributions)), 
factor_contributions[col], 
bottom=bottom, label=col, color=colors[i], alpha=0.8)
bottom += factor_contributions[col]
# Добавляем аномальные доходности отдельно
ax3.bar(range(len(factor_contributions)), 
factor_contributions['Abnormal'], 
bottom=bottom, label='Abnormal', color='darkred', alpha=0.9)
ax3.axvline(event_idx - start_idx, color='red', linestyle='--', 
linewidth=2, alpha=0.7)
ax3.set_title('Factor Decomposition Around Event Date', 
fontsize=14, fontweight='bold')
ax3.set_ylabel('Return Contribution')
ax3.set_xlabel('Days Around Event')
ax3.set_xticks(range(len(factor_contributions)))
ax3.set_xticklabels([d.strftime('%m-%d') for d in factor_contributions.index], 
rotation=45)
ax3.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
ax3.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Выводим результаты факторной модели
print(f"\nРЕЗУЛЬТАТЫ ФАКТОРНОЙ МОДЕЛИ ДЛЯ {company}")
print(f"Скорректированный R-квадрат: {results.rsquared_adj:.4f}")
print(f"F-статистика: {results.fvalue:.2f}")
print(f"P-value модели: {results.f_pvalue:.6f}")
print(f"\nКОЭФФИЦИЕНТЫ ФАКТОРОВ")
for param_name, coef in results.params.items():
p_val = results.pvalues[param_name]
significance = "***" if p_val < 0.01 else "**" if p_val < 0.05 else "*" if p_val < 0.1 else "" print(f"{param_name:12}: {coef:8.4f} (p={p_val:.4f}) {significance}") # Анализ событийного окна car_total = event_window_aligned['Cumulative_Abnormal_Return'].iloc[-1] max_car = event_window_aligned['Cumulative_Abnormal_Return'].max() min_car = event_window_aligned['Cumulative_Abnormal_Return'].min() print(f"\nАНАЛИЗ СОБЫТИЙНОГО ОКНА") print(f"Общий CAR за период: {car_total:.2%}") print(f"Максимальный CAR: {max_car:.2%}") print(f"Минимальный CAR: {min_car:.2%}") print(f"Количество дней с положительными аномальными доходностями: " f"{(event_window_aligned['Abnormal_Return'] > 0).sum()}")
print(f"Количество дней с отрицательными аномальными доходностями: "
f"{(event_window_aligned['Abnormal_Return'] < 0).sum()}")
else:
print("Недостаточно данных в событийном окне для анализа")
else:
print("Нет данных в указанном событийном окне")
else:
print("Указанная дата события вне доступного периода данных")
else:
print("Нет доступных факторов для построения модели")
else:
print(f"Компания {company} не найдена в загруженных данных")
Загружаем данные для энергетических компаний...
✓ XOM - успешно загружен
✓ CVX - успешно загружен
✓ COP - успешно загружен
✓ EOG - успешно загружен
✓ SLB - успешно загружен
✓ VLO - успешно загружен
✓ MPC - успешно загружен
✓ BTU - успешно загружен
✓ RIG - успешно загружен
✓ CNX - успешно загружен
Успешно загружено 10 из 10 компаний
Загружаем рыночный индекс SPY...
✓ SPY - успешно загружен
Загружаем товарные данные...
✓ Нефть (CL=F) - успешно загружена
✓ Природный газ (NG=F) - успешно загружен
Размер итогового датасета: (750, 13)
Доступные активы: ['XOM', 'CVX', 'COP', 'EOG', 'SLB', 'VLO', 'MPC', 'BTU', 'RIG', 'CNX', 'SPY', 'Oil', 'NatGas']
Создаем лаговые переменные...
Выполняем PCA анализ...
Объясненная дисперсия первых 3 компонент: [0.64499355 0.0861335  0.07121549]
Кумулятивная объясненная дисперсия: [0.64499355 0.73112705 0.80234254]
Выполняем факторный анализ для RIG...
Доступные факторы: ['SPY', 'Oil', 'NatGas', 'Oil_lag_1', 'NatGas_lag_1', 'PC1', 'PC2']

Анализ событийного окна для акций Transocean Ltd. (RIG) в период 15 марта 2025 г. ± 30 дней: (а) ежедневные аномальные доходности, рассчитанные как отклонения от прогнозов многофакторной модели; (б) кумулятивные аномальные доходности (CAR), демонстрирующие накопленный эффект события; (в) декомпозиция факторных вкладов в формирование доходности в ближайшие дни вокруг события

Рис. 4: Анализ событийного окна для акций Transocean Ltd. (RIG) в период 15 марта 2025 г. ± 30 дней: (а) ежедневные аномальные доходности, рассчитанные как отклонения от прогнозов многофакторной модели; (б) кумулятивные аномальные доходности (CAR), демонстрирующие накопленный эффект события; (в) декомпозиция факторных вкладов в формирование доходности в ближайшие дни вокруг события

РЕЗУЛЬТАТЫ ФАКТОРНОЙ МОДЕЛИ ДЛЯ RIG
Скорректированный R-квадрат: 0.5554
F-статистика: 134.48
P-value модели: 0.000000
КОЭФФИЦИЕНТЫ ФАКТОРОВ
const       :   0.0003 (p=0.7785) 
SPY         :   0.2316 (p=0.0206) **
Oil         :   0.1792 (p=0.0016) ***
NatGas      :  -0.0676 (p=0.0009) ***
Oil_lag_1   :  -0.0069 (p=0.8723) 
NatGas_lag_1:   0.0055 (p=0.7772) 
PC1         :   0.0098 (p=0.0000) ***
PC2         :   0.0016 (p=0.1263) 
АНАЛИЗ СОБЫТИЙНОГО ОКНА
Общий CAR за период: -18.42%
Максимальный CAR: -3.56%
Минимальный CAR: -20.71%
Количество дней с положительными аномальными доходностями: 17
Количество дней с отрицательными аномальными доходностями: 25

Вот как можно интерпретировать полученные данные:

👉🏻  Анализ статистических свойств распределения: асимметрия, эксцесс и нормальность

Построенная многофакторная модель демонстрирует высокое качество с R-квадратом 55,54% и статистически значимой F-статистикой 134,48. Ключевые факторы воздействия включают умеренную рыночную бету 0,23, значимую положительную связь с ценами нефти (0,18) и отрицательную корреляцию с природным газом (-0,07). Высокая значимость первой главной компоненты энергетического сектора подтверждает, что RIG тесно связана с общими секторальными трендами, при этом лаговые переменные товарных цен не показали влияния.

Событийный анализ выявляет существенное негативное воздействие с кумулятивной аномальной доходностью -18,42% за 60-дневный период. Динамика от максимального CAR -3,56% до минимального -20,71% демонстрирует постепенное ухудшение восприятия события рынком. Преобладание дней с отрицательными аномальными доходностями (25 против 17 положительных) указывает на устойчивое негативное влияние, не связанное с систематическими рыночными факторами.

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

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

  1. Мы загружаем данные по нескольким компаниям энергетического сектора, включая Transocean Ltd. (RIG), а также рыночный индекс и ключевые товарные рынки (нефть и природный газ);
  2. Рассчитываем доходности и создаем лаговые переменные для учета отложенных эффектов влияния товарных рынков;
  3. Используем метод главных компонент (PCA) для выделения основных драйверов секторальной динамики, что позволяет уловить системные риски, не отраженные в других факторах;
  4. Строим многофакторную модель, объясняющую доходность RIG через набор рыночных, товарных и секторальных факторов;
  5. Проводим событийный анализ (event study), выделяя аномальные доходности вокруг интересующей нас даты;
  6. Выполняем декомпозицию доходности по факторам для понимания причин аномальных движений цены.

Результаты такого анализа позволяют ответить на ряд важных вопросов:

  • Насколько сильно конкретное событие повлияло на стоимость акций компании?
  • Какие факторы оказывают наибольшее влияние на динамику акций?
  • Являются ли наблюдаемые ценовые движения аномальными или они объясняются действием известных рыночных факторов?

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

Обнаружение структурных изменений и регрессионные модели с переключением режимов

Еще одна частая ad hoc задача в финансовой аналитике — выявление структурных изменений в поведении рынков или отдельных активов. Для ее решения я предпочитаю использовать модели с переключением режимов (regime-switching models), которые позволяют учитывать различные состояния рынка и переходы между ними.

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

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import yfinance as yf
from hmmlearn.hmm import GaussianHMM
from sklearn.preprocessing import StandardScaler
import warnings
warnings.filterwarnings('ignore')
def fix_multiindex_columns(data):
if isinstance(data.columns, pd.MultiIndex):
data.columns = data.columns.get_level_values(0)
return data
def find_price_columns(df):
columns = {}
for col in df.columns:
col_lower = col.lower()
if 'close' in col_lower:
columns['Close'] = col
elif 'high' in col_lower:
columns['High'] = col
elif 'low' in col_lower:
columns['Low'] = col
elif 'volume' in col_lower:
columns['Volume'] = col
return columns
# Загрузка данных
ticker = "RIG"  # Transocean Ltd.
start_date = "2022-07-01"
end_date = "2025-07-01"
print(f"Загружаем данные для {ticker}...")
# Получение данных
df = yf.download(ticker, start=start_date, end=end_date, progress=False)
# Исправление проблемы с MultiIndex
df = fix_multiindex_columns(df)
print("Структура данных после загрузки:")
print(f"Колонки: {df.columns.tolist()}")
print(f"Размер: {df.shape}")
# Поиск нужных колонок
col_mapping = find_price_columns(df)
print(f"Найденные колонки: {col_mapping}")
# Проверяем наличие основных колонок
if 'Close' not in col_mapping:
# Берем первую числовую колонку как цену закрытия
numeric_cols = df.select_dtypes(include=[np.number]).columns
if len(numeric_cols) > 0:
col_mapping['Close'] = numeric_cols[0]
print(f"Используем '{numeric_cols[0]}' как цену закрытия")
else:
raise ValueError("Не найдена подходящая колонка с ценами")
# Переименовываем колонки для удобства
df_clean = pd.DataFrame(index=df.index)
df_clean['Close'] = df[col_mapping['Close']]
if 'High' in col_mapping:
df_clean['High'] = df[col_mapping['High']]
else:
df_clean['High'] = df_clean['Close'] * 1.02  # Заглушка
if 'Low' in col_mapping:
df_clean['Low'] = df[col_mapping['Low']]
else:
df_clean['Low'] = df_clean['Close'] * 0.98  # Заглушка
if 'Volume' in col_mapping:
df_clean['Volume'] = df[col_mapping['Volume']]
else:
df_clean['Volume'] = np.random.lognormal(15, 0.5, len(df_clean))  # Синтетические объемы
print("Колонка Volume не найдена, используется синтетическая")
print(f"Подготовленные данные: {df_clean.shape}")
# Расчет доходностей и волатильности
df_clean['Returns'] = df_clean['Close'].pct_change()
df_clean['Log_Returns'] = np.log(df_clean['Close']).diff()
df_clean['Volatility_21d'] = df_clean['Returns'].rolling(21).std() * np.sqrt(252)  # Годовая волатильность
# Расчет дополнительных признаков для модели
df_clean['Volume_Change'] = df_clean['Volume'].pct_change()
df_clean['Price_Range'] = (df_clean['High'] - df_clean['Low']) / df_clean['Close']
df_clean['Returns_5d'] = df_clean['Close'].pct_change(5)
df_clean['Returns_21d'] = df_clean['Close'].pct_change(21)
# Подготовка данных для модели
features = ['Log_Returns', 'Volatility_21d', 'Volume_Change', 'Price_Range', 'Returns_5d']
data = df_clean[features].dropna()
print(f"Данные для модели HMM: {data.shape}")
print("Первые строки данных:")
print(data.head())
# Стандартизация данных
scaler = StandardScaler()
scaled_data = scaler.fit_transform(data)
print("Данные стандартизированы")
# Определение оптимального числа режимов
print("Определяем оптимальное количество режимов...")
n_components_range = range(2, 5)
bic_values = []
aic_values = []
for n_components in n_components_range:
try:
hmm = GaussianHMM(n_components=n_components, covariance_type="full", 
n_iter=1000, random_state=42)
hmm.fit(scaled_data)
bic_values.append(hmm.bic(scaled_data))
aic_values.append(hmm.aic(scaled_data))
print(f"Режимов: {n_components}, BIC: {hmm.bic(scaled_data):.2f}, AIC: {hmm.aic(scaled_data):.2f}")
except Exception as e:
print(f"Ошибка для {n_components} режимов: {e}")
bic_values.append(np.inf)
aic_values.append(np.inf)
# Выбор оптимального числа компонент
if bic_values:
optimal_n = n_components_range[np.argmin(bic_values)]
print(f"Оптимальное количество режимов по BIC: {optimal_n}")
else:
optimal_n = 3
print("Используем 3 режима по умолчанию")
# Обучение финальной модели
n_components = optimal_n
hmm = GaussianHMM(n_components=n_components, covariance_type="full", 
n_iter=1000, random_state=42)
hmm.fit(scaled_data)
# Предсказание скрытых состояний
hidden_states = hmm.predict(scaled_data)
# Добавление предсказанных состояний в датафрейм
df_clean.loc[data.index, 'Regime'] = hidden_states
print(f"Модель обучена. Обнаружено {n_components} режима")
# Анализ характеристик каждого режима
regime_stats = pd.DataFrame()
for i in range(n_components):
regime_data = df_clean[df_clean['Regime'] == i]
if len(regime_data) > 0:
regime_stats[f'Regime_{i}'] = [
len(regime_data),
regime_data['Returns'].mean() * 252,  # Годовая доходность
regime_data['Returns'].std() * np.sqrt(252),  # Годовая волатильность
regime_data['Returns'].mean() / max(regime_data['Returns'].std(), 1e-6) * np.sqrt(252),  # Коэффициент Шарпа
regime_data['Volume'].mean(),  # Средний объем
regime_data['Price_Range'].mean()  # Средний дневной диапазон цен
]
else:
regime_stats[f'Regime_{i}'] = [0, 0, 0, 0, 0, 0]
regime_stats.index = ['Count', 'Annual_Return', 'Annual_Volatility', 'Sharpe_Ratio', 'Avg_Volume', 'Avg_Price_Range']
# Визуализация результатов
plt.figure(figsize=(15, 12))
# График цены с выделенными режимами
ax1 = plt.subplot(3, 1, 1)
colors = ['red', 'blue', 'green', 'orange', 'purple'][:n_components]
for i in range(n_components):
mask = df_clean['Regime'] == i
if mask.sum() > 0:
ax1.scatter(df_clean.index[mask], df_clean['Close'][mask], 
c=colors[i], s=20, label=f'Regime {i}', alpha=0.7)
ax1.plot(df_clean.index, df_clean['Close'], color='black', alpha=0.3, linewidth=1)
ax1.set_title(f'{ticker} Price with Detected Market Regimes', fontsize=14, fontweight='bold')
ax1.set_ylabel('Price ($)')
ax1.legend()
ax1.grid(True, alpha=0.3)
# График доходностей с выделенными режимами
ax2 = plt.subplot(3, 1, 2)
for i in range(n_components):
mask = df_clean['Regime'] == i
if mask.sum() > 0:
ax2.scatter(df_clean.index[mask], df_clean['Returns'][mask], 
c=colors[i], s=20, label=f'Regime {i}', alpha=0.7)
ax2.plot(df_clean.index, df_clean['Returns'], color='black', alpha=0.3, linewidth=0.5)
ax2.set_title('Daily Returns with Detected Market Regimes', fontsize=14, fontweight='bold')
ax2.set_ylabel('Daily Returns')
ax2.legend()
ax2.grid(True, alpha=0.3)
# График волатильности с выделенными режимами
ax3 = plt.subplot(3, 1, 3)
for i in range(n_components):
mask = df_clean['Regime'] == i
if mask.sum() > 0:
ax3.scatter(df_clean.index[mask], df_clean['Volatility_21d'][mask], 
c=colors[i], s=20, label=f'Regime {i}', alpha=0.7)
ax3.plot(df_clean.index, df_clean['Volatility_21d'], color='black', alpha=0.3, linewidth=0.5)
ax3.set_title('Annualized Volatility with Detected Market Regimes', fontsize=14, fontweight='bold')
ax3.set_ylabel('21-Day Volatility (Annualized)')
ax3.set_xlabel('Date')
ax3.legend()
ax3.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Вывод статистики по режимам
print("\nСТАТИСТИКА ПО ВЫЯВЛЕННЫМ РЕЖИМАМ РЫНКА")
print(regime_stats.round(4))
# Анализ матрицы переходов
transition_matrix = pd.DataFrame(hmm.transmat_,
index=[f'From_Regime_{i}' for i in range(n_components)],
columns=[f'To_Regime_{i}' for i in range(n_components)])
print("\nМАТРИЦА ПЕРЕХОДОВ МЕЖДУ РЕЖИМАМИ")
print(transition_matrix.round(4))
# Анализ устойчивости режимов
print("\nАНАЛИЗ УСТОЙЧИВОСТИ РЕЖИМОВ")
for i in range(n_components):
persistence = transition_matrix.iloc[i, i]
avg_duration = 1 / (1 - persistence) if persistence < 1 else np.inf print(f"Режим {i}: вероятность сохранения {persistence:.3f}, средняя длительность {avg_duration:.1f} дней") # Моделирование прогноза if len(hidden_states) > 0:
print("\nГЕНЕРАЦИЯ ПРОГНОЗА")
# Моделируем 50 дней вперед
n_forecast = 50
current_regime = hidden_states[-1]
forecast_regimes = []
forecast_returns = []
# Получаем параметры распределений для каждого режима
means = hmm.means_
covars = hmm.covars_
# Моделируем последовательность режимов
for _ in range(n_forecast):
# Вероятность перехода в следующий режим
next_regime_prob = transition_matrix.iloc[current_regime]
next_regime = np.random.choice(range(n_components), p=next_regime_prob.values)
forecast_regimes.append(next_regime)
# Генерируем доходность из распределения текущего режима
regime_mean = means[next_regime][0]  # Берем только лог-доходность
regime_var = covars[next_regime][0, 0]  # Вариация лог-доходности
returns_sample = np.random.normal(regime_mean, np.sqrt(regime_var))
forecast_returns.append(returns_sample)
current_regime = next_regime
# Преобразуем предсказанные лог-доходности в цены
last_price = df_clean['Close'].iloc[-1]
forecast_prices = [last_price]
for ret in forecast_returns:
# Возвращаем к оригинальному масштабу
scaled_return = ret * scaler.scale_[0] + scaler.mean_[0]
next_price = forecast_prices[-1] * np.exp(scaled_return)
forecast_prices.append(next_price)
# Создаем датафрейм с прогнозами
last_date = df_clean.index[-1]
forecast_dates = pd.date_range(start=last_date + pd.Timedelta(days=1), periods=n_forecast)
forecast_df = pd.DataFrame({
'Date': forecast_dates,
'Forecasted_Regime': forecast_regimes,
'Forecasted_Price': forecast_prices[1:]
})
# Визуализация прогноза
plt.figure(figsize=(15, 8))
# График исторических и прогнозных цен
ax = plt.subplot(1, 1, 1)
historical_period = min(252, len(df_clean))  # Показываем последний год или все данные
ax.plot(df_clean.index[-historical_period:], df_clean['Close'][-historical_period:], 
color='black', linewidth=2, label='Historical Price')
# Отображаем прогнозы с учетом режима
for i in range(n_components):
mask = forecast_df['Forecasted_Regime'] == i
if mask.sum() > 0:
ax.plot(forecast_df['Date'][mask], forecast_df['Forecasted_Price'][mask], 
'o-', color=colors[i], markersize=4, label=f'Forecast Regime {i}', 
alpha=0.8, linewidth=1.5)
ax.set_title(f'{ticker} Прогноз цен акций с моделью Regime Switching', 
fontsize=14, fontweight='bold')
ax.set_ylabel('Price ($)')
ax.set_xlabel('Date')
ax.axvline(last_date, color='darkred', linestyle='--', linewidth=2, 
label='Forecast Start')
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print(f"Сгенерирован прогноз на {n_forecast} дней")
print(f"Текущий режим: {current_regime}")
print(f"Последняя цена: ${last_price:.2f}")
print(f"Прогнозная цена через {n_forecast} дней: ${forecast_prices[-1]:.2f}")
# Статистика по прогнозным режимам
regime_forecast_counts = pd.Series(forecast_regimes).value_counts().sort_index()
print(f"\nРаспределение режимов в прогнозе:")
for regime, count in regime_forecast_counts.items():
print(f"Режим {regime}: {count} дней ({count/n_forecast*100:.1f}%)")
else:
print("Нет данных для генерации прогноза")
Загружаем данные для RIG...
Структура данных после загрузки:
Колонки: ['Close', 'High', 'Low', 'Open', 'Volume']
Размер: (751, 5)
Найденные колонки: {'Close': 'Close', 'High': 'High', 'Low': 'Low', 'Volume': 'Volume'}
Подготовленные данные: (751, 4)
Данные для модели HMM: (730, 5)
Первые строки данных:
Log_Returns  Volatility_21d  Volume_Change  Price_Range   Returns_5d 
Date                                                                  
2022-08-02     0.149704        1.145310       1.436156     0.104278   0.350181 
2022-08-03    -0.043723        1.140288      -0.521297     0.092179   0.177632
2022-08-04    -0.066402        1.160117      -0.143575     0.080597   0.112957
2022-08-05     0.026511        1.150863       0.220555     0.122093   0.017751
2022-08-08     0.002903        1.145619      -0.196286     0.040580   0.071429
Данные стандартизированы
Определяем оптимальное количество режимов...
Режимов: 2, BIC: 9112.55, AIC: 8915.05
Режимов: 3, BIC: 8746.98, AIC: 8434.65
Режимов: 4, BIC: 8408.94, AIC: 7972.60
Оптимальное количество режимов по BIC: 4
Модель обучена. Обнаружено 4 режима

Рис. 5: Идентификация рыночных режимов для акций Transocean Ltd. (RIG) методом скрытых марковских моделей (HMM). (а): динамика цены с цветовым кодированием обнаруженных режимов; (б): дневные доходности, классифицированные по режимам; (в): 21-дневная годовая волатильность с выделением режимов. Каждый режим характеризуется различными статистическими свойствами доходности и волатильности. Период анализа: июль 2022 г. — июль 2025 г.

Рис. 5: Идентификация рыночных режимов для акций Transocean Ltd. (RIG) методом скрытых марковских моделей (HMM). (а): динамика цены с цветовым кодированием обнаруженных режимов; (б): дневные доходности, классифицированные по режимам; (в): 21-дневная годовая волатильность с выделением режимов. Каждый режим характеризуется различными статистическими свойствами доходности и волатильности. Период анализа: июль 2022 г. — июль 2025 г.

СТАТИСТИКА ПО ВЫЯВЛЕННЫМ РЕЖИМАМ РЫНКА
Regime_0      Regime_1      Regime_2      Regime_3
Count              4.500000e+01  3.240000e+02  2.720000e+02  8.900000e+01
Annual_Return     -4.018700e+00 -4.500000e-01  7.532000e-01  2.168300e+00
Annual_Volatility  1.296800e+00  4.137000e-01  5.589000e-01  6.113000e-01
Sharpe_Ratio      -3.098900e+00 -1.087700e+00  1.347600e+00  3.546800e+00
Avg_Volume         3.892108e+07  1.764153e+07  2.369861e+07  2.552360e+07
Avg_Price_Range    9.510000e-02  4.210000e-02  4.980000e-02  5.140000e-02
МАТРИЦА ПЕРЕХОДОВ МЕЖДУ РЕЖИМАМИ
To_Regime_0  To_Regime_1  To_Regime_2  To_Regime_3
From_Regime_0       0.2896       0.2110       0.3657       0.1337
From_Regime_1       0.0444       0.9556       0.0000       0.0000
From_Regime_2       0.0608       0.0153       0.9240       0.0000
From_Regime_3       0.0254       0.0000       0.0471       0.9275
АНАЛИЗ УСТОЙЧИВОСТИ РЕЖИМОВ
Режим 0: вероятность сохранения 0.290, средняя длительность 1.4 дней
Режим 1: вероятность сохранения 0.956, средняя длительность 22.5 дней
Режим 2: вероятность сохранения 0.924, средняя длительность 13.2 дней
Режим 3: вероятность сохранения 0.927, средняя длительность 13.8 дней
ГЕНЕРАЦИЯ ПРОГНОЗА
Сгенерирован прогноз на 50 дней
Текущий режим: 2
Последняя цена: $2.59
Прогнозная цена через 50 дней: $2.45
Распределение режимов в прогнозе:
Режим 0: 8 дней (16.0%)
Режим 1: 12 дней (24.0%)
Режим 2: 27 дней (54.0%)
Режим 3: 3 дней (6.0%)

Прогнозирование цены акций RIG с использованием модели переключения режимов. Черная линия представляет историческую динамику цены, цветные точки и линии — прогнозные значения с указанием вероятного режима на каждый день. Красная пунктирная линия обозначает начало прогнозного периода. Прогноз основан на матрице переходов между режимами и параметрах распределений, оцененных методом максимального правдоподобия

Рис. 6: Прогнозирование цены акций RIG с использованием модели переключения режимов. Черная линия представляет историческую динамику цены, цветные точки и линии — прогнозные значения с указанием вероятного режима на каждый день. Красная пунктирная линия обозначает начало прогнозного периода. Прогноз основан на матрице переходов между режимами и параметрах распределений, оцененных методом максимального правдоподобия

Этот код демонстрирует профессиональный подход к анализу финансовых временных рядов с использованием скрытых марковских моделей (HMM):

  1. Мы загружаем данные для акций Transocean Ltd. (RIG) и рассчитываем набор признаков, характеризующих различные аспекты рыночной динамики;
  2. Используем алгоритм GaussianHMM для выявления скрытых режимов рынка на основе этих признаков;
  3. Анализируем статистические характеристики каждого режима (доходность, волатильность, коэффициент Шарпа);
  4. Исследуем матрицу переходов между режимами, что дает понимание устойчивости каждого состояния рынка;
  5. Используем полученную модель для прогнозирования будущей динамики цен с учетом вероятностей переключения режимов.
👉🏻  Анализ данных с Python на примере исследования изменения температуры в мире и России

Ключевое преимущество такого подхода — его способность улавливать и моделировать различные состояния рынка. Например, мы можем выявить:

  • Режим низкой волатильности и стабильного роста;
  • Режим высокой волатильности и резких падений;
  • Режим бокового движения с умеренной волатильностью.

Модели с переключением режимов особенно полезны для ad hoc анализа, поскольку они позволяют:

  1. Выявлять точки структурных изменений в поведении рынка;
  2. Оценивать длительность различных рыночных режимов;
  3. Прогнозировать вероятность перехода из одного режима в другой;
  4. Адаптировать инвестиционные стратегии к текущему состоянию рынка.

Практические советы по организации процесса решения ad hoc задач

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

Итеративный подход к анализу

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

Я рекомендую следующую последовательность действий:

  1. Начинайте с простейшей модели или анализа, которые дают быстрые, пусть и неточные результаты;
  2. Анализируйте промежуточные результаты и формулируйте гипотезы о возможных улучшениях;
  3. Реализуйте одно улучшение за раз и оценивайте его эффект;
  4. Регулярно возвращайтесь к исходной бизнес-задаче, чтобы не уйти в сторону;
  5. Документируйте каждую итерацию и полученные выводы.

Такой подход позволяет быстрее обнаруживать перспективные направления анализа и избегать излишних затрат времени на тупиковые пути.

👉🏻  Применение Python для среднесрочной торговли на Forex: разработка скринера трендов

Управление ожиданиями и коммуникация

Одна из частых проблем при решении ad hoc задач — несоответствие ожиданий заказчика и возможностей аналитика. Поскольку такие задачи часто связаны с неизведанными областями, невозможно заранее гарантировать определенный результат.

Для эффективного управления ожиданиями я рекомендую:

  1. В начале работы четко обсудить, что является успешным результатом, а что будет считаться неудачей;
  2. Установить промежуточные контрольные точки для демонстрации прогресса;
  3. Предлагать несколько альтернативных подходов с указанием их сильных и слабых сторон;
  4. Использовать байесовский подход к формулировке выводов — говорить не о точных значениях, а о вероятностных интервалах и степени уверенности;
  5. Подготовить план действий для различных сценариев, включая негативные.

Масштабируемость решений и автоматизация

Хотя ad hoc задачи по определению являются разовыми, часто схожие вопросы возникают снова и снова. Поэтому стоит изначально проектировать решения с учетом возможности их повторного использования.

Я рекомендую создавать модульную архитектуру кода, где:

  1. Базовые функции обработки данных выделены в отдельные модули;
  2. Параметры моделей вынесены в конфигурационные файлы;
  3. Все этапы анализа автоматизированы и объединены в единый конвейер;
  4. Результаты сохраняются в структурированном виде для последующего анализа;
  5. Код снабжен подробными комментариями и документацией.

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

Интеграция результатов ad hoc анализа в бизнес-процессы

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

От аналитического результата к действию

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

  1. Переводить технические результаты на язык бизнеса — не «модель имеет R-squared 0.72», а «наш анализ показывает, что 72% изменений рентабельности объясняются тремя ключевыми факторами»;
  2. Формулировать конкретные рекомендации с оценкой потенциального эффекта и сопутствующих рисков;
  3. Предлагать алгоритм действий для реализации рекомендаций;
  4. Определять метрики успеха и способы их измерения;
  5. Разрабатывать план мониторинга и адаптации на основе обратной связи.

Документация и знания

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

  1. Создавать детальную документацию по каждому проекту, включающую не только результаты, но и процесс их получения;
  2. Регулярно проводить внутренние воркшопы и презентации для обмена опытом;
  3. Вести библиотеку аналитических шаблонов и подходов;
  4. Разрабатывать обучающие материалы на основе реальных кейсов;
  5. Создавать интерактивные дашборды для демонстрации результатов нетехническим специалистам.

Выводы

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

  1. Основные типы и особенности ad hoc задач в финансовом анализе;
  2. Методологические подходы к их решению, включая правильную формулировку задачи и исследовательский анализ данных;
  3. Техники обработки и подготовки данных, в том числе работу с неструктурированными финансовыми данными и обнаружение аномалий;
  4. Продвинутые методы анализа: факторные модели с альтернативными данными, модели с переключением режимов;
  5. Практические советы по организации процесса и интеграции результатов в бизнес-процессы.

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

Вместо этого, реальную ценность создают:

  • Умение работать с уникальными, альтернативными источниками данных;
  • Способность комбинировать различные методологические подходы;
  • Навыки построения сложных, но интерпретируемых моделей;
  • Глубокое понимание предметной области и бизнес-контекста.

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