#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ ДВИЖОК АРБИТРАЖА — поиск возможностей и бумажная книга. Три типа возможностей, отсортированы по РЕАЛЬНОЙ исполнимости для человека с двумя вкладками браузера, а не по красоте числа: 1. CARRY спот+перп на ОДНОЙ бирже (купил монету, продал перп, собираешь фандинг). Переводов между биржами нет, гонки нет, ликвидации нет пока хватает залога. Самый исполнимый. 2. FUNDING перп на бирже A против перпа на бирже B, дельта-нейтрально. Тоже без гонки, но нужен залог на двух площадках и есть базисный риск: перпы могут разъехаться в цене. 3. SPREAD спот дешевле на одной бирже, дороже на другой. Красиво выглядит, ХУЖЕ ВСЕГО исполняется: без заранее разложенных денег на обеих биржах вы не успеете — перевод идёт 10-30 минут. ЧЕСТНАЯ АРИФМЕТИКА — почему тут всё считается «до часов окупаемости». Ставка 0.01% за 8 часов = 10.95% годовых, выглядит отлично. Но вход и выход стоят ~0.22% комиссий. Чтобы отбить один только вход-выход, позицию надо продержать 176 часов = неделю. Годовые без часов окупаемости — обман. ЗАЩИТА ОТ МУСОРА. Любой наивный сканер спреда показывает сверху мёртвые монеты с зависшей котировкой на одной бирже. Это ровно та ошибка, из-за которой 22 июля в сетку попал QUICK по $48 вместо $0.0075. Поэтому цена сверяется с медианой по всем биржам, и расхождение >3% считается СЛОМАННОЙ КОТИРОВКОЙ, а не возможностью. """ import json, math, sqlite3, statistics, time # ── комиссии тейкера, консервативно: без VIP-уровней и скидок за токен ── PERP_TAKER = {"bingx": 0.0005, "bitunix": 0.0006, "mexc": 0.0002, "gate": 0.0005, "kucoin": 0.0006, "okx": 0.0005, "binance": 0.0005, "bybit": 0.00055} SPOT_TAKER = {"binance": 0.0010, "mexc": 0.0005, "gate": 0.0020, "kucoin": 0.0010, "bingx": 0.0010} SLIP_PERP = 0.0003 # проскальзывание на ногу, консервативно SLIP_SPOT = 0.0003 HOURS_YEAR = 24 * 365 def apr(rate_per_interval, interval_h): """Ставка за интервал → годовые. Интервал у альтов бывает 1ч и 4ч, не только 8ч.""" if not interval_h or interval_h <= 0: return 0.0 return rate_per_interval / interval_h * HOURS_YEAR def _rate_h(d): return d["rate"] / d["interval_h"] if d.get("interval_h") else 0.0 # ────────────────────────── санитарный фильтр цен ─────────────────────────── def price_consensus(sym, perps, spots, tol=0.03): """ Медиана цены по всем биржам + список тех, кто от неё отъехал. Возвращает (медиана, {биржа: отклонение}) — сломанные котировки наружу. """ px = {} for v, book in perps.items(): d = book.get(sym) if d and d.get("mark"): px["perp:" + v] = d["mark"] for v, book in spots.items(): d = book.get(sym) if d: px["spot:" + v] = (d["bid"] + d["ask"]) / 2 if len(px) < 3: return None, {} med = statistics.median(px.values()) if not med: return None, {} return med, {k: (p - med) / med for k, p in px.items()} def sane(sym, perps, spots, tol=0.03): """Множество бирж, чья цена согласуется с медианой. Остальные — сломаны.""" med, dev = price_consensus(sym, perps, spots) if med is None: return None, set() return med, {k for k, d in dev.items() if abs(d) <= tol} # ─────────────────────────────── ПОИСК ВОЗМОЖНОСТЕЙ ────────────────────────── def scan(perps, spots, universe=None, min_vol_usd=300_000, min_apr=0.15, max_breakeven_h=168): """ Возвращает список возможностей, каждая — с ПОЛНОЙ экономикой: сколько платит, сколько стоит войти-выйти, через сколько часов окупится. """ out = [] syms = set() for b in perps.values(): syms |= set(b) if universe: syms &= universe # ← «список берётся тот, что на бинансе» for sym in syms: med, ok = sane(sym, perps, spots) if med is None: continue legs = {v: d for v, d in ((v, b.get(sym)) for v, b in perps.items()) if d and ("perp:" + v) in ok and (d.get("vol_usd") or 0) >= min_vol_usd} # ── 1. CARRY: спот + перп на одной бирже ── for v, d in legs.items(): sp = spots.get(v, {}).get(sym) if not sp or ("spot:" + v) not in ok: continue rh = _rate_h(d) if rh <= 0: # шорт перпа платит только при положительном фандинге continue basis = (d["mark"] - sp["ask"]) / sp["ask"] if d.get("mark") and sp["ask"] else 0.0 cost = 2 * (SPOT_TAKER.get(v, .001) + PERP_TAKER.get(v, .0005) + SLIP_SPOT + SLIP_PERP) a = rh * HOURS_YEAR if a < min_apr: continue be = (cost - basis) / rh if rh > 0 else 1e9 if be > max_breakeven_h or be < 0: if be < 0: be = 0.0 # базис уже покрывает вход else: continue out.append({"kind": "carry", "symbol": sym, "venue": v, "long": f"{v} спот", "short": f"{v} перп", "apr": a, "rate_h": rh, "interval_h": d["interval_h"], "basis": basis, "cost_round": cost, "breakeven_h": be, "price": med, "vol_usd": d.get("vol_usd", 0), "note": "одна биржа, переводов нет"}) # ── 2. FUNDING: перп против перпа ── if len(legs) >= 2: ranked = sorted(legs.items(), key=lambda kv: _rate_h(kv[1])) lo_v, lo = ranked[0] # тут лонг (платят нам или платим меньше) hi_v, hi = ranked[-1] # тут шорт (собираем фандинг) net_h = _rate_h(hi) - _rate_h(lo) # ЖИВУЧЕСТЬ ЭДЖА: пересчёт БЕЗ обеих крайних бирж. Если разрыв # держится только на одной бирже-выбросе — это, скорее всего, # особенность её формулы фандинга, а не рыночная дислокация. # Пример: ERA, четыре биржи дают −0.055%/ч, bingx +0.001%/ч. if len(ranked) >= 4: robust_h = _rate_h(ranked[-2][1]) - _rate_h(ranked[1][1]) else: robust_h = 0.0 if net_h > 0: cost = 2 * (PERP_TAKER.get(lo_v, .0005) + PERP_TAKER.get(hi_v, .0005) + 2 * SLIP_PERP) a = net_h * HOURS_YEAR be = cost / net_h if a >= min_apr and be <= max_breakeven_h: out.append({"apr_robust": robust_h * HOURS_YEAR, "venues_n": len(ranked), "kind": "funding", "symbol": sym, "long": f"{lo_v} перп", "short": f"{hi_v} перп", "long_venue": lo_v, "short_venue": hi_v, "apr": a, "rate_h": net_h, "interval_h": min(lo["interval_h"], hi["interval_h"]), "long_rate": lo["rate"], "short_rate": hi["rate"], "long_interval_h": lo["interval_h"], "short_interval_h": hi["interval_h"], "cost_round": cost, "breakeven_h": be, "price": med, "vol_usd": min(lo.get("vol_usd", 0), hi.get("vol_usd", 0)), "note": "залог на двух биржах, базисный риск"}) # ── 3. SPREAD: спот дешевле там, дороже тут ── # ОБЯЗАТЕЛЬНО обе стороны с оборотом: «спред» на паре, которой никто не # торгует, — это застывшая котировка. Живой пример: EUR на bingx, # оборот 0, open=high=low=last, «арбитраж» 1.4% — и он там вечно. sp_ok = {v: spots[v][sym] for v in spots if sym in spots[v] and ("spot:" + v) in ok and (spots[v][sym].get("vol_usd") or 0) >= min_vol_usd} if len(sp_ok) >= 2: cheap = min(sp_ok.items(), key=lambda kv: kv[1]["ask"]) rich = max(sp_ok.items(), key=lambda kv: kv[1]["bid"]) if cheap[0] != rich[0]: gross = (rich[1]["bid"] - cheap[1]["ask"]) / cheap[1]["ask"] cost = (SPOT_TAKER.get(cheap[0], .001) + SPOT_TAKER.get(rich[0], .001) + 2 * SLIP_SPOT) net = gross - cost # верхняя граница: настоящий спред на ликвидной паре 0.1-0.5%. # Больше 2% на паре с оборотом — почти всегда сломанные данные. if 0.001 < net < 0.02: out.append({"kind": "spread", "symbol": sym, "long": f"купить {cheap[0]}", "short": f"продать {rich[0]}", "buy_venue": cheap[0], "sell_venue": rich[0], "gross": gross, "cost_round": cost, "net": net, "apr": 0.0, "breakeven_h": 0.0, "price": med, "buy_px": cheap[1]["ask"], "sell_px": rich[1]["bid"], "vol_usd": min(cheap[1].get("vol_usd", 0) or 0, rich[1].get("vol_usd", 0) or 0), "note": "нужны деньги ЗАРАНЕЕ на обеих биржах"}) order = {"carry": 0, "funding": 1, "spread": 2} out.sort(key=lambda o: (order[o["kind"]], -(o["net"] if o["kind"] == "spread" else o["apr"]))) return out # ──────────────────────────────── БУМАЖНАЯ КНИГА ───────────────────────────── SCHEMA = """ CREATE TABLE IF NOT EXISTS opps( ts INTEGER, kind TEXT, symbol TEXT, apr REAL, net REAL, breakeven_h REAL, long_leg TEXT, short_leg TEXT, price REAL, vol_usd REAL); CREATE TABLE IF NOT EXISTS positions( id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT, symbol TEXT, long_leg TEXT, short_leg TEXT, opened_ms INTEGER, closed_ms INTEGER, notional REAL, entry_apr REAL, entry_cost REAL, exit_cost REAL, funding_collected REAL DEFAULT 0, spread_pnl REAL DEFAULT 0, state TEXT DEFAULT 'open', meta TEXT); CREATE TABLE IF NOT EXISTS settles( pos_id INTEGER, ts INTEGER, leg TEXT, rate REAL, amount REAL); CREATE TABLE IF NOT EXISTS seen( key TEXT PRIMARY KEY, first_ms INTEGER, last_ms INTEGER, streak INTEGER, alerted_ms INTEGER DEFAULT 0, best_apr REAL DEFAULT 0); CREATE INDEX IF NOT EXISTS i_opps ON opps(ts); CREATE INDEX IF NOT EXISTS i_pos ON positions(state); """ def db(path="arb.db"): c = sqlite3.connect(path, timeout=30) c.executescript(SCHEMA) return c def key_of(o): return f"{o['kind']}|{o['symbol']}|{o['long']}→{o['short']}" def track_persistence(conn, opps, now_ms, gap_ms=15 * 60_000): """ Возможность засчитывается только если держится НЕСКОЛЬКО сканов подряд. Одиночный всплеск — это почти всегда дёрнувшаяся котировка, а не эдж. """ cur = conn.cursor() live = set() for o in opps: k = key_of(o) live.add(k) row = cur.execute("SELECT first_ms,last_ms,streak,best_apr FROM seen WHERE key=?", (k,)).fetchone() metric = o["net"] if o["kind"] == "spread" else o["apr"] if row and now_ms - row[1] <= gap_ms: cur.execute("UPDATE seen SET last_ms=?,streak=streak+1,best_apr=MAX(best_apr,?) " "WHERE key=?", (now_ms, metric, k)) o["streak"] = row[2] + 1 o["age_h"] = (now_ms - row[0]) / 3600000.0 else: cur.execute("INSERT OR REPLACE INTO seen(key,first_ms,last_ms,streak,alerted_ms,best_apr)" " VALUES(?,?,?,1,0,?)", (k, now_ms, now_ms, metric)) o["streak"] = 1 o["age_h"] = 0.0 conn.commit() return live def open_paper(conn, o, now_ms, notional=1000.0): if o["kind"] == "spread": cost = o["cost_round"] * notional pnl = o["gross"] * notional - cost conn.execute( "INSERT INTO positions(kind,symbol,long_leg,short_leg,opened_ms,closed_ms," "notional,entry_apr,entry_cost,exit_cost,spread_pnl,state,meta)" " VALUES(?,?,?,?,?,?,?,?,?,?,?,'closed',?)", (o["kind"], o["symbol"], o["long"], o["short"], now_ms, now_ms, notional, 0.0, cost, 0.0, pnl, json.dumps(o, default=str))) conn.commit() return # МЕТКА ЖИВУЧЕСТИ ДОЛЖНА ПОПАСТЬ В meta, иначе сравнение корзин # robust/fragile не измеряет ничего. Первая версия писала её только для # спреда (там meta = дамп всей возможности), и 5148 позиций из 5639 # остались без метки — «проверяемая гипотеза» молча не проверялась. meta = {"legs": {}, "tag": o.get("_tag", "robust"), "breakeven_h": float(o.get("breakeven_h", 0) or 0)} if o["kind"] == "carry": meta["legs"][o["venue"]] = {"side": "short_perp", "interval_h": o["interval_h"], "next_ms": now_ms + o["interval_h"] * 3600000} else: meta["legs"][o["short_venue"]] = {"side": "short_perp", "interval_h": o["short_interval_h"], "next_ms": now_ms + o["short_interval_h"] * 3600000} meta["legs"][o["long_venue"]] = {"side": "long_perp", "interval_h": o["long_interval_h"], "next_ms": now_ms + o["long_interval_h"] * 3600000} conn.execute( "INSERT INTO positions(kind,symbol,long_leg,short_leg,opened_ms,notional," "entry_apr,entry_cost,state,meta) VALUES(?,?,?,?,?,?,?,?,'open',?)", (o["kind"], o["symbol"], o["long"], o["short"], now_ms, notional, o["apr"], o["cost_round"] / 2 * notional, json.dumps(meta))) conn.commit() def settle_paper(conn, perps, now_ms, exit_apr=0.05, max_hold_h=24 * 14): """ Начисляет фандинг ПО РАСПИСАНИЮ КАЖДОЙ НОГИ (у бирж оно разное), закрывает позицию когда эдж исчез или вышло время. """ cur = conn.cursor() closed = [] for pid, sym, kind, opened, notional, meta_s in cur.execute( "SELECT id,symbol,kind,opened_ms,notional,meta FROM positions " "WHERE state='open'").fetchall(): meta = json.loads(meta_s) total_new = 0.0 for venue, leg in meta["legs"].items(): d = perps.get(venue, {}).get(sym) if not d: continue guard = 0 while now_ms >= leg["next_ms"] and guard < 50: sign = 1.0 if leg["side"] == "short_perp" else -1.0 amt = sign * d["rate"] * notional cur.execute("INSERT INTO settles(pos_id,ts,leg,rate,amount) VALUES(?,?,?,?,?)", (pid, leg["next_ms"], venue, d["rate"], amt)) total_new += amt leg["next_ms"] += leg["interval_h"] * 3600000 guard += 1 if total_new: cur.execute("UPDATE positions SET funding_collected=funding_collected+?, meta=? " "WHERE id=?", (total_new, json.dumps(meta), pid)) # актуальная доходность — эдж мог исчезнуть cur_apr = 0.0 legs = meta["legs"] if kind == "carry": v = next(iter(legs)) d = perps.get(v, {}).get(sym) if d: cur_apr = apr(d["rate"], d["interval_h"]) else: sv = next((v for v, l in legs.items() if l["side"] == "short_perp"), None) lv = next((v for v, l in legs.items() if l["side"] == "long_perp"), None) ds, dl = perps.get(sv, {}).get(sym), perps.get(lv, {}).get(sym) if ds and dl: cur_apr = (_rate_h(ds) - _rate_h(dl)) * HOURS_YEAR # МИНИМАЛЬНЫЙ СРОК УДЕРЖАНИЯ. # Ставка фандинга дёргается на каждом скане, и правило «закрыть, как # только доходность просела ниже порога» закрывало позицию через # 5 минут — до первого же начисления. Результат первой версии: 5639 # закрытых сделок, −$13.8k, и 111% убытка объясняется одними # комиссиями. Это был замер МОЕГО ПРАВИЛА ВЫХОДА, а не стратегии. # Живой человек не входит в дельта-нейтральную позицию, чтобы выйти # через пять минут. Держим минимум до окупаемости входа (но не больше # двух суток), и раньше выходим только если ставка развернулась # и мы начали ПЛАТИТЬ. held_h = (now_ms - opened) / 3600000.0 min_hold_h = min(max(meta.get("breakeven_h", 0), 8.0), 48.0) if cur_apr < 0 or held_h > max_hold_h or (held_h >= min_hold_h and cur_apr < exit_apr): ec = cur.execute("SELECT entry_cost FROM positions WHERE id=?", (pid,)).fetchone()[0] cur.execute("UPDATE positions SET state='closed',closed_ms=?,exit_cost=? WHERE id=?", (now_ms, ec, pid)) closed.append((pid, sym, kind, held_h)) conn.commit() return closed def book_summary(conn): cur = conn.cursor() row = cur.execute( "SELECT COUNT(*), COALESCE(SUM(funding_collected+spread_pnl-entry_cost-exit_cost),0) " "FROM positions WHERE state='closed'").fetchone() op = cur.execute( "SELECT COUNT(*), COALESCE(SUM(funding_collected-entry_cost),0) " "FROM positions WHERE state='open'").fetchone() win = cur.execute( "SELECT COUNT(*) FROM positions WHERE state='closed' " "AND funding_collected+spread_pnl-entry_cost-exit_cost > 0").fetchone()[0] by_kind = {k: (n, p) for k, n, p in cur.execute( "SELECT kind, COUNT(*), COALESCE(SUM(funding_collected+spread_pnl-entry_cost-exit_cost),0) " "FROM positions WHERE state='closed' GROUP BY kind").fetchall()} # ИМЯ ПОЛЯ НАЗЫВАЕТ ЕДИНИЦУ. Было "winrate" со значением 0.38167 — # это проценты, но снаружи читается как доля 38%, и витрина печатала «0%». # Одно и то же число нельзя подавать в двух смыслах. return {"closed": row[0], "closed_pnl": row[1], "open": op[0], "open_pnl": op[1], "wins": win, "winrate_pct": (win / row[0] * 100) if row[0] else 0.0, "by_kind": by_kind}