#!/usr/bin/env python3 """ COPY-TRADE API — раздача грид-конфигов агентам и людям ПОЧЕМУ КОНФИГ, А НЕ СДЕЛКИ: у грида нет «входа и выхода» — есть непрерывная сетка. Копировать каждый филл бессмысленно: при шаге 0.8% задержка в пару секунд съедает весь доход сделки. Подписчик ставит ТАКУЮ ЖЕ сетку у себя, и дальше она работает автономно. Задержка перестаёт иметь значение. ЭНДПОИНТЫ GET /api/grid/configs все активные сетки (публично) GET /api/grid/config/ одна сетка + все уровни для постановки GET /api/grid/state/ живое состояние: какие уровни заняты GET /api/grid/performance трек-рекорд по символам GET /api/grid/snapshot всё сразу — для зеркала на Vercel GET /api/grid/vip/detail расширенное (нужен заголовок X-VIP-Key) GET /health Запуск: python3 copytrade_api.py --port 8101 VIP_KEYS="key1,key2" python3 copytrade_api.py --port 8101 """ import argparse, json, os, sqlite3, time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import urllib.parse from urllib.parse import urlparse HERE = os.path.dirname(os.path.abspath(__file__)) SPOT_FEE = 0.00075 MIN_STEP_RULE = 4 # шаг должен быть ≥ 4× комиссий за круг # система месячных ключей sys_path_added = False try: import importlib.util as _il _spec = _il.spec_from_file_location("access_keys", os.path.join(HERE, "access_keys.py")) access_keys = _il.module_from_spec(_spec); _spec.loader.exec_module(access_keys) except Exception: access_keys = None def check_key(hdr): """вернуть тариф по ключу: FREE если ключа нет/невалиден""" if not hdr: return {"tier": "FREE", "valid": False, "reason": "ключ не передан"} if not access_keys: return {"tier": "FREE", "valid": False, "reason": "модуль ключей недоступен"} r = access_keys.validate(hdr) if r.get("valid"): try: access_keys.touch(r["key"]) except Exception: pass return r def discover(): """найти все запущенные paper-гриды по их базам""" out = [] for f in os.listdir(HERE): if f.startswith("paper_grid_") and f.endswith(".db"): out.append(f[len("paper_grid_"):-3]) return sorted(out) def read(symbol): p = os.path.join(HERE, f"paper_grid_{symbol}.db") if not os.path.exists(p): return None db = sqlite3.connect(f"file:{p}?mode=ro", uri=True) t = f"paper_grid_{symbol.lower()}" cur = db.cursor() meta = {} try: for k, v in cur.execute(f"select k, v from {t}_meta"): meta[k] = json.loads(v) except sqlite3.OperationalError: return None pos = [{"level": r[0], "entry": r[1], "opened_at": r[2]} for r in cur.execute(f"select level_idx, entry_price, opened_at from {t}_pos")] n, pnl, first = 0, 0.0, 0 try: n, pnl, first = cur.execute( f"select count(*), coalesce(sum(pnl_usd),0), coalesce(min(closed_at),0) from {t}_fills" ).fetchone() except sqlite3.OperationalError: pass recent = [{"level": r[0], "entry": r[1], "exit": r[2], "pnl": round(r[3], 2), "closed_at": r[4]} for r in cur.execute( f"select level_idx, entry_price, exit_price, pnl_usd, closed_at " f"from {t}_fills order by closed_at desc limit 20")] db.close() cfg = meta.get("config", {}) started = meta.get("started_at", 0) days = max((time.time()*1000 - started) / 86400000, 1e-9) if started else 0 cap = cfg.get("levels", 20) * cfg.get("unit_usd", 100) touches = meta.get("touches", 0) return { "symbol": symbol, "config": { "step_pct": cfg.get("step_pct"), "levels": cfg.get("levels"), "unit_usd": cfg.get("unit_usd"), "center": meta.get("center"), "capital_required_usd": cap, "forced_exits": False, "note": "regime_flatten и grid_breakout ОТКЛЮЧЕНЫ намеренно", }, "grid_levels": [round(x, 8) for x in meta.get("levels", [])], "state": {"open_positions": len(pos), "positions": pos, "inventory_usd": len(pos) * cfg.get("unit_usd", 100)}, "performance": { "fills": int(n), "pnl_usd": round(float(pnl), 2), "days": round(days, 2), "apr_pct": round(float(pnl)/cap*100*365/days, 1) if days > 0.05 and cap else None, "fill_ratio_pct": round(n/touches*100, 1) if touches else None, "started_at": started, }, "recent_fills": recent, } def all_data(): return [d for d in (read(s) for s in discover()) if d] def make_copy_instructions(d): c = d["config"] lv = d["grid_levels"] return { "how_it_works": "Поставь такую же сетку у себя. Дальше она работает автономно — " "копировать отдельные сделки НЕ нужно и вредно.", "steps": [ f"1. Выдели ${c['capital_required_usd']:,.0f} на {d['symbol']}", f"2. Поставь {c['levels']} лимитных заявок на покупку по уровням ниже", f"3. К каждой покупке — заявка на продажу на {c['step_pct']}% выше её цены", f"4. Размер каждой заявки ${c['unit_usd']:.0f}", "5. НЕ закрывай инвентарь при выходе цены из диапазона — жди возврата", ], "buy_levels": lv, "sell_target_formula": f"цена_покупки × {1 + (c['step_pct'] or 0)/100:.4f}", "critical_rules": [ f"Шаг {c['step_pct']}% выбран не случайно: он должен быть минимум " f"в {MIN_STEP_RULE} раза больше комиссий за круг ({SPOT_FEE*2*100:.2f}%). " "При меньшем шаге биржа забирает больше половины дохода.", "Никаких стопов на инвентаре. Принудительное закрытие — главный убийца грида: " "в наших данных цена возвращалась к уровню входа в 88% случаев.", "Это paper-режим. Реальными деньгами мы этот конфиг ещё не торгуем.", ], } class H(BaseHTTPRequestHandler): def _send(self, obj, code=200): b = json.dumps(obj, ensure_ascii=False, indent=1).encode() self.send_response(code) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Cache-Control", "public, max-age=20") self.send_header("Content-Length", str(len(b))) self.end_headers(); self.wfile.write(b) def log_message(self, *a): pass def do_GET(self): p = urlparse(self.path).path.rstrip("/") try: if p in ("/health", ""): return self._send({"ok": True, "grids": len(discover()), "ts": int(time.time())}) if p == "/api/grid/configs": return self._send({"updated": int(time.time()), "grids": [ {"symbol": d["symbol"], **d["config"], "performance": d["performance"]} for d in all_data()]}) if p.startswith("/api/grid/config/"): sym = p.rsplit("/", 1)[-1].upper() d = read(sym) if not d: return self._send({"error": "нет такой сетки"}, 404) return self._send({**{k: d[k] for k in ("symbol", "config", "grid_levels")}, "copy_instructions": make_copy_instructions(d)}) if p.startswith("/api/grid/state/"): sym = p.rsplit("/", 1)[-1].upper() d = read(sym) if not d: return self._send({"error": "нет такой сетки"}, 404) return self._send({"symbol": sym, "state": d["state"], "performance": d["performance"]}) if p == "/api/grid/performance": data = all_data() tot = sum(d["performance"]["pnl_usd"] for d in data) return self._send({ "updated": int(time.time()), "total_pnl_usd": round(tot, 2), "mode": "paper", "by_symbol": [{"symbol": d["symbol"], **d["performance"]} for d in data]}) if p == "/api/grid/snapshot": return self._send({"updated": int(time.time()), "mode": "paper", "grids": all_data()}) # ── админка ключей (нужен ключ тарифа ADMIN, он же любой AGENT-ключ владельца) if p.startswith("/api/grid/admin/"): k = check_key(self.headers.get("X-VIP-Key", "")) if not k.get("valid") or k["tier"] != "AGENT": return self._send({"error": "нужен AGENT-ключ владельца"}, 401) if not access_keys: return self._send({"error": "модуль ключей недоступен"}, 500) db = access_keys.load() cur = access_keys.period() act = p.rsplit("/", 1)[-1] if act == "keys": rows = [] for key, r in sorted(db["keys"].items(), key=lambda x: (x[1]["period"], x[0])): exp = access_keys.expired(r["period"]) rows.append({"key": key, "tier": r["tier"], "period": r["period"], "until": f"{r['period'][2:]}.20{r['period'][:2]}", "uses": r.get("uses", 0), "note": r.get("note", ""), "status": "отозван" if r.get("revoked") else ("истёк" if exp else "активен"), "expiring_now": r["period"] == cur and not r.get("revoked") and not exp, "last_used": r.get("last_used")}) return self._send({"period_now": cur, "total": len(rows), "active": sum(1 for x in rows if x["status"] == "активен"), "keys": rows}) if act == "issue": qs = dict(x.split("=", 1) for x in (urlparse(self.path).query or "").split("&") if "=" in x) tier = qs.get("tier", "VIP").upper() cnt = min(int(qs.get("count", 1)), 20) note = urllib.parse.unquote_plus(qs.get("note", "")) made = [] for _ in range(cnt): nk = access_keys.make(tier, int(qs.get("months", 1))) db["keys"][nk] = {"tier": tier, "period": access_keys.parse(nk)["period"], "note": note, "issued": int(time.time()), "uses": 0, "revoked": False} made.append(nk) access_keys.save(db) return self._send({"issued": made, "tier": tier}) if act == "revoke": qs = dict(x.split("=", 1) for x in (urlparse(self.path).query or "").split("&") if "=" in x) target = qs.get("key", "").upper() if target not in db["keys"]: return self._send({"error": "нет такого ключа"}, 404) db["keys"][target]["revoked"] = True access_keys.save(db) return self._send({"revoked": target}) return self._send({"error": "неизвестное действие", "actions": ["keys", "issue", "revoke"]}, 404) if p == "/api/grid/key/check": k = check_key(self.headers.get("X-VIP-Key", "")) return self._send(k, 200 if k.get("valid") else 401) if p == "/api/grid/vip/detail": k = check_key(self.headers.get("X-VIP-Key", "")) if not k.get("valid") or k["tier"] not in ("VIP", "AGENT", "PRO"): return self._send({"error": "нужен действующий ключ тарифа PRO/VIP/AGENT", "reason": k.get("reason"), "how_to_get": "ключ выдаётся на месяц, формат SA-VIP-ГГММ-XXXXXXXX", "free_endpoints": ["/api/grid/configs", "/api/grid/config/{sym}", "/api/grid/performance"]}, 401) data = all_data() body = {"updated": int(time.time()), "tier": k["tier"], "key_valid_until": f"конец {k['period'][2:]}.20{k['period'][:2]}", "grids": data, "copy_instructions": {d["symbol"]: make_copy_instructions(d) for d in data}} if k["tier"] in ("VIP", "AGENT"): body["extras"] = { "model_expectation": {"fill_ratio_pct": 80, "apr_range_pct": [24, 31], "validated_on": "48 наблюдений символ×месяц, все положительные"}, "step_rule": f"шаг ≥ {MIN_STEP_RULE}× комиссий за круг ({SPOT_FEE*2*100:.2f}%) = " f"минимум {MIN_STEP_RULE*SPOT_FEE*2*100:.1f}%", "forbidden": ["stop-loss на инвентаре", "принудительное закрытие по режиму", "шаг ниже порога выживания"], } return self._send(body) return self._send({"error": "неизвестный путь", "see": "/api/grid/configs"}, 404) except Exception as e: return self._send({"error": type(e).__name__, "detail": str(e)[:120]}, 500) if __name__ == "__main__": ap = argparse.ArgumentParser() ap.add_argument("--port", type=int, default=8101) a = ap.parse_args() nk = len(access_keys.load()["keys"]) if access_keys else 0 print(f"copy-trade API на :{a.port} · сеток: {len(discover())} · ключей в базе: {nk}") ThreadingHTTPServer(("0.0.0.0", a.port), H).serve_forever()