Парный трейдинг: 1 из 3 способов поиска пар

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

Кратко о «Парном трейдинге»: в основе стратегии лежит предположение, что есть две акции, которые имеют глубокую экономическую связь друг с другом, и их цена движется в одном направлении с разной скоростью. Когда отстает акция А, мы ее покупаем и одновременно продаем в короткую акцию Б. И наоборот.

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

📏Подготовка к поиску

Для правильной работы необходимо соблюсти следующие условия:

  1. Истории цен должны быть равной длины. (Результат сравнения историй за 200 дней и за 20 дней может быть непредсказуем).
  2. Значения должны быть переведены в относительные величины. (Тяжело сравнивать активы за $200 и за $1).
  3. Каждая история не должна обладать стационарностью сама по себе. (Важна именно стационарность спреда пары, а не отдельного актива в ней).

Коинтеграцию каждого времянного ряда будем проверять с помощью метода Дики-Фуллера из библиотеки statsmodels. Код подготовки ниже:

def get_performance(a):
    """
    Конвертируем историю в относительные величины
    """
    return np.insert(np.cumsum(np.diff(a) / a[:-1] * 100.), 0, 0)

# получаем все длины историй цен
all_lengths = [len(p) for s, p in symbol_prices.items()]
# получаем порог максимальной цены для 5% минимальных историй
length_limit = int(np.percentile(all_lengths, 5))
# фильтруем короткие истории, обрезаем длинные, переводим в относительные величины
symbol_performance = {s: get_performance(p[-length_limit:]) for s, p in symbol_prices.items() if len(p) >= length_limit} 

# проверяем коинтеграцию и удаляем стационарные ряды
to_delete = []
for s, p in symbol_performance.items():
    result = adfuller(p)
    score = result[0]
    pvalue = result[1]
    crit = result[4]
    #score, pvalue, score < crit['5%']
    if score < crit['5%']:
        # cointegrated
        to_delete.append(s)
if to_delete:
    for s in to_delete:
        del symbol_performance[s]

☝Выбор акций для поиска

Искать пары будем среди активов торгующихся на рынке, включая ETF, ограничив следующими условиями:

  • Цена более $10.
  • Средний объем более 500 тыс. акций в день.
  • ATR за 13 дней более $0.40.

Проверяем на ликвидных активах с хорошим движением внутри дня. Из 6 тысяч американских акций, торгующихся на биржах, нам подходят ~1500 активов. Тестировать будем предыдущий год (360 календарных дней).

🔍1 из 3: Простой коинтеграционный тест

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

Функция проверки стационарности:
statsmodels.tsa.stattools.coint(X, Y)

Функция поиска пар получает словарь с историей относительного изменения цен и последовательно проверяет каждую пару. Для 1500 активов это примерно 1.1 миллиона вариантов.

Выбираем пары с оценкой ниже 5% уровня ошибки и p-значением меньше 0,001. Код поиска пар ниже:

def find_cointegrated_pairs(symbol_prices, need_preparation=False):
    # готовим матрицы для сбора оценок и p-значений
    n = len(symbol_prices)
    score_matrix = np.zeros((n, n))
    pvalue_matrix = np.ones((n, n))
    
    symbols = list(symbol_prices.keys())
    pairs = []
    
    for i in range(n):
        for j in range(i+1, n):
            S1 = symbol_prices[symbols[i]]
            S2 = symbol_prices[symbols[j]]
            
            # подготавливаем ряды, если надо
            if need_preparation:
                S1, S2 = prepare_pair(S1, S2, to_performance=True)
            
            # проверяем коинтеграцию
            # crit[0] - 1%; crit[1] - 5%; crit[2] - 10%
            score, pvalue, crit = coint(S1, S2)
            
            # заполняем матрицы значений
            score_matrix[i, j] = score
            pvalue_matrix[i, j] = pvalue
            # добавляем пары, если score < crit[5%] и p-значение < 0.001
            if score < crit[1] and pvalue < 0.001:
                pairs.append((symbols[i], symbols[j], pvalue))
                
    # сортируем пары по возрастанию p-значения
    sorted_pairs = sorted(pairs, key=operator.itemgetter(2))
    return score_matrix, pvalue_matrix, sorted_pairs

На момент написания статьи тест нашел 6400 пар (~0.6%), поиск загружал 1 ядро процессора и длился порядка 45 минут. Вот первые несколько пар с наименьшим p-значением:

  • XIV, SVXY
  • IWB, SPY
  • IVV, SPY
  • DUST, GDX
  • SPY, SPXU

Видно, что это ETF на индексы и первые два действительно имеют стационарный спред, а вот начиная с IVV и SPY найти проблемы нам помогут графики.

🎏Проверка найденных пар

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

  • XIV, SVXY
  • DUST, GDX
  • XLK, QQQ
  • PRU, C
  • TMUS, WCG

👎XIV, SVXY

XIV — ETN (аналог ETF) обратный индексу страха $VIX.
VSXY — ETF обратный индексу страха $VIX.

На графике история цен и сигнальная линия z-оценки. SVXY домножен на коэффициент для приведения цен к единой величине. Видно, что график хорош, но два месяца назад (ноябрь-декабрь 2016) спред уменьшился. Причина в моем поставщике данных. Ранее была бо́льшая погрешность в цене, которую нивелировали.

Код z-оценки:

def zscore_std(series):
    return ((series - series.mean()) / np.std(series)).rename("z-score")

👎DUST, GDX

DUST — 3х-кратный обратный ETF к GDX.
GDX — ETF компаний-золотодобытчиков.

График разочаровывает и подобных графиков в результатах много. Данные активы обратно-скоррелированы — этот факт в их природе существования. Функция coint(X, Y) на вход получает два времянных ряда и сама проверяет их стационарность. Видимо эта функция использует в основе проверку корреляции и дополнительно подгоняет ряды.

👍XLK, QQQ

XLK — ETF на индекс технологических компаний.
QQQ — ETF на индекс NASDAQ-100, где преимущественно технологические компании.

Здесь результаты лучше, но так как в QQQ также много компаний из XLV (здравоохранение), то в январе 2017 XLK сумел вырваться вперед.

👌PRU, C

Копнем поглубже и возьмем несколько компаний.

PRU — Prudential Financials, страховая компания.
C — Citibank, банк.

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

👍TMUS, WCG

TMUS — T-Mobile US, американский сотовый оператор.
WCG — WellCare Health Plans, компания из сектора здравоохранения.

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

🏁Вывод

Данный метод имеет право на существование. Удается найти хорошие пары, которые достойны участия в бэктестинге. Но одновременно к нам приходит большое количество шлака💩, что заставляет отсматривать👀 результаты вручную. Лучше себя проявил поиск с помощью метода Дики-Фуллера, о котором я буду писать в следующей статье.

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

🎓Обучение «Парному трейдингу» у профессионалов👍.
Александр Румянцев aka "iamraa"
Автор Quantrum.me
Интересуетесь алготрейдингом на Python? Присоединяйтесь к команде. Пишите в личку или на email.