Я занимаюсь фьючерсным межбиржевым арбитражем. Для тех, кто не знает про что пойдет речь, суть простая: один и тот же контракт (например, 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.