Я занимаюсь фьючерсным межбиржевым арбитражем. Для тех, кто не знает про что пойдет речь, суть простая: один и тот же контракт (например, ETHUSDT perpetual) торгуется на нескольких биржах одновременно. Цены всегда немного отличаются. Покупаешь где дешевле, продаёшь где дороже - разница твоя, независимо направления рынка и от того, куда же дальше пойдет цена.
Казалось бы, звучит все максимально элементарно, но на практике обнаруживается куча подводных камней.
За несколько месяцев я написал систему, которая параллельно мониторит 4 биржи (Bybit, MEXC, BingX, HTX), рассчитывает спреды по ~450 торговым парам (число зависит от наличия контрактов на биржах) каждые 5 секунд, сохраняет историю в базу данных на PostgreSQL и ищет устойчивые паттерны. В процессе написания я наступил на всевозможные логические грабли и понял, как сделать так, чтобы мой арбитражный бот не показывал прибыль, которой не существует на практике.
В этой статье - три ключевых урока, которые мне пришлось усвоить, каждый из которых стоил мне дней отладки.
Урок 1: Четыре биржи - четыре языка
Первая задача выглядела простой: получить текущие цены со всех бирж. Один GET-запрос к каждой. Что же может пойти не так?
Оказалось, всё.
Каждая биржа - это отдельная вселенная со своим API, своими названиями полей и своими ограничениями.
Один и тот же символ ETHUSDT:
Bybit: symbol = "ETHUSDT" MEXC: symbol = "ETH_USDT" BingX: symbol = "ETH-USDT" HTX: symbol = "ETH-USDT"
И везде разные эндпоинты!
Один и тот же объём торгов за 24 часа:
Bybit: turnover24h (в USDT, то что нужно) MEXC: amount24 (в USDT) BingX: quoteVolume (в USDT) HTX: trade_turnover (в USDT, но на другом endpoint)
Один и тот же Open Interest:
Bybit: openInterestValue (уже в USDT) MEXC: holdVol (в контрактах! Нужен contractSize × price) BingX: openInterest (в монетах! Требует умножения на price. Но иногда уже в USDT) HTX: trade_turnover (в USDT, но из совершенно ДРУГОГО endpoint)
Биржа HTX же в свою очередь вообще не отдаёт mark price и index price в batch endpoint для тикеров. Приходится считать mid-price из (best_bid + best_ask) / 2 как приближение.
В качестве решения логичным показался переход к абстрактному базовому классу BaseExchange и четырём адаптерам. Каждый адаптер знает причуды своей биржи и нормализует данные в единую структуру TickerData:
@dataclass class TickerData: symbol: str # Всегда "ETHUSDT" (нормализованный) exchange: str # "bybit" / "mexc" / "bingx" / "htx" last_price: float best_bid: float # Лучшая цена покупки best_ask: float # Лучшая цена продажи funding_rate: float volume_24h: float # Всегда в USDT open_interest: float # Всегда в USDT bid_size: float # Объём в стакане ask_size: float ...
Один формат - один алгоритм расчёта спредов. Биржа добавляется написанием нового адаптера на ~150-400 строк, остальной код не меняется. Весьма удобно, как по мне.
Ещё одним спасением стала параллельность. Четыре биржи опрашиваются одновременно через ThreadPoolExecutor:
with ThreadPoolExecutor(max_workers=4) as executor: futures = { executor.submit(self._fetch_single_exchange, name): name for name in self._instances.keys() } for future in as_completed(futures): snapshot = future.result(timeout=15)
Почему это критично: если опрашивать последовательно, то данные с первой биржи устаревают, пока грузится четвёртая. При параллельных запросах разница во времени между ответами (timing gap) обычно < 300ms. Если gap > 500ms - данные помечаются как потенциально неточные.
Урок 2: Биржи не любят, когда их отвлекают
HTX разрешает 10 запросов в секунду. Bybit - 120 в минуту. MEXC - 20 в секунду. BingX - 100 в минуту.
При цикле обновления каждые 5 секунд я получал 429 Too Many Requests от HTX через пару минут работы. Хуже того - после нескольких 429 подряд биржа начинала банить IP на 5-10 минут и больше (при дальнейших попытках получить данные с биржи).
Решением стал адаптивный rate limiter отдельным файлом в качестве модуля к проекту.
# Лимиты хардкодом из документации каждой биржи DEFAULT_LIMITS = { "bybit": ExchangeLimits(max_requests=120, period_seconds=60, min_interval=0.1), "mexc": ExchangeLimits(max_requests=20, period_seconds=1, min_interval=0.15), "bingx": ExchangeLimits(max_requests=100, period_seconds=60, min_interval=0.1), "htx": ExchangeLimits(max_requests=10, period_seconds=1, min_interval=0.2), }
Перед каждым запросом - rate_limiter.wait_if_needed(exchange). Если лимит исчерпан - поток спокойно спит ровно столько, сколько нужно. Если биржа ответила 429 - backoff (откат) увеличивается в 1.5x. При продолжительных успешных запросах - плавно возвращается к 1.0x (через умножение на 0.9).
Но есть важный нюанс: sleep выполняется за пределами lock-а. В противном же случае один поток, ожидающий HTX, заблокировал бы запросы к Bybit:
with self._lock: wait_time = self._calculate_wait(exchange) # Sleep БЕЗ lock - другие потоки не заблокированы if wait_time > 0: time.sleep(wait_time) with self._lock: self._request_times[exchange].append(time.monotonic())
Ещё один сюрприз, подкинутый биржами, - SSL. HTX периодически отдаёт невалидные сертификаты. После двух SSL-ошибок подряд адаптер автоматически переключается на verify=False и продолжает работать. Не идеально с точки зрения безопасности, но для публичного API без авторизации - на мой взгляд допустимо.
Урок 3: Спред, которого не существует
Вопреки возможным ожиданиям это был самый болезненный урок.
Представьте ситуацию: вы запустили радар, увидели спред 0.45% на условном ETHUSDT между Bybit и MEXC, посчитали в голове: "При позиции $1000 и с плечом 5x это ~$22 прибыли на сделку!". Звучит и правда отлично.
Пришло время проверять вручную. Вы открыли оба стакана и поняли, что этого спреда не существует. Как это произошло?
А проблема заключается вот в чём: last_price - это цена последней совершённой сделки. Но когда ты покупаешь, ты платишь по ask (цена продавца). Когда продаёшь - получаешь bid (цена поку��ателя). А bid всегда ниже ask.
Приведу пример:
Bybit ETHUSDT: last_price = $3,200.50 best_bid = $3,200.20 (по этой цене можешь ПРОДАТЬ) best_ask = $3,200.80 (по этой цене можешь КУПИТЬ) MEXC ETHUSDT: last_price = $3,204.80 best_bid = $3,204.30 (продать) best_ask = $3,205.20 (купить)
Наивный спред (по last_price):
(3204.80 - 3200.50) / 3200.50 = 0.134%
Реальный executable спред (Long Bybit по ask, Short MEXC по bid):
(3204.30 - 3200.80) / 3200.80 = 0.109%
Разница - 23%. И это на ликвидном ETH. На альткоинах с широким стаканом executable спред бывает гораздо меньше наивного. Иногда executable спред может быть и отрицательным - то есть сделка убыточна ещё до открытия.
Как я это исправил и реализовал:
# Два варианта направления: # Вариант 1 Long A (покупаем по ask_a), Short B (продаём по bid_b) exec_spread_1 = (bid_b - ask_a) / ask_a * 100 # Вариант 2 Long B (покупаем по ask_b), Short A (продаём по bid_a) exec_spread_2 = (bid_a - ask_b) / ask_b * 100 # Автоматически выбираем лучший вариант if exec_spread_1 >= exec_spread_2: long_ticker = ticker_a # покупаем где дешевле по ask short_ticker = ticker_b # продаём где дороже по bid executable_spread = exec_spread_1
Теперь система автоматически определяет оптимальное направление. В таблице, которую выводит радар при работе, два столбца: Spread (по last_price, исключительно информационный) и Exec (реальный исполнимый). Фильтрация и ранжирование - по Exec.
Но спред - это ведь не вся картина. Есть ещё funding rate.
Дополнительный урок, до которого я догадался не сразу: Funding Rate - скрытый доход (ну или расход)
Как вы возможно знаете, каждые 8 часов (00:00, 08:00, 16:00 UTC) или чаще биржа проводит расчёт между лонгами и шортами. Если funding rate положительный - лонги платят шортам. Отрицательный - наоборот. Это инструмент бирж для естественного регулирования цены perpetual-контракта по отношению к его реальному аналогу.
Если у тебя long на бирже A и short на бирже B:
funding_diff = short_funding - long_funding
Если funding_diff > 0 - ты зарабатываешь на этой разнице. Это дополнительный доход сверх спреда, три раза в сутки, если мы удерживаем позицию.
Итоговый Net Score учитывает и спред, и funding rate:
daily_funding_benefit = funding_diff * 3 * 100 # 3 раза в день, в процентах net_score = 0.7 * executable_spread + 0.3 * daily_funding_benefit
Вес 0.7 на спред и 0.3 на funding - потому что спред ты видишь и фиксируешь прямо сейчас, а funding может измениться к моменту начисления.
Как при всем этом не убить PostgreSQL
При ~450 символах × 6 парах бирж × обновлении каждые 5 секунд - это потенциально 32,000 записей в минуту - многовато, правда? За сутки - больше 2 миллионов. За неделю - 15-20 миллионов.
Без throttling база разрастается на глазах.
Решение: двухуровневый throttling.
Спреды сохраняются не чаще раза в 10 секунд на каждую уникальную связку (символ + пара бирж). Метрики (OI, volume, funding) - раз в 30 секунд, и только для символов с объёмом > $500K.
def save_spreads_batch(self, spreads, force=False): if not force: with self._throttle_lock: filtered = [] for s in spreads: key = f"{s['symbol']}_{s['long_exchange']}_{s['short_exchange']}" last_save = self._last_spread_save.get(key) if last_save is None or (now - last_save) >= timedelta(seconds=10): filtered.append(s) self._last_spread_save[key] = now spreads = filtered
Параллельно - in-memory SpreadTracker на deque. Хранит последние 10 минут истории каждого спреда для real-time трендов (сходится/расходится/стабилен). Без обращения к БД.
Удаление старых данных - батчами по 50,000 записей через ctid (физический адрес строки в PostgreSQL), чтобы не блокировать таблицу.
А теперь архитектура целиком:
┌──────────────┐ │ 4 БИРЖИ │ └──────┬───────┘ │ REST API (каждые 5 сек, параллельно) ▼ ┌──────────────┐ │ Адаптеры │ ← Нормализация в TickerData └──────┬───────┘ │ ┌──────────┼──────────┐ ▼ ▼ ▼ ┌───────────┐ ┌────────┐ ┌────────┐ │ Spread │ │Metrics │ │ Spread │ │Calculator │ │ Engine │ │Tracker │ │(bid/ask) │ │(дельты)│ │(тренды)│ └─────┬─────┘ └───┬────┘ └───┬────┘ │ │ │ ▼ ▼ ▼ ┌─────────────────────────────────┐ │ PostgreSQL │ │ spreads │ metrics │ overheat │ └────────────────┬────────────────┘ │ ┌────────────┼────────────┐ ▼ ▼ ▼ ┌──────────┐ ┌─────────┐ ┌─────────┐ │Backtester│ │ Web │ │Telegram │ │Optimizer │ │Dashboard│ │ Alerts │ └──────────┘ └─────────┘ └─────────┘
А что дальше?
Радар показывает спреды в конкретный момент времени. Но самый ценный вопрос - можно ли предсказать спред ещё до его появления?
Оказывается, можно. Перед тем как цены на двух биржах разойдутся, происходят микросдвиги в других метриках: Open Interest растёт на одной бирже, funding rate ползёт, orderbook перекашивается. Как сейсмограф перед землетрясением.
В следующей статье расскажу, как я построил Overheat Score - систему предсказания спредов через дивергенцию метрик между биржами.
А пока - полную архитектуру, PDF с документацией и результаты работы радара в реальном времени выкладываю в свой ТГ-канал @SpreadHunterS.
