#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ ИСТОЧНИКИ ДЛЯ АРБИТРАЖА — bulk-загрузка перпов и спота с 6+ бирж. Принцип тот же, что в price_sources.py: НИКОГДА не верить одной бирже. Здесь это критично вдвойне, потому что единицы измерения фандинга у всех разные. ЗАМЕР С АРЕНЫ (US), 2026-07-25: binance fapi ❌ 451 bybit ❌ 403 ← гео-блок касается США bingx ✅ bitunix ✅ mexc ✅ gate ✅ kucoin ✅ okx ✅ ОБНОВЛЕНИЕ 2026-08-01: binance и bybit ВЕРНУЛИСЬ — через релей на сервере 35 (europe-north1, Финляндия), где обе биржи отвечают 200. Итого 8 источников. ГЛАВНАЯ ЛОВУШКА — ЕДИНИЦЫ ФАНДИНГА. bitunix отдаёт BTC как 0.002368, все остальные как ~0.000022. Это НЕ аномальный фандинг, это ПРОЦЕНТЫ вместо доли. Принять за долю → 0.2368% × 3 раза в сутки × 365 = 259% годовых на BTC, и весь топ алертов навсегда занят биржей с неправильными единицами. Поэтому масштаб КАЖДОЙ биржи выводится из данных (calibrate_scales), а не зашивается в код: если API однажды поменяет формат, мы это увидим, а не съедим. ИНТЕРВАЛЫ ТОЖЕ РАЗНЫЕ: часы (bingx, bitunix, mexc), секунды (gate), миллисекунды (kucoin), дельта таймстампов (okx). Ставка 0.01% за 1 час и за 8 часов отличаются по доходности в 8 раз — нормализуем к APR. """ import json, os, re, statistics, time, urllib.request, urllib.error UA = {"User-Agent": "Mozilla/5.0 (compatible; SovereignArena/1.0)"} TIMEOUT = 15 def _get(url, timeout=TIMEOUT): req = urllib.request.Request(url, headers=UA) with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read().decode()) def _f(x, default=None): try: v = float(x) return v if v == v and abs(v) != float("inf") else default except (TypeError, ValueError): return default # ─────────────────────────── нормализация тикеров ─────────────────────────── _KU_BASE = {"XBT": "BTC"} def norm(sym, venue=""): """Любой формат → BASEUSDT. BTC-USDT-SWAP / BTC_USDT / XBTUSDTM → BTCUSDT.""" if not sym: return None s = str(sym).upper().replace("-SWAP", "").replace("_", "").replace("-", "") if venue == "kucoin-perp" and s.endswith("M"): s = s[:-1] # XBTUSDTM → XBTUSDT if not s.endswith("USDT"): return None base = s[:-4] base = _KU_BASE.get(base, base) if not base or not re.fullmatch(r"[A-Z0-9]+", base): return None return base + "USDT" # ─────────────────────────────── ПЕРПЫ (фандинг) ───────────────────────────── # Каждый загрузчик возвращает {SYMBOL: {rate, interval_h, next_ms, mark, vol_usd}} # rate — В ТОМ ВИДЕ, В КАКОМ ОТДАЛА БИРЖА. Масштаб применяется позже. def perp_bingx(): prem = _get("https://open-api.bingx.com/openApi/swap/v2/quote/premiumIndex").get("data") or [] tick = _get("https://open-api.bingx.com/openApi/swap/v2/quote/ticker").get("data") or [] vol = {norm(t.get("symbol")): _f(t.get("quoteVolume"), 0) for t in tick} out = {} for r in prem: s = norm(r.get("symbol")) if not s: continue rate = _f(r.get("lastFundingRate")) ih = _f(r.get("fundingIntervalHours"), 8) if rate is None: continue out[s] = {"rate": rate, "interval_h": ih or 8, "next_ms": _f(r.get("nextFundingTime"), 0), "mark": _f(r.get("markPrice")), "vol_usd": vol.get(s, 0) or 0} return out def perp_bitunix(): fr = _get("https://fapi.bitunix.com/api/v1/futures/market/funding_rate/batch").get("data") or [] tk = _get("https://fapi.bitunix.com/api/v1/futures/market/tickers").get("data") or [] vol = {norm(t.get("symbol")): _f(t.get("quoteVol"), 0) for t in tk} out = {} for r in fr: s = norm(r.get("symbol")) rate = _f(r.get("fundingRate")) if not s or rate is None: continue out[s] = {"rate": rate, "interval_h": _f(r.get("fundingInterval"), 8) or 8, "next_ms": _f(r.get("nextFundingTime"), 0), "mark": _f(r.get("markPrice")), "vol_usd": vol.get(s, 0) or 0} return out def perp_mexc(): fr = _get("https://contract.mexc.com/api/v1/contract/funding_rate").get("data") or [] tk = _get("https://contract.mexc.com/api/v1/contract/ticker").get("data") or [] vol = {norm(t.get("symbol")): _f(t.get("amount24"), 0) for t in tk} out = {} for r in fr: s = norm(r.get("symbol")) rate = _f(r.get("fundingRate")) if not s or rate is None: continue out[s] = {"rate": rate, "interval_h": _f(r.get("collectCycle"), 8) or 8, "next_ms": _f(r.get("nextSettleTime"), 0), "mark": _f(r.get("fairPrice")), "vol_usd": vol.get(s, 0) or 0} return out def perp_gate(): rows = _get("https://api.gateio.ws/api/v4/futures/usdt/contracts") # trade_size в contracts — это ЧИСЛО КОНТРАКТОВ, а не деньги. Настоящий # оборот в USDT только в /tickers; без него фильтр ликвидности врал. tk = _get("https://api.gateio.ws/api/v4/futures/usdt/tickers") vol = {norm(t.get("contract")): _f(t.get("volume_24h_settle"), 0) or 0 for t in (tk if isinstance(tk, list) else [])} out = {} for r in rows if isinstance(rows, list) else []: s = norm(r.get("name")) rate = _f(r.get("funding_rate")) if not s or rate is None or r.get("in_delisting"): continue secs = _f(r.get("funding_interval"), 28800) or 28800 # ← СЕКУНДЫ out[s] = {"rate": rate, "interval_h": secs / 3600.0, "next_ms": _f(r.get("funding_next_apply"), 0) * 1000, "mark": _f(r.get("mark_price")), "vol_usd": vol.get(s, 0)} return out def perp_kucoin(): rows = _get("https://api-futures.kucoin.com/api/v1/contracts/active").get("data") or [] out = {} for r in rows: s = norm(r.get("symbol"), "kucoin-perp") rate = _f(r.get("fundingFeeRate")) if not s or rate is None: continue gran = _f(r.get("fundingRateGranularity"), 28800000) or 28800000 # ← МИЛЛИСЕКУНДЫ out[s] = {"rate": rate, "interval_h": gran / 3600000.0, "next_ms": 0, "mark": _f(r.get("markPrice")), "vol_usd": _f(r.get("turnoverOf24h"), 0) or 0} return out def perp_okx(): fr = _get("https://www.okx.com/api/v5/public/funding-rate?instId=ANY").get("data") or [] tk = _get("https://www.okx.com/api/v5/market/tickers?instType=SWAP").get("data") or [] px, vol = {}, {} for t in tk: s = norm(t.get("instId")) if s: px[s] = _f(t.get("last")) vol[s] = _f(t.get("volCcy24h"), 0) or 0 out = {} for r in fr: s = norm(r.get("instId")) rate = _f(r.get("fundingRate")) if not s or rate is None: continue t0, t1 = _f(r.get("fundingTime"), 0), _f(r.get("nextFundingTime"), 0) ih = (t1 - t0) / 3600000.0 if t1 and t0 and t1 > t0 else 8 out[s] = {"rate": rate, "interval_h": ih if 0.5 <= ih <= 24 else 8, "next_ms": t0, "mark": px.get(s), "vol_usd": (vol.get(s, 0) or 0) * (px.get(s) or 0)} return out # ───────────────── BINANCE и BYBIT через релей на сервере 35 ───────────────── # # С арены (us-central1) fapi.binance.com отдаёт 451, api.bybit.com — 403: # гео-блок этих бирж касается США. Сервер 35 стоит в europe-north1 (Финляндия), # и оттуда обе биржи отвечают 200 — проверено запросом с самой машины. # # Поэтому фандинг этих двух бирж забирает релей на 35 и отдаёт нам по # ВНУТРЕННЕЙ сети VPC. Наружу порт не открыт и правила firewall для него нет. # # Если релей молчит — обе биржи просто выпадают из расчёта, как любая другая # недоступная. Радар от этого не ломается: он и жил без них всю неделю. FIN35_URL = os.environ.get("FIN35_RELAY", "http://10.166.0.2:8112/funding") _fin35_cache = {"data": None, "ts": 0} def _fin35(log=print): """Один запрос на оба источника, результат кэшируется на минуту.""" if _fin35_cache["data"] is not None and time.time() - _fin35_cache["ts"] < 60: return _fin35_cache["data"] d = _get(FIN35_URL, timeout=20) if d.get("stale"): log(f" ⚠️ релей 35 отдаёт данные возрастом {d.get('age_s')}с") _fin35_cache["data"] = d.get("venues") or {} _fin35_cache["ts"] = time.time() return _fin35_cache["data"] def perp_binance(): return _fin35().get("binance") or {} def perp_bybit(): return _fin35().get("bybit") or {} PERP = {"bingx": perp_bingx, "bitunix": perp_bitunix, "mexc": perp_mexc, "gate": perp_gate, "kucoin": perp_kucoin, "okx": perp_okx, "binance": perp_binance, "bybit": perp_bybit} # ───────────────────────────────── СПОТ (bid/ask) ──────────────────────────── def spot_binance(): # ticker/24hr вместо bookTicker: те же bid/ask, но ЕЩЁ И ОБОРОТ. # Без оборота спред-сканер показывает сверху мёртвые пары с зависшей # котировкой (живой пример: EUR на bingx, volume=0, «спред» 1.4%). rows = _get("https://data-api.binance.vision/api/v3/ticker/24hr") out = {} for r in rows: s = norm(r.get("symbol")) b, a = _f(r.get("bidPrice")), _f(r.get("askPrice")) if s and b and a and a > b > 0: out[s] = {"bid": b, "ask": a, "vol_usd": _f(r.get("quoteVolume"), 0) or 0} return out def spot_mexc(): rows = _get("https://api.mexc.com/api/v3/ticker/24hr") out = {} for r in rows: s = norm(r.get("symbol")) b, a = _f(r.get("bidPrice")), _f(r.get("askPrice")) if s and b and a and a > b > 0: out[s] = {"bid": b, "ask": a, "vol_usd": _f(r.get("quoteVolume"), 0) or 0} return out def spot_gate(): rows = _get("https://api.gateio.ws/api/v4/spot/tickers") out = {} for r in rows if isinstance(rows, list) else []: s = norm(r.get("currency_pair")) b, a = _f(r.get("highest_bid")), _f(r.get("lowest_ask")) if s and b and a and a > b > 0: out[s] = {"bid": b, "ask": a, "vol_usd": _f(r.get("quote_volume"), 0) or 0} return out def spot_kucoin(): rows = (_get("https://api.kucoin.com/api/v1/market/allTickers").get("data") or {}).get("ticker") or [] out = {} for r in rows: s = norm(r.get("symbol")) b, a = _f(r.get("buy")), _f(r.get("sell")) if s and b and a and a > b > 0: out[s] = {"bid": b, "ask": a, "vol_usd": _f(r.get("volValue"), 0) or 0} return out def spot_bingx(): rows = _get("https://open-api.bingx.com/openApi/spot/v1/ticker/24hr").get("data") or [] out = {} for r in rows: s = norm(r.get("symbol")) vol = _f(r.get("quoteVolume"), 0) or 0 if not s or vol <= 0: continue # ← пары с нулевым оборотом: котировка заморожена, не цена b, a = _f(r.get("bidPrice")), _f(r.get("askPrice")) if not (b and a): b = a = _f(r.get("lastPrice")) if b and a and a >= b > 0: out[s] = {"bid": b, "ask": a, "vol_usd": vol} return out SPOT = {"binance": spot_binance, "mexc": spot_mexc, "gate": spot_gate, "kucoin": spot_kucoin, "bingx": spot_bingx} # ───────────────────── КАЛИБРОВКА ЕДИНИЦ (защита от bitunix) ───────────────── CAL_SYMBOLS = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "DOGEUSDT", "BNBUSDT", "ADAUSDT", "LINKUSDT", "AVAXUSDT", "LTCUSDT"] def calibrate_scales(perps, log=print): """ Выводит множитель единиц для каждой биржи ИЗ ДАННЫХ. Берём мажоры, где фандинг на всех биржах физически обязан быть близким (арбитражёры не дают разъехаться больше чем на десятые процента). Считаем медиану |rate| по каждой бирже и общую медиану по всем. Отношение ≈1 → доля; ≈100 → проценты; иначе биржа помечается unreliable. Именно так ловится bitunix: его медиана в ~100 раз больше общей. """ # СРАВНИВАЕМ СТАВКИ, ПРИВЕДЁННЫЕ К ЧАСУ, А НЕ СЫРЫЕ. # Сырое сравнение путает две разные вещи: чужие единицы измерения и # чужой интервал. Биржа с ЧАСОВЫМ фандингом даёт сырую ставку примерно # в 8 раз меньше восьмичасового эталона → ratio ≈ 0.125 → проваливается # между полосами 0.05 и 0.2 и МОЛЧА ВЫКЛЮЧАЕТСЯ вместе со всеми своими # инструментами. Часовые интервалы в данных уже есть (DEXE на bingx и # bitunix), так что это не гипотетический случай. # После деления на interval_h остаётся ровно один источник расхождения — # единицы, а это и есть то, что мы хотим измерить. per_venue = {} for v, book in perps.items(): vals = [abs(book[s]["rate"]) / (book[s].get("interval_h") or 8) for s in CAL_SYMBOLS if s in book and book[s]["rate"]] if len(vals) >= 3: per_venue[v] = statistics.median(vals) if len(per_venue) < 3: log(" ⚠️ калибровка невозможна: мало бирж с мажорами") return {v: 1.0 for v in perps}, {} # общая медиана по НОРМАЛИЗОВАННЫМ кандидатам: пробуем 1 и 0.01 guesses = {} for v, m in per_venue.items(): guesses[v] = m ref = statistics.median(sorted(guesses.values())) scales, report = {}, {} for v, m in per_venue.items(): ratio = m / ref if ref else 1.0 if 0.2 <= ratio <= 5: sc, verdict = 1.0, "доля" elif 20 <= ratio <= 500: sc, verdict = 0.01, "ПРОЦЕНТЫ → ×0.01" elif 0.002 <= ratio <= 0.05: sc, verdict = 100.0, "б.п.? → ×100" else: sc, verdict = 0.0, f"НЕ РАСПОЗНАНО (×{ratio:.3g}) → биржа выключена" scales[v] = sc report[v] = {"median_abs": m, "ratio_to_ref": ratio, "scale": sc, "verdict": verdict} for v in perps: scales.setdefault(v, 1.0) return scales, report def load_perps(venues=None, log=print): """Грузит все перп-биржи, калибрует единицы, применяет масштаб.""" perps, errors = {}, {} for v, fn in PERP.items(): if venues and v not in venues: continue try: t0 = time.time() perps[v] = fn() log(f" перп {v:8} {len(perps[v]):>4} инстр. {int((time.time()-t0)*1000)}мс") except Exception as e: errors[v] = f"{type(e).__name__}: {e}" log(f" перп {v:8} ❌ {errors[v][:60]}") scales, report = calibrate_scales(perps, log=log) for v, rep in sorted(report.items()): mark = "✅" if rep["scale"] == 1.0 else ("🔧" if rep["scale"] else "🚫") log(f" {mark} единицы {v:8} медиана {rep['median_abs']:.3e} " f"×{rep['ratio_to_ref']:.1f} от эталона → {rep['verdict']}") for v, book in list(perps.items()): sc = scales.get(v, 1.0) if sc == 0.0: perps.pop(v) continue if sc != 1.0: for d in book.values(): d["rate"] *= sc return perps, report, errors def load_spots(venues=None, log=print): spots, errors = {}, {} for v, fn in SPOT.items(): if venues and v not in venues: continue try: t0 = time.time() spots[v] = fn() log(f" спот {v:8} {len(spots[v]):>4} пар {int((time.time()-t0)*1000)}мс") except Exception as e: errors[v] = f"{type(e).__name__}: {e}" log(f" спот {v:8} ❌ {errors[v][:60]}") return spots, errors def binance_universe(log=print): """Список торгуемых USDT-пар на Binance — фильтр качества для всей выборки.""" try: info = _get("https://data-api.binance.vision/api/v3/exchangeInfo") u = {s["symbol"] for s in info.get("symbols", []) if s.get("status") == "TRADING" and s.get("quoteAsset") == "USDT"} log(f" вселенная Binance: {len(u)} USDT-пар") return u except Exception as e: log(f" ⚠️ вселенная Binance недоступна ({type(e).__name__}) — фильтр отключён") return set() if __name__ == "__main__": print("── перпы ──") perps, rep, err = load_perps() print("── спот ──") spots, serr = load_spots() print("── вселенная ──") uni = binance_universe() common = set.intersection(*[set(b) for b in perps.values()]) if perps else set() print(f"\nобщих символов на ВСЕХ перп-биржах: {len(common)}") for s in ["BTCUSDT", "ETHUSDT", "SOLUSDT"]: line = [f"{v}:{b[s]['rate']*100:+.4f}%/{b[s]['interval_h']:.0f}ч" for v, b in sorted(perps.items()) if s in b] print(f" {s:9} " + " ".join(line))