Парный трейдинг с нуля: две монеты на поводке
Урок 15 курса по арбитражу. Почему сравнение двух цен само по себе ничего не значит, как спред и его z-score показывают расхождение связанных монет, и небольшой бэктест, показывающий, как комиссии съедают преимущество. Код Python с воспроизводимым выводом.
Урок 15 курса "Собери своего арбитражного бота". Первые стратегии искали разрыв между двумя биржами. Эта стратегия ищет разрыв между двумя монетами, которые обычно движутся вместе.
Собака и ее хозяин
Человек выгуливает собаку на длинном поводке. Собака забегает вперед, затем отстает, затем перебегает на другую сторону. Но поводок длинный, а не бесконечный, поэтому собака ненадолго уходит далеко от человека. Если вы видите, что собака сильно убежала вперед, можно предположить, что через минуту она будет ближе к человеку. Вы не знаете, куда идет пара. Вы знаете только, что расстояние между ними возвращается к норме.
Две монеты могут вести себя так же. Представьте две похожие монеты из одного сектора или монету и токен, который ее оборачивает. Каждая блуждает вверх и вниз по своим причинам, но некая сила удерживает их вместе, а расстояние между ними постоянно возвращается. Торговлю на возврате этого расстояния называют парным трейдингом. Это простейший вид статистического арбитража.
Вы не ставите на направление любой из монет. Когда расстояние слишком велико, вы продаете убежавшую вперед монету и покупаете отставшую. Когда расстояние сжимается, вы закрываете обе позиции. Если весь рынок падает, шорт приносит деньги, а лонг теряет, и они примерно компенсируют друг друга.
Прямой подход: сравнить цены
Простая идея: взять две цены и посмотреть, какая выше. Монета A стоит 50, монета B стоит 100. B "дороже". Продать B, купить A?
Это ничего не говорит. Цена зависит от количества выпущенных монет, а не от того, дорогая ли монета. Монета с меньшей ценой не является более дешевой в полезном смысле. На демонстрационном рынке ниже A в начале стоит 50, B стоит 100, и это постоянное отношение в половину является нормой для этой пары.
Стоит смотреть на изменение отношения, а не на само отношение. Цены ведут себя как умножения, монета растет на 1 процент, потом на 2 процента, поэтому логарифм превращает их в сложения. Логарифм отношения является разностью логарифмов. Эту разность называют спредом:
spread = log(price of A) - log(price of B)
Если обе монеты удвоятся, спред не изменится, потому что оба логарифма вырастут на одну величину. Он движется, только когда одна монета обгоняет другую. Это расстояние на поводке.
Как далеко значит слишком далеко: z-score
Фразе "спред большой" нужно число. Возьмите последние 100 значений спреда. Посчитайте их среднее и обычное отклонение от среднего, стандартное отклонение. Затем спросите: на сколько обычных отклонений сегодняшнее значение выше среднего?
z = (spread now - average of the last 100) / wobble of the last 100
Это z-score. Если z равен 0, пара находится на обычном расстоянии. Если z равен 2, спред на два обычных отклонения выше нормы. В спокойном мире с колоколообразным распределением это случается примерно 2 или 3 дня из 100, поэтому событие редкое и на него стоит реагировать.
Правила:
- z выше +2: спред слишком высок, A убежала вперед. Откройте шорт A, купите B.
- z ниже −2: A отстала. Купите A, откройте шорт B.
- Закройте пару, когда z вернется близко к 0, мы используем абсолютное значение меньше 0.5.
- За каждую сделку платятся комиссии по четырем ногам: открыть A, открыть B, закрыть A, закрыть B.
Код
Демонстрационный рынок создан намеренно: B является случайным блужданием, а A следует за B с шумом, который постоянно возвращается к нулю, это и есть поводок. Затем мы применяем правила.
import math, random, statistics
random.seed(3)
n = 1500
log_b, noise = [math.log(100)], [0.0]
for t in range(n):
log_b.append(log_b[-1] + random.gauss(0, 0.01)) # coin B: a random walk
noise.append(noise[-1] * 0.9 + random.gauss(0, 0.01)) # a noise that always drifts back to 0
log_a = [log_b[t] + noise[t] + math.log(0.5) for t in range(n + 1)] # coin A follows B
price_a = [math.exp(x) for x in log_a]
price_b = [math.exp(x) for x in log_b]
print("prices at the start:", round(price_a[0], 2), round(price_b[0], 2))
spread = [log_a[t] - log_b[t] for t in range(n + 1)]
print("spread mean", round(statistics.mean(spread), 3), "stdev", round(statistics.pstdev(spread), 3))
def backtest(spread, window=100, entry=2.0, exit_=0.5, fee=0.001):
pos, entry_spread, pnl, trades = 0, 0.0, 0.0, 0
for t in range(window, len(spread)):
w = spread[t - window:t]
z = (spread[t] - statistics.mean(w)) / statistics.pstdev(w)
if pos == 0:
if z > entry: pos, entry_spread = -1, spread[t] # spread high: short A, long B
elif z < -entry: pos, entry_spread = +1, spread[t] # spread low: long A, short B
elif abs(z) < exit_:
pnl += pos * (spread[t] - entry_spread) - 4 * fee # 2 legs in, 2 legs out
trades += 1
pos = 0
return trades, pnl * 100
for fee in (0.001, 0.003, 0.006, 0.01):
trades, result = backtest(spread, fee=fee)
print(f"fee {fee*100:.1f}% per leg: trades {trades}, result {result:+.1f} %")
Вывод:
prices at the start: 50.0 100.0
spread mean -0.693 stdev 0.026
fee 0.1% per leg: trades 22, result +64.3 %
fee 0.3% per leg: trades 22, result +46.7 %
fee 0.6% per leg: trades 22, result +20.3 %
fee 1.0% per leg: trades 22, result -14.9 %
Разберем его.
- Средний спред равен −0.693, это log(0.5), "нормальное" отношение в половину. Его обычное отклонение равно 0.026, примерно 2.6 процента. Этим отклонением вы и торгуете.
- За 1500 шагов правила сделали 22 полных круга. Сделка зарабатывает расстояние, на которое спред вернулся, здесь около 3.3 процента на сделку до комиссий, первая строка: 64.3 плюс 22 сделки × 0.4 комиссии, разделить на 22 сделки.
- Комиссии решают все. При 0.1 процента на ногу четыре ноги стоят 0.4 процента на сделку, суммарный результат равен +64.3 процента. При 1 проценте на ногу, это маленькая монета с большим спредом между покупкой и продажей, четыре ноги стоят 4 процента, больше заработанных 3.3 процента, поэтому каждая сделка убыточна, а общий результат равен −14.9 процента.
- Результат дан в процентах от номинала одной ноги и предполагает, что поводок существует.
Что скрывает эта игрушка
Считайте +64 процента иллюстрацией механизма. Числа огромны, потому что мы создали мир, где поводок никогда не рвется. На настоящем рынке:
- Связь может разорваться. Две монеты, которые год двигались вместе, могут навсегда разойтись после новости об одной из них. "Слишком высокий" спред продолжает расти, а шорт по убежавшей монете продолжает терять. Настоящему боту нужно правило: стоп. Если z выходит за 4 или пара остается открытой слишком долго, закройте ее и примите убыток.
- Связь нужно проверять. У проверки поводка есть название: коинтеграция. Простыми словами: возьмите спред и убедитесь, что он действительно возвращается к среднему, а не уходит дальше. Простая первая проверка: сравнить отклонение спреда в первой половине данных со второй.
- Отношение двух ног может быть не один к одному. Здесь A движется точно вместе с B. В настоящих данных одна монета может двигаться в 1.3 раза сильнее другой, тогда размеры ног берут как 1 к 1.3. Поиск этого числа, регрессия, является следующим шагом полного метода.
- Расходы больше, чем в таблице. Учитывайте комиссии, разницу между ценами покупки и продажи и стоимость займа для короткой ноги.
Попросите ИИ-агента написать и проверить код
Расплывчатый запрос:
Напиши бота для парного трейдинга.
Запрос со встроенными проверками:
Напиши pairs.py по коду урока 15. Создай тот же синтетический рынок с random.seed(3) и выведи строки точно как в уроке. Строка для комиссии 0.1% должна показать 22 сделки и результат около +64.3. Затем добавь стоп: закрывай пару, когда абсолютный z-score выше 4, отдельно считай остановленные сделки и выведи, как меняется результат. Затем добавь функцию, которая принимает список цен закрытия двух монет и возвращает спред, а также его стандартное отклонение в первой и второй половинах. Пока ничего не торгуй и не скачивай данные.
Проверьте первый вывод построчно с уроком до того, как позволить агенту что-то добавлять.
Ваше упражнение
- Запустите код и сопоставьте все шесть напечатанных строк.
- Измените порог входа с 2.0 на 1.5, затем на 3.0. Как меняются число сделок и результат при комиссии 0.1 процента? Чем обходится более низкий порог?
- Намеренно порвите поводок. После шага 750 прибавляйте 0.004 к шуму на каждом шаге, это постоянный дрейф. Остается ли результат выше нуля? Добавьте стоп из запроса агенту и посмотрите, как меняются числа.
- Скачайте дневные цены закрытия двух монет, которые, по вашему мнению, связаны, например через публичный API биржи, как в уроке 9, и посчитайте их спред и отклонение. Спред плоский или блуждает?
Что дальше
У вас появилась третья сделка для бота. Урок 16 является финишем: бот работает в бумажном режиме с дашбордом, README и публичным репозиторием, а вы показываете его другим людям.