#!/usr/bin/env python3 """ ИСТОЧНИКИ ЦЕН — единый модуль для всей грид-инфраструктуры. ЗАЧЕМ: арена стоит в us-central1, а Binance отдаёт американским IP HTTP 451. Из-за этого при первом деплое пришлось довольствоваться Kraken/Coinbase — а там нет лоу-капов, на которых грид-лаба и показала лучший доход на сделку. ЧТО ЗДЕСЬ: десять независимых источников с общим интерфейсом. Каждый возвращает свечу {"ts","o","h","l","c"} и умеет отдать список своих символов. Порядок в SOURCES = приоритет: сначала биржи с самой широкой номенклатурой лоу-капов, в конце — заведомо доступные из США. binance полная номенклатура, 451 из США binance-vision публичное зеркало данных Binance (другой CDN — может пройти) bybit широкая номенклатура bingx широкая номенклатура ← добавлено по указанию Robert bitunix спот, interval числом ← добавлено по указанию Robert mexc больше всего лоу-капов вообще gate широкая номенклатура kucoin широкая номенклатура kraken мало альтов, но США дружелюбен coinbase мало альтов, США дружелюбен ИСПОЛЬЗОВАНИЕ: from price_sources import last_candle, probe, symbol_available c = last_candle("LINKUSDT") ПРОВЕРКА С МАШИНЫ (главное — гонять ИМЕННО НА АРЕНЕ, не в песочнице): python3 price_sources.py --probe # кто отвечает и с какой ценой python3 price_sources.py --probe --symbol SXTUSDT python3 price_sources.py --where SXTUSDT ALCXUSDT EPICUSDT # где есть символ """ import json, sys, time, urllib.error, urllib.request TIMEOUT = 15 UA = {"User-Agent": "Mozilla/5.0 (compatible; sovereign-grid/2.0)"} # расхождение цен между источниками, выше которого источник считается лгущим. # 3% — заведомо больше спреда между биржами, но заведомо меньше "не тот тикер" # (случай QUICK: Coinbase отдавал $48 при реальных $0.0075). SANITY_PCT = 3.0 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 _c(ts, o, h, l, c): return {"ts": int(ts), "o": float(o), "h": float(h), "l": float(l), "c": float(c)} # ───────────────────────────── источники ───────────────────────────── # Каждый берёт ПРЕДПОСЛЕДНЮЮ свечу — последняя ещё формируется и её # high/low растут задним числом, что даёт фантомные срабатывания сетки. def _binance(sym, host="api.binance.com"): d = _get(f"https://{host}/api/v3/klines?symbol={sym}&interval=1m&limit=2") k = d[-2] return _c(k[0], k[1], k[2], k[3], k[4]) def _binance_vision(sym): return _binance(sym, host="data-api.binance.vision") BYBIT_HOSTS = ["api.bybit.com", "api.bytick.com", "api.bybit.nl"] _bybit_host = None def _bybit(sym): """Bybit отдаёт 403 американским IP на основном домене. bytick.com — его же официальное зеркало, гео-фильтр там другой.""" global _bybit_host hosts = ([_bybit_host] + [h for h in BYBIT_HOSTS if h != _bybit_host]) \ if _bybit_host else BYBIT_HOSTS last = None for h in hosts: try: d = _get(f"https://{h}/v5/market/kline?category=spot&symbol={sym}&interval=1&limit=2") k = d["result"]["list"][1] # от нового к старому _bybit_host = h return _c(k[0], k[1], k[2], k[3], k[4]) except Exception as e: last = e raise last def _bingx(sym): p = sym.replace("USDT", "-USDT") d = _get(f"https://open-api.bingx.com/openApi/spot/v1/market/kline?symbol={p}&interval=1m&limit=3") rows = d["data"] if not rows: raise ValueError("bingx: пустой ответ") rows = sorted(rows, key=lambda r: r[0]) k = rows[-2] if len(rows) > 1 else rows[-1] # [openTime, open, high, low, close, volume, closeTime, quoteVol] return _c(k[0], k[1], k[2], k[3], k[4]) def _bitunix(sym): # interval передаётся ЧИСЛОМ минут; "1m"/"1min" дают Parameter Error. # ts приходит строкой ISO ("2026-07-25T09:21:00Z"), а не миллисекундами. d = _get(f"https://openapi.bitunix.com/api/spot/v1/market/kline?symbol={sym}&interval=1") rows = d.get("data") or [] if not rows: raise ValueError("bitunix: пустой ответ") def _ms(v): if isinstance(v, (int, float)): return int(v) s = str(v) if s.isdigit(): return int(s) from datetime import datetime, timezone return int(datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ") .replace(tzinfo=timezone.utc).timestamp() * 1000) rows = sorted(rows, key=lambda r: _ms(r["ts"])) k = rows[-2] if len(rows) > 1 else rows[-1] return _c(_ms(k["ts"]), k["open"], k["high"], k["low"], k["close"]) def _mexc(sym): d = _get(f"https://api.mexc.com/api/v3/klines?symbol={sym}&interval=1m&limit=2") k = d[-2] return _c(k[0], k[1], k[2], k[3], k[4]) def _gate(sym): p = sym.replace("USDT", "_USDT") d = _get(f"https://api.gateio.ws/api/v4/spot/candlesticks?currency_pair={p}&interval=1m&limit=3") # [ts, quoteVol, close, high, low, open, baseVol, closed] k = d[-2] return _c(int(k[0]) * 1000, k[5], k[3], k[4], k[2]) def _kucoin(sym): p = sym.replace("USDT", "-USDT") d = _get(f"https://api.kucoin.com/api/v1/market/candles?symbol={p}&type=1min") rows = d["data"] # новые первыми k = rows[1] if len(rows) > 1 else rows[0] # [time, open, close, high, low, volume, turnover] return _c(int(k[0]) * 1000, k[1], k[3], k[4], k[2]) def _kraken(sym): base = sym.replace("USDT", "") pair = {"BTC": "XBTUSDT"}.get(base, base + "USDT") res = _get(f"https://api.kraken.com/0/public/OHLC?pair={pair}&interval=1")["result"] rows = next(v for k, v in res.items() if k != "last") k = rows[-2] return _c(int(k[0]) * 1000, k[1], k[2], k[3], k[4]) def _coinbase(sym): prod = sym.replace("USDT", "-USD") d = _get(f"https://api.exchange.coinbase.com/products/{prod}/candles?granularity=60") k = d[1] # [time, low, high, open, close, volume] return _c(int(k[0]) * 1000, k[3], k[2], k[1], k[4]) SOURCES = [ ("binance", _binance), ("binance-vision", _binance_vision), ("bybit", _bybit), ("bingx", _bingx), ("bitunix", _bitunix), ("mexc", _mexc), ("gate", _gate), ("kucoin", _kucoin), ("kraken", _kraken), ("coinbase", _coinbase), ] BY_NAME = dict(SOURCES) _active = None # источник, ответивший в прошлый раз — пробуем его первым _blacklist = {} # имя → время, до которого не трогаем (после отказа) COOLDOWN = 600 # 10 минут: не долбить биржу, которая отдала 451 def _order(): now = time.time() live = [s for s in SOURCES if _blacklist.get(s[0], 0) < now] if not live: _blacklist.clear() live = list(SOURCES) if _active: live = [s for s in live if s[0] == _active] + [s for s in live if s[0] != _active] return live def last_candle(symbol, verbose=True): """Свеча с первого источника, который ответил. Отказавший уходит в кулдаун.""" global _active errs = [] for name, fn in _order(): try: c = fn(symbol) if c["c"] <= 0: raise ValueError("нулевая цена") if _active != name: if verbose: print(f"[price] источник: {name}", flush=True) _active = name return c except Exception as e: code = getattr(e, "code", None) errs.append(f"{name}:{code or type(e).__name__}") # 451/403 = гео-блок, это надолго; прочее может быть разовым _blacklist[name] = time.time() + (COOLDOWN if code in (451, 403) else 60) if name == _active: _active = None raise RuntimeError("все источники цен недоступны — " + ", ".join(errs)) # ────────────────────── проверка перед запуском бота ────────────────────── def probe(symbol="LINKUSDT"): """Опросить ВСЕ источники. Возвращает список результатов и медианную цену.""" out = [] for name, fn in SOURCES: t0 = time.time() try: c = fn(symbol) out.append({"src": name, "ok": True, "price": c["c"], "ms": int((time.time() - t0) * 1000)}) except Exception as e: out.append({"src": name, "ok": False, "err": f"{getattr(e,'code','') or type(e).__name__}", "ms": int((time.time() - t0) * 1000)}) ok = sorted(r["price"] for r in out if r["ok"]) med = ok[len(ok) // 2] if ok else None for r in out: if r["ok"] and med: r["dev_pct"] = (r["price"] / med - 1) * 100 r["sane"] = abs(r["dev_pct"]) <= SANITY_PCT return out, med def symbol_available(symbol, need=2): """ Есть ли символ минимум на `need` источниках И сходятся ли цены. Ровно та проверка, которой не хватило на QUICK: Coinbase отдавал $48 при реальных $0.0075 — сетка встала бы по мусорной цене. """ res, med = probe(symbol) good = [r for r in res if r["ok"] and r.get("sane")] return { "symbol": symbol, "available": len(good) >= need and med is not None, "sources": [r["src"] for r in good], "price": med, "rejected": [f"{r['src']}({r['dev_pct']:+.0f}%)" for r in res if r["ok"] and not r.get("sane")], } def _cli(): args = sys.argv[1:] if "--probe" in args: sym = "LINKUSDT" if "--symbol" in args: sym = args[args.index("--symbol") + 1] res, med = probe(sym) print(f"\n{sym} · медиана {med}\n" + "-" * 62) for r in res: if r["ok"]: flag = "✅" if r.get("sane") else "⚠️ ЦЕНА НЕ СХОДИТСЯ" print(f" {r['src']:16} {r['price']:>14.8g} {r.get('dev_pct',0):+6.2f}% " f"{r['ms']:>5}мс {flag}") else: print(f" {r['src']:16} {'—':>14} {'':>6} {r['ms']:>5}мс ❌ {r['err']}") live = [r["src"] for r in res if r["ok"] and r.get("sane")] print(f"\nрабочих источников: {len(live)}/{len(res)} → {', '.join(live)}") return if "--where" in args: syms = args[args.index("--where") + 1:] print(f"{'символ':<12} {'цена':>13} источники") print("-" * 78) for s in syms: r = symbol_available(s) mark = "✅" if r["available"] else "❌" price = f"{r['price']:.8g}" if r["price"] else "—" print(f"{mark} {s:<10} {price:>13} {', '.join(r['sources']) or 'нет'}" + (f" отвергнуты: {', '.join(r['rejected'])}" if r["rejected"] else "")) return print(__doc__) if __name__ == "__main__": _cli()