#!/usr/bin/env python3 """ СУПЕРВИЗОР СЕТОК — один сервис вместо systemd-юнита на каждый символ. ЗАЧЕМ. SSH на арену закрыт после хардening, единственный путь внутрь — `gcloud compute ssh --tunnel-through-iap` через браузер. Каждое добавление символа означало: создать юнит, daemon-reload, enable --now. Через браузерный терминал длинные команды теряют символы, а сессия отваливается каждые 10-15 минут. Сегодня из-за этого дважды молча не доехали команды. РЕШЕНИЕ. Список символов лежит в JSON по URL. Супервизор его читает, поднимает по процессу на символ и следит за ними. Добавить сетку = опубликовать новый symbols.json отсюда. Шелл больше не нужен. {"symbols": [{"symbol": "EPICUSDT", "step": 2.5, "levels": 20, "unit": 100}]} Правило: символ не берётся в работу, пока цена не подтверждена минимум двумя источниками и они не расходятся больше чем на 3% (см. price_sources). Ровно эта проверка отсеяла QUICK, где Coinbase отдавал $48 вместо $0.0075. Запуск: python3 grid_supervisor.py --config https://grid-src.vercel.app/symbols.json python3 grid_supervisor.py --config ./symbols.json --once # проверить и выйти """ import argparse, json, os, signal, subprocess, sys, time, urllib.request HERE = os.path.dirname(os.path.abspath(__file__)) BOT = os.path.join(HERE, "paper_grid_bot.py") RECHECK_SEC = 300 # как часто перечитывать список символов sys.path.insert(0, HERE) def load_config(src): if src.startswith("http"): req = urllib.request.Request(src, headers={"User-Agent": "grid-supervisor", "Cache-Control": "no-cache"}) with urllib.request.urlopen(req, timeout=25) as r: return json.loads(r.read().decode()) return json.load(open(src, encoding="utf-8")) def norm(entry): if isinstance(entry, str): entry = {"symbol": entry} return {"symbol": entry["symbol"], "step": float(entry.get("step", 1.3)), "levels": int(entry.get("levels", 20)), "unit": float(entry.get("unit", 100))} class Supervisor: def __init__(self, src, use_pg): self.src, self.use_pg = src, use_pg self.procs = {} # symbol → (Popen, spec) self.rejected = {} # symbol → причина self.stop = False signal.signal(signal.SIGTERM, self._sig) signal.signal(signal.SIGINT, self._sig) def _sig(self, *a): print("[sup] останавливаю сетки…", flush=True) self.stop = True for sym, (p, _) in self.procs.items(): p.terminate() def spawn(self, spec): sym = spec["symbol"] cmd = [sys.executable, "-u", BOT, "--symbol", sym, "--step", str(spec["step"]), "--levels", str(spec["levels"]), "--unit", str(spec["unit"])] if self.use_pg: cmd.append("--pg") log = open(f"/var/log/arena-grid-{sym}.log", "a", buffering=1) \ if os.access("/var/log", os.W_OK) else None p = subprocess.Popen(cmd, stdout=log or subprocess.DEVNULL, stderr=subprocess.STDOUT, cwd=HERE) self.procs[sym] = (p, spec) print(f"[sup] ▶ {sym} шаг {spec['step']}% × {spec['levels']} (pid {p.pid})", flush=True) def reconcile(self): try: cfg = load_config(self.src) except Exception as e: print(f"[sup] не смог прочитать список символов: {str(e)[:90]} — " f"работаю по старому", flush=True) return want = {} for e in cfg.get("symbols", []): s = norm(e) want[s["symbol"]] = s # убрать лишние for sym in list(self.procs): if sym not in want: p, _ = self.procs.pop(sym) p.terminate() print(f"[sup] ■ {sym} убран из списка — остановлен", flush=True) # добавить новые (с проверкой цены) from price_sources import symbol_available for sym, spec in want.items(): if sym in self.procs: old = self.procs[sym][1] if old != spec: # параметры поменялись self.procs[sym][0].terminate() self.procs.pop(sym) print(f"[sup] ↻ {sym} параметры изменились {old} → {spec}", flush=True) else: continue if spec["step"] < 0.6: self.rejected[sym] = f"шаг {spec['step']}% ниже порога выживания 0.6%" continue chk = symbol_available(sym) if not chk["available"]: self.rejected[sym] = "цену не подтвердили два источника" print(f"[sup] ✖ {sym} пропущен: {self.rejected[sym]}" + (f" (отвергнуты {', '.join(chk['rejected'])})" if chk["rejected"] else ""), flush=True) continue self.rejected.pop(sym, None) print(f"[sup] {sym} цена {chk['price']:.8g} подтверждена: " f"{', '.join(chk['sources'][:4])}", flush=True) self.spawn(spec) def watch(self): for sym, (p, spec) in list(self.procs.items()): if p.poll() is not None: print(f"[sup] ✖ {sym} упал (код {p.returncode}) — поднимаю", flush=True) self.procs.pop(sym) time.sleep(2) self.spawn(spec) def loop(self, once=False): self.reconcile() if once: print(f"\n[sup] живых сеток: {len(self.procs)} → {', '.join(sorted(self.procs))}") if self.rejected: print(f"[sup] отвергнуто: " + "; ".join(f"{k} ({v})" for k, v in self.rejected.items())) self._sig() return last = time.time() while not self.stop: time.sleep(5) self.watch() if time.time() - last > RECHECK_SEC: last = time.time() self.reconcile() def main(): ap = argparse.ArgumentParser() ap.add_argument("--config", default="https://grid-src.vercel.app/symbols.json") ap.add_argument("--pg", action="store_true") ap.add_argument("--once", action="store_true", help="сверить список и выйти") a = ap.parse_args() print(f"[sup] список символов: {a.config}", flush=True) Supervisor(a.config, a.pg).loop(once=a.once) if __name__ == "__main__": main()