#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
СЕРВИС АРБИТРАЖА — скан → алерты в Telegram → бумажная книга → публичная витрина.
Запуск: python3 arb_service.py --loop 300
Разово: python3 arb_service.py --once --no-publish
ЧТО ИМЕННО ЗДЕСЬ ПРОВЕРЯЕТСЯ, А НЕ ДЕКЛАРИРУЕТСЯ.
Мы не знаем заранее, окупается ли фандинг-арбитраж после комиссий. Поэтому
КАЖДАЯ найденная возможность открывается в бумажной книге и помечается
robust/fragile — по тому, выживает ли разрыв без двух крайних бирж.
Через неделю данные скажут сами: если у fragile-корзины итог около нуля или
в минус, значит метрика живучести работает и алерты надо слать только по
robust. Если разницы нет — метрику выкинуть. Это проверяемая гипотеза,
а не украшение интерфейса.
В ЧАТ УХОДИТ НЕ ВСЁ. Алерт только если: разрыв держится ≥3 скана подряд,
живучесть выше порога, оборот на обеих ногах достаточный, и по этой паре
не было алерта последние 6 часов. Сканер, который шлёт всё подряд, читать
перестают на второй день.
СЕКРЕТЫ берутся из окружения (ARB_TG_TOKEN, ARB_TG_CHAT, VERCEL_TOKEN)
либо из соседнего файла arb_secrets.json. В коде их нет.
"""
import argparse, base64, hashlib, json, os, sys, time, urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import arb_sources as S
import arb_engine as E
HERE = os.path.dirname(os.path.abspath(__file__))
DB = os.path.join(HERE, "arb.db")
_SEC = {}
try:
with open(os.path.join(HERE, "arb_secrets.json"), encoding="utf-8") as f:
_SEC = json.load(f)
except Exception:
pass
def _from_sibling(module, attr):
"""
Достаём ключ из уже стоящего рядом модуля, а не из копии в этом файле.
Смысл: ключ живёт в ОДНОМ месте. Ротация — правка одного файла,
и нигде нет второго экземпляра, о котором потом забудут.
"""
try:
return getattr(__import__(module), attr, "") or ""
except Exception:
return ""
def secret(name, default=""):
return os.environ.get(name) or _SEC.get(name) or default
TG_TOKEN = secret("ARB_TG_TOKEN") or _from_sibling("tg_bot", "TOKEN")
TG_CHAT = secret("ARB_TG_CHAT", "932299051")
VERCEL_TOKEN = secret("VERCEL_TOKEN") or _from_sibling("push_to_vercel", "VERCEL_TOKEN")
TEAM = "team_oVflHvk6poDyQRHW88LCTcg8"
PROJECT = "arb-radar"
ALIAS = "sovereign-arb-radar.vercel.app" # arb-radar.vercel.app занят чужим проектом
# ── пороги алертов ──
ALERT_STREAK = 3 # сколько сканов подряд должен держаться разрыв
ALERT_MIN_APR = 0.40 # 40% годовых — ниже не будим человека
ALERT_MIN_ROBUST = 0.25 # эдж должен выживать без двух крайних бирж
ALERT_MIN_VOL = 1_000_000
ALERT_MAX_BE_H = 48 # окупаемость входа-выхода не дольше двух суток
ALERT_COOLDOWN_H = 6
PAPER_NOTIONAL = 1000.0
PAPER_MIN_APR = 0.20 # в бумагу берём ШИРЕ, чем в алерты — ради статистики
# ── БЮДЖЕТ ПУБЛИКАЦИЙ ──
# Скан и публикация РАЗВЯЗАНЫ. У Vercel free 100 деплоев в сутки НА АККАУНТ,
# а не на проект. Публикация на каждом скане (раз в 5 мин) = 288 деплоев в
# сутки: этот сервис в одиночку выжирает лимит и блокирует ВСЕ остальные
# деплои аккаунта, включая сайт. Проверено больно 29.07 — деплой сайта
# отбился 402 payment_required.
# Поэтому: не чаще PUBLISH_EVERY_S, не больше PUBLISH_MAX_DAY за сутки и
# только если содержимое реально изменилось.
PUBLISH_EVERY_S = 1800 # 30 минут → максимум 48 в сутки
PUBLISH_MAX_DAY = 40 # жёсткий потолок, оставляем место остальным
def log(*a):
print(time.strftime("[%H:%M:%S]"), *a, flush=True)
def tg(text):
if not TG_TOKEN or not TG_CHAT:
return
try:
data = json.dumps({"chat_id": TG_CHAT, "text": text, "parse_mode": "HTML",
"disable_web_page_preview": True}).encode()
req = urllib.request.Request(
f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage",
data=data, headers={"Content-Type": "application/json"})
urllib.request.urlopen(req, timeout=20).read()
except Exception as e:
log(f" ⚠️ telegram: {type(e).__name__}")
def fmt_alert(o):
if o["kind"] == "spread":
return (f"⚡ СПРЕД {o['symbol']}\n"
f"купить {o['buy_venue']} по {o['buy_px']:.6g}\n"
f"продать {o['sell_venue']} по {o['sell_px']:.6g}\n"
f"грязными {o['gross']*100:.2f}%, комиссии {o['cost_round']*100:.2f}% → "
f"чистыми {o['net']*100:.2f}%\n"
f"оборот ${o['vol_usd']:,.0f} · держится {o.get('age_h',0):.1f}ч\n"
f"⚠️ деньги нужны ЗАРАНЕЕ на обеих биржах — на перевод спреда не хватит")
head = "🔁 КЭРРИ" if o["kind"] == "carry" else "⚖️ ФАНДИНГ"
lines = [f"{head} {o['symbol']}",
f"лонг: {o['long']}",
f"шорт: {o['short']}",
f"доход {o['apr']*100:.0f}% годовых"]
if o["kind"] == "funding":
lines.append(f"без двух крайних бирж: {o.get('apr_robust',0)*100:.0f}% "
f"(бирж в расчёте {o.get('venues_n',0)})")
lines.append(f"ставки: {o['long_rate']*100:+.4f}%/{o['long_interval_h']:.0f}ч "
f"против {o['short_rate']*100:+.4f}%/{o['short_interval_h']:.0f}ч")
else:
lines.append(f"ставка {o['rate_h']*o['interval_h']*100:+.4f}%/{o['interval_h']:.0f}ч, "
f"базис {o['basis']*100:+.2f}%")
lines += [f"вход+выход стоят {o['cost_round']*100:.2f}% → "
f"окупается за {o['breakeven_h']:.0f}ч",
f"оборот ${o['vol_usd']:,.0f} · держится {o.get('age_h',0):.1f}ч "
f"({o.get('streak',0)} сканов)",
"⚠️ дельта-нейтрально, но нужен залог на обеих ногах; "
"ставка меняется каждый интервал"]
return "\n".join(lines)
def qualifies_for_alert(o):
if o.get("streak", 0) < ALERT_STREAK:
return False
if o["vol_usd"] < ALERT_MIN_VOL:
return False
if o["kind"] == "spread":
return o["net"] >= 0.003
if o["apr"] < ALERT_MIN_APR or o["breakeven_h"] > ALERT_MAX_BE_H:
return False
if o["kind"] == "funding" and o.get("apr_robust", 0) < ALERT_MIN_ROBUST:
return False
return True
def deploy(files):
def api(url, body=None, method="GET"):
req = urllib.request.Request(
url, data=json.dumps(body).encode() if body else None, method=method,
headers={"Authorization": "Bearer " + VERCEL_TOKEN,
"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as r:
return json.load(r)
fl = [{"file": k, "data": base64.b64encode(v.encode()).decode(), "encoding": "base64"}
for k, v in files.items()]
d = api(f"https://api.vercel.com/v13/deployments?teamId={TEAM}&forceNew=1",
{"name": PROJECT, "files": fl, "target": "production",
"projectSettings": {"framework": None, "buildCommand": "",
"outputDirectory": ".", "installCommand": ""}}, "POST")
did = d["id"]
for _ in range(60):
st = api(f"https://api.vercel.com/v13/deployments/{did}?teamId={TEAM}")["readyState"]
if st == "READY":
break
if st in ("ERROR", "CANCELED"):
raise RuntimeError(f"деплой упал: {st}")
time.sleep(3)
api(f"https://api.vercel.com/v2/deployments/{did}/aliases?teamId={TEAM}",
{"alias": ALIAS}, "POST")
return did
def _pub_state(conn):
conn.execute("CREATE TABLE IF NOT EXISTS publish_log(ts INTEGER, day TEXT, hash TEXT)")
conn.commit()
row = conn.execute("SELECT ts,hash FROM publish_log ORDER BY ts DESC LIMIT 1").fetchone()
day = time.strftime("%Y-%m-%d", time.gmtime())
n = conn.execute("SELECT COUNT(*) FROM publish_log WHERE day=?", (day,)).fetchone()[0]
return (row[0] if row else 0), (row[1] if row else ""), n, day
def may_publish(conn, page, now_ms):
"""
Три ограничителя: частота, суточный бюджет и изменение содержимого.
Лимит Vercel — на ВЕСЬ аккаунт, поэтому расточительность здесь ломает
деплой сайта и зеркала, а не только этой витрины.
"""
last_ts, last_hash, n_today, _ = _pub_state(conn)
h = hashlib.sha256(page.encode()).hexdigest()[:16]
if n_today >= PUBLISH_MAX_DAY:
return False, f"суточный бюджет исчерпан ({n_today}/{PUBLISH_MAX_DAY})"
if now_ms - last_ts < PUBLISH_EVERY_S * 1000:
left = int((PUBLISH_EVERY_S * 1000 - (now_ms - last_ts)) / 60000)
return False, f"рано, следующая через ~{left} мин"
if h == last_hash:
return False, "содержимое не изменилось"
return True, ""
def mark_published(conn, page, now_ms):
h = hashlib.sha256(page.encode()).hexdigest()[:16]
conn.execute("INSERT INTO publish_log(ts,day,hash) VALUES(?,?,?)",
(now_ms, time.strftime("%Y-%m-%d", time.gmtime()), h))
conn.commit()
def build_page(snap):
o = snap["opportunities"]
b = snap["book"]
def rows(kind, n=15):
sel = [x for x in o if x["kind"] == kind][:n]
if not sel:
return "
| сейчас ничего выше порога |
"
out = []
for x in sel:
if kind == "spread":
metric = f"{x['net']*100:.2f}%"
extra = f"{x['gross']*100:.2f}%"
be = "—"
else:
metric = f"{x['apr']*100:.0f}%"
extra = f"{x.get('apr_robust',0)*100:.0f}%" if kind == "funding" else "—"
be = f"{x['breakeven_h']:.0f}ч"
cls = "good" if (kind != "funding" or x.get("apr_robust", 0) >= 0.25) else "weak"
out.append(
f"| {x['symbol']} | {x['long']} | "
f"{x['short']} | {metric} | {extra} | "
f"{be} | ${x['vol_usd']:,.0f} |
")
return "".join(out)
bk = (f"закрыто {b['closed']} · прибыльных {b['winrate_pct']:.1f}% · "
f"итог ${b['closed_pnl']:+,.2f} · открыто {b['open']} (${b['open_pnl']:+,.2f})")
cmp_html = ""
if b.get("robust_vs_fragile"):
r = b["robust_vs_fragile"]
cmp_html = ("Проверка метрики живучести
| корзина | "
"закрыто | итог | "
"прибыльных |
"
+ "".join(f"| {k} | {v['n']} | "
f"${v['pnl']:+,.2f} | "
f"{v['wr_pct']:.1f}% |
"
for k, v in r.items()) + "
")
return f"""
Арбитражный радар — фандинг и спред
Арбитражный радар
обновлено {snap['updated']} · биржи: {', '.join(snap['venues'])} ·
вселенная — {snap['universe_n']} пар, торгуемых на Binance
Это не сигналы «жми и богатей». Каждая строка — расхождение, которое ещё нужно
исполнить руками, с залогом на двух площадках. Годовые посчитаны от текущей ставки,
а она меняется каждый интервал. Колонка «окупаемость» честнее годовых: столько
часов позицию надо продержать, чтобы отбить одни лишь комиссии входа и выхода.
Фандинг: перп против перпа
дельта-нейтрально. Колонка «без крайних» — доход, если выкинуть две
крайние биржи. Если там почти ноль, весь разрыв держится на одной площадке
и это, скорее, особенность её формулы, а не рынок.
| монета | лонг | шорт | годовых |
без крайних | окупаемость | оборот |
{rows('funding')}
Кэрри: спот и перп на одной бирже
купить монету, продать против неё перп, собирать фандинг.
Переводов между биржами нет — самый исполнимый вариант.
| монета | купить | продать | годовых |
— | окупаемость | оборот |
{rows('carry')}
Спред: спот дешевле там, дороже тут
показываем только пары с реальным оборотом на ОБЕИХ биржах.
Без этого фильтра список занимают мёртвые монеты с зависшей котировкой.
| монета | купить | продать | чистыми |
грязными | — | оборот |
{rows('spread')}
Бумажная книга
{bk}
{cmp_html}
Как это считается. Ставка фандинга приводится к годовым через её
собственный интервал — у альтов он бывает 1 и 4 часа, а не только 8, и без
нормализации доход завышается в разы. Комиссии заложены тейкерские, без
VIP-скидок, плюс {E.SLIP_PERP*100:.2f}% проскальзывания на ногу. Цена каждой
монеты сверяется с медианой по всем биржам: расхождение больше 3% считается
сломанной котировкой и выбрасывается.
Данные: {', '.join(snap['venues'])}. Binance и Bybit не отвечают
с нашего узла (451 и 403), поэтому их ставки в расчёт не входят — список монет
берётся с Binance, а фандинг считается по доступным площадкам.
Исходники:
grid-src ·
машинный срез:
arb.json
"""
def once(publish=True, alert=True):
now = int(time.time() * 1000)
log("── скан ──")
perps, rep, perr = S.load_perps(log=lambda *a: log(" ", *a))
spots, serr = S.load_spots(log=lambda *a: log(" ", *a))
uni = S.binance_universe(log=lambda *a: log(" ", *a))
if not perps:
log("нет ни одной перп-биржи — пропуск")
return
opps = E.scan(perps, spots, universe=uni, min_apr=PAPER_MIN_APR)
conn = E.db(DB)
E.track_persistence(conn, opps, now)
cur = conn.cursor()
for o in opps:
cur.execute("INSERT INTO opps(ts,kind,symbol,apr,net,breakeven_h,long_leg,"
"short_leg,price,vol_usd) VALUES(?,?,?,?,?,?,?,?,?,?)",
(now, o["kind"], o["symbol"], o.get("apr", 0), o.get("net", 0),
o.get("breakeven_h", 0), o["long"], o["short"],
o.get("price", 0), o.get("vol_usd", 0)))
conn.commit()
# ── бумага: закрыть отжившее, открыть новое ──
for pid, sym, kind, held in E.settle_paper(conn, perps, now):
log(f" ✖ закрыта бумага #{pid} {sym} ({kind}), держали {held:.1f}ч")
opened = 0
for o in opps:
if o.get("streak", 0) < 2:
continue
if cur.execute("SELECT 1 FROM positions WHERE state='open' AND symbol=? "
"AND long_leg=? AND short_leg=?",
(o["symbol"], o["long"], o["short"])).fetchone():
continue
o["_tag"] = ("robust" if (o["kind"] != "funding" or o.get("apr_robust", 0) >= 0.25)
else "fragile")
E.open_paper(conn, o, now, PAPER_NOTIONAL)
opened += 1
if opened:
log(f" ✚ открыто бумажных позиций: {opened}")
# ── алерты ──
sent = 0
if alert:
for o in opps:
if not qualifies_for_alert(o):
continue
k = E.key_of(o)
row = cur.execute("SELECT alerted_ms,best_apr FROM seen WHERE key=?",
(k,)).fetchone()
metric = o["net"] if o["kind"] == "spread" else o["apr"]
if row and row[0] and (now - row[0]) < ALERT_COOLDOWN_H * 3600000:
if metric < row[1] * 1.5:
continue
tg(fmt_alert(o))
cur.execute("UPDATE seen SET alerted_ms=? WHERE key=?", (now, k))
sent += 1
if sent >= 6:
break
conn.commit()
log(f" возможностей {len(opps)} · алертов {sent}")
# ── витрина ──
b = E.book_summary(conn)
b["robust_vs_fragile"] = {}
for tag in ("robust", "fragile"):
r = cur.execute(
"SELECT COUNT(*), COALESCE(SUM(funding_collected+spread_pnl-entry_cost-exit_cost),0),"
" SUM(CASE WHEN funding_collected+spread_pnl-entry_cost-exit_cost>0 THEN 1 ELSE 0 END)"
" FROM positions WHERE state='closed' AND meta LIKE ?",
('%"' + tag + '"%',)).fetchone()
if r[0]:
b["robust_vs_fragile"][tag] = {"n": r[0], "pnl": r[1],
"wr_pct": (r[2] or 0) / r[0] * 100}
snap = {"updated": time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime()),
"venues": sorted(perps.keys()), "universe_n": len(uni),
"opportunities": opps[:80], "book": b,
"unit_calibration": rep, "errors": {**perr, **serr}}
with open(os.path.join(HERE, "arb_snapshot.json"), "w", encoding="utf-8") as f:
json.dump(snap, f, ensure_ascii=False, default=str)
if publish and VERCEL_TOKEN:
page = build_page(snap)
ok, why = may_publish(conn, page, now)
if not ok:
log(f" ⏸ публикация пропущена: {why}")
else:
try:
did = deploy({
"index.html": page,
"arb.json": json.dumps(snap, ensure_ascii=False, default=str),
"vercel.json": json.dumps({"headers": [{"source": "/(.*)", "headers": [
{"key": "Access-Control-Allow-Origin", "value": "*"},
{"key": "Cache-Control", "value": "no-store"}]}]})})
mark_published(conn, page, now)
log(f" 🌐 https://{ALIAS} ({did[:18]}…)")
except Exception as e:
log(f" ⚠️ публикация: {type(e).__name__}: {e}")
conn.close()
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--loop", type=int, default=0, help="секунд между сканами")
ap.add_argument("--once", action="store_true")
ap.add_argument("--no-publish", action="store_true")
ap.add_argument("--no-alert", action="store_true")
a = ap.parse_args()
if a.loop:
while True:
try:
once(publish=not a.no_publish, alert=not a.no_alert)
except Exception as e:
log(f"❌ {type(e).__name__}: {e}")
time.sleep(a.loop)
else:
once(publish=not a.no_publish, alert=not a.no_alert)