polyester-bot 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bot/monitor.py ADDED
@@ -0,0 +1,847 @@
1
+ """Gap hunter + executor.
2
+
3
+ Scans every USDT-quoted pair for bid-ask gaps that beat the 0.2% maker
4
+ round-trip fee, strikes fast (buy at bid, ask just under the offer), manages
5
+ the ask with the bail-out rule, and Telegram-alerts every step.
6
+
7
+ Bail-out rule (user spec): while our ask rests, if more than 2 better
8
+ (lower) ask levels sit above... i.e. below our price for >60s, cancel and
9
+ re-ask one tick under the current best ask (maker exit, even at a loss).
10
+
11
+ Adopted at startup: any asset with a Trading balance that has a USDT pair
12
+ (e.g. the BTC from the filled manual order, plus XRP).
13
+ """
14
+
15
+ import asyncio
16
+ import hashlib
17
+ import json
18
+ import os
19
+ import re
20
+ import subprocess
21
+ import sys
22
+ import time
23
+ import urllib.parse
24
+ import urllib.request
25
+ import uuid
26
+ from decimal import Decimal, ROUND_FLOOR
27
+
28
+ import nacl.signing
29
+ from polyester import POLYESTER_TESTNET_ENVIRONMENT, AsyncPolyester
30
+ from polyester.models import ClientOrderId
31
+
32
+ try:
33
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
34
+ except Exception:
35
+ pass
36
+
37
+ if __package__:
38
+ from .paths import HERE, env_files, state_file
39
+ else:
40
+ from paths import HERE, env_files, state_file
41
+
42
+
43
+ def load_env():
44
+ for path in env_files():
45
+ try:
46
+ with open(path, encoding="utf-8") as f:
47
+ for line in f:
48
+ line = line.strip()
49
+ if line and not line.startswith("#") and "=" in line:
50
+ k, _, v = line.partition("=")
51
+ os.environ.setdefault(k.strip(), v.strip())
52
+ except OSError:
53
+ pass
54
+
55
+
56
+ load_env()
57
+
58
+ API = "https://api.testnet.polyester.com"
59
+ UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
60
+ "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")
61
+ KEY = os.environ.get("POLYESTER_API_KEY_ID", "")
62
+ _SK = nacl.signing.SigningKey(bytes.fromhex(os.environ.get("POLYESTER_ED25519_SEED", "0" * 64)))
63
+ ACCOUNT = os.environ.get("POLYESTER_ACCOUNT_ID", "")
64
+
65
+ QUOTE = "USDT"
66
+ RT_FEE_PCT = Decimal("0.20")
67
+ GAP_MIN_PCT = Decimal(os.environ.get("GAP_MIN_PCT", "0.21"))
68
+ DEPTH_MIN_USD = Decimal(os.environ.get("DEPTH_MIN_USD", "50"))
69
+ FALLBACK_SYMBOL = os.environ.get("FALLBACK_SYMBOL", "").strip()
70
+ FALLBACK_MIN_GAP_PCT = Decimal(os.environ.get("FALLBACK_MIN_GAP_PCT", "0.05"))
71
+ FALLBACK_DEPTH_MIN_USD = Decimal(os.environ.get("FALLBACK_DEPTH_MIN_USD", "1000"))
72
+ FALLBACK_COOLDOWN = float(os.environ.get("FALLBACK_COOLDOWN", "60"))
73
+ FALLBACK_PENNY = os.environ.get("FALLBACK_PENNY", "0") == "1"
74
+ SCAN_SECONDS = float(os.environ.get("SCAN_SECONDS", "10"))
75
+ POLL_SECONDS = float(os.environ.get("POLL_SECONDS", "2"))
76
+ PERSIST_SECONDS = float(os.environ.get("PERSIST_SECONDS", "60"))
77
+ BETTER_ASKS_LIMIT = int(os.environ.get("BETTER_ASKS_LIMIT", "2"))
78
+ BUY_TIMEOUT_SECONDS = float(os.environ.get("BUY_TIMEOUT_SECONDS", "180"))
79
+ FEE_BUFFER = Decimal("1.0015")
80
+ FREEZE_SECONDS = float(os.environ.get("FREEZE_SECONDS", "300"))
81
+ STALL_SECONDS = float(os.environ.get("STALL_SECONDS", "900"))
82
+ BALANCE_GRACE = float(os.environ.get("BALANCE_GRACE_SECONDS", "120"))
83
+ HEARTBEAT = "/var/tmp/polyester.heartbeat"
84
+
85
+
86
+ def frozen_seconds(wall, mono, last_wall, last_mono):
87
+ """Seconds the VM spent frozen: wall clock advanced but the guest did not run.
88
+
89
+ A paused sandbox stops the guest kernel, so CLOCK_MONOTONIC freezes while
90
+ the wall clock keeps going. The difference is the freeze duration.
91
+ """
92
+ return (wall - last_wall) - (mono - last_mono)
93
+
94
+ TERMINAL = {"filled", "canceled", "rejected", "expired"}
95
+
96
+ COST_BASIS = {"BTC-USDT": Decimal("76227.38"), "ARB-USDT": Decimal("0.1684")}
97
+
98
+
99
+ def pdec(v):
100
+ if v is None or v == "":
101
+ return Decimal(0)
102
+ return v.as_decimal() if hasattr(v, "as_decimal") else Decimal(str(v))
103
+
104
+
105
+ def qdec(v, scale):
106
+ if v is None or v == "":
107
+ return Decimal(0)
108
+ return v.as_decimal(scale) if hasattr(v, "as_decimal") else Decimal(str(v))
109
+
110
+
111
+ def floor_step(x, step):
112
+ step = Decimal(str(step))
113
+ if step <= 0:
114
+ return x
115
+ return (x / step).to_integral_value(rounding=ROUND_FLOOR) * step
116
+
117
+
118
+ def alert(text):
119
+ print(text, flush=True)
120
+ import threading
121
+
122
+ def send():
123
+ tok = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
124
+ chat = os.environ.get("TELEGRAM_CHAT_ID", "").strip()
125
+ if tok and chat:
126
+ try:
127
+ req = urllib.request.Request(
128
+ "https://api.telegram.org/bot" + tok + "/sendMessage",
129
+ data=urllib.parse.urlencode({"chat_id": chat, "text": text}).encode())
130
+ urllib.request.urlopen(req, timeout=5)
131
+ except Exception as e:
132
+ print("telegram failed:", repr(e), flush=True)
133
+ hook = os.environ.get("DISCORD_WEBHOOK_URL", "").strip()
134
+ if hook:
135
+ try:
136
+ req = urllib.request.Request(
137
+ hook, data=json.dumps({"content": text}).encode(),
138
+ headers={"Content-Type": "application/json"})
139
+ urllib.request.urlopen(req, timeout=10)
140
+ except Exception as e:
141
+ print("discord failed:", repr(e), flush=True)
142
+
143
+ threading.Thread(target=send, daemon=True).start()
144
+
145
+
146
+ def money(x):
147
+ return f"{Decimal(x):,.2f}"
148
+
149
+
150
+ def num(x):
151
+ s = f"{Decimal(x):,.8f}".rstrip("0").rstrip(".")
152
+ return s or "0"
153
+
154
+
155
+ def wallet(base=None):
156
+ """The balances that matter right now: 'USDT 3,094.10 | ARB 8,946.35'."""
157
+ try:
158
+ rows = {r.get("symbol"): r for r in rest_balances()}
159
+ except Exception as e:
160
+ print("wallet read failed:", repr(e), flush=True)
161
+ return "balances unavailable"
162
+ parts = [f"{QUOTE} {money(rows.get(QUOTE, {}).get('trading') or 0)}"]
163
+ if base and base in rows:
164
+ parts.append(f"{base} {num(rows[base].get('trading') or 0)}")
165
+ return " | ".join(parts)
166
+
167
+
168
+ def notify(emoji, action, *lines):
169
+ """Telegram layout: emoji + action, then one line per fact."""
170
+ alert("\n".join([f"{emoji} {action}"] + [l for l in lines if l]))
171
+
172
+
173
+ def rest_open_orders():
174
+ """All open orders via signed REST (carries clientOrderId + symbol)."""
175
+ ts = str(int(time.time() * 1000))
176
+ path = "/v1/orders/open"
177
+ canon = "\n".join([ts, "GET", path, "", hashlib.sha256(b"").hexdigest()])
178
+ req = urllib.request.Request(API + path, method="GET")
179
+ req.add_header("User-Agent", UA)
180
+ req.add_header("Accept", "application/json")
181
+ req.add_header("Origin", "https://testnet.polyester.com")
182
+ req.add_header("X-API-KEY-ID", KEY)
183
+ req.add_header("X-API-TIMESTAMP", ts)
184
+ req.add_header("X-API-SIGNATURE", _SK.sign(canon.encode()).signature.hex())
185
+ with urllib.request.urlopen(req, timeout=30) as r:
186
+ return json.loads(r.read() or b'{"orders": []}')
187
+
188
+
189
+ def rest_balances():
190
+ """GET /v1/balances via signed REST (rows carry asset symbols)."""
191
+ ts = str(int(time.time() * 1000))
192
+ path = "/v1/balances"
193
+ canon = "\n".join([ts, "GET", path, "", hashlib.sha256(b"").hexdigest()])
194
+ req = urllib.request.Request(API + path, method="GET")
195
+ req.add_header("User-Agent", UA)
196
+ req.add_header("Accept", "application/json")
197
+ req.add_header("Origin", "https://testnet.polyester.com")
198
+ req.add_header("X-API-KEY-ID", KEY)
199
+ req.add_header("X-API-TIMESTAMP", ts)
200
+ req.add_header("X-API-SIGNATURE", _SK.sign(canon.encode()).signature.hex())
201
+ with urllib.request.urlopen(req, timeout=30) as r:
202
+ return json.loads(r.read())["balances"]
203
+
204
+
205
+ def rest_me():
206
+ """GET /v1/auth/me via signed REST (the account this key belongs to)."""
207
+ ts = str(int(time.time() * 1000))
208
+ path = "/v1/auth/me"
209
+ canon = "\n".join([ts, "GET", path, "", hashlib.sha256(b"").hexdigest()])
210
+ req = urllib.request.Request(API + path, method="GET")
211
+ req.add_header("User-Agent", UA)
212
+ req.add_header("Accept", "application/json")
213
+ req.add_header("Origin", "https://testnet.polyester.com")
214
+ req.add_header("X-API-KEY-ID", KEY)
215
+ req.add_header("X-API-TIMESTAMP", ts)
216
+ req.add_header("X-API-SIGNATURE", _SK.sign(canon.encode()).signature.hex())
217
+ with urllib.request.urlopen(req, timeout=30) as r:
218
+ return json.loads(r.read() or b"{}")
219
+
220
+
221
+ async def place(c, sym, side, qty, price, post_only=False):
222
+ cid = "gap1-" + side[0] + "-" + uuid.uuid4().hex[:12]
223
+ item = {"symbol": sym, "side": side, "order_type": "limit", "tif": "gtc",
224
+ "qty": format(qty, "f"), "price": format(price, "f"),
225
+ "client_order_id": cid}
226
+ if post_only:
227
+ item["post_only"] = True
228
+ res = await c.orders.batch_create(symbol=sym, items=[item],
229
+ request_id="gap1-" + uuid.uuid4().hex[:12])
230
+ r = res.results[0]
231
+ if r.status == "rejected":
232
+ return None, str(getattr(r, "code", "") or "REJECTED")
233
+ return cid, None
234
+
235
+
236
+ async def order_state(c, sym, cid, scale):
237
+ """(status_lower, cum_qty, avg_px) for one of our orders."""
238
+ res = await c.orders.get(key=ClientOrderId(cid))
239
+ o = getattr(res, "order", None)
240
+ if o is None:
241
+ return "pending", Decimal(0), Decimal(0)
242
+ return (str(o.status).lower(), qdec(o.cum_qty, scale), pdec(o.avg_px))
243
+
244
+
245
+ async def scan_books(c, pairs):
246
+ """Every USDT pair with a two-sided book: (gap_pct, sym, bid, ask, bid$, ask$)."""
247
+ rows = []
248
+ for sym in pairs:
249
+ try:
250
+ book = await c.orderbook.get(symbol=sym, depth=1)
251
+ if not book.bids or not book.asks:
252
+ continue
253
+ bid, ask = pdec(book.bids[0].price), pdec(book.asks[0].price)
254
+ if bid <= 0 or ask <= bid:
255
+ continue
256
+ gap = (ask - bid) / ((ask + bid) / 2) * 100
257
+ bid_usd = pdec(book.bids[0].qty) * bid
258
+ ask_usd = pdec(book.asks[0].qty) * ask
259
+ rows.append((gap, sym, bid, ask, bid_usd, ask_usd))
260
+ except Exception as e:
261
+ print("scan skip", sym, repr(e), flush=True)
262
+ rows.sort(key=lambda r: r[0], reverse=True)
263
+ return rows
264
+
265
+
266
+ async def place_sell(c, pos, meta):
267
+ sym = pos["symbol"]
268
+ base = sym.split("-")[0]
269
+ tick, step, min_not = meta[sym]["tick"], meta[sym]["step"], meta[sym]["min_notional"]
270
+ book = await c.orderbook.get(symbol=sym, depth=10)
271
+ if not book.asks:
272
+ return False
273
+ best_ask = pdec(book.asks[0].price)
274
+ best_bid = pdec(book.bids[0].price) if book.bids else Decimal(0)
275
+ spread = ((best_ask - best_bid) / ((best_ask + best_bid) / 2) * 100
276
+ if best_bid else Decimal(0))
277
+ basis = pos.get("buy_px") or Decimal(0)
278
+ if pos.get("bail"):
279
+ target = best_ask - tick
280
+ elif pos.get("join_ask"):
281
+ target = max(best_ask, basis) if basis > 0 else best_ask
282
+ elif basis > 0:
283
+ target = max(best_ask - tick, basis)
284
+ else:
285
+ target = best_ask - tick
286
+ q = floor_step(pos["qty"], step)
287
+ held = None
288
+ try:
289
+ for row in rest_balances():
290
+ if row.get("symbol") == base:
291
+ held = floor_step(Decimal(row.get("trading") or 0), step)
292
+ q = min(q, floor_step(Decimal(row.get("available") or 0), step))
293
+ break
294
+ except Exception:
295
+ held = None
296
+ if q <= 0 or q * best_ask < min_not:
297
+ if held and held * best_ask >= min_not:
298
+ try:
299
+ await c.orders.cancel_all(symbol=sym,
300
+ request_id="gap1-ca-" + uuid.uuid4().hex[:10])
301
+ pos["skip_noted"] = False
302
+ notify("⚠️", "ORPHAN CLEARED",
303
+ f"{sym} | {num(held)} reserved by an orphan order | retrying")
304
+ except Exception as e:
305
+ print("orphan clear failed:", repr(e), flush=True)
306
+ elif not pos.get("skip_noted"):
307
+ pos["too_small"] = True
308
+ pos["skip_noted"] = True
309
+ notify("⚠️", "SELL SKIPPED",
310
+ f"{sym} | below pair minimums | qty {num(q)}")
311
+ return False
312
+ po = not pos.get("no_postonly")
313
+ for price, post in ((target, po), (best_ask, False)):
314
+ if price <= 0:
315
+ continue
316
+ cid, err = await place(c, sym, "sell", q, price, post_only=post)
317
+ if cid:
318
+ pos["sell_cid"], pos["ask_px"] = cid, price
319
+ pos["better"], pos["ask_at"] = {}, time.time()
320
+ pnl_txt = (f"expected {(price - basis) * q * Decimal('0.998'):+.2f} USDT"
321
+ if basis > 0 else "expected P&L unknown (no basis)")
322
+ notify("🔵", "ASK",
323
+ f"{sym} | gap {spread:.3f}% | {num(q)} @ {num(price)} | "
324
+ f"bought @ {num(basis) if basis else '?'}",
325
+ f"{pnl_txt} | {wallet(base)}")
326
+ return True
327
+ if err and "insufficient" in err.lower():
328
+ try:
329
+ await c.orders.cancel_all(symbol=sym,
330
+ request_id="gap1-ca-" + uuid.uuid4().hex[:10])
331
+ pos["skip_noted"] = False
332
+ notify("⚠️", "INSUFFICIENT FUNDS",
333
+ f"{sym} | orphaned orders cleared | retrying")
334
+ except Exception as e:
335
+ print("orphan clear failed:", repr(e), flush=True)
336
+ elif pos.get("last_err") != err:
337
+ pos["last_err"] = err
338
+ notify("⚠️", "ASK REJECTED", f"{sym} | {err}")
339
+ return False
340
+
341
+
342
+ def season_volume():
343
+ """Season volume from the checkpoint scorecard, or None if unavailable."""
344
+ try:
345
+ out = subprocess.run(
346
+ [sys.executable, os.path.join(HERE, "polyester_farm.py"), "checkpoints"],
347
+ capture_output=True, text=True, timeout=180, cwd=HERE)
348
+ m = re.search(r"volume=\$([\d.]+)", out.stdout or "")
349
+ if m:
350
+ return Decimal(m.group(1))
351
+ print("volume parse failed:", (out.stderr or out.stdout)[-200:], flush=True)
352
+ except Exception as e:
353
+ print("volume baseline fetch failed:", repr(e), flush=True)
354
+ return None
355
+
356
+
357
+ def rest_asset_balance(base):
358
+ """(trading, available) for one asset via signed REST. Raises on failure."""
359
+ for row in rest_balances():
360
+ if row.get("symbol") == base:
361
+ return (Decimal(row.get("trading") or 0),
362
+ Decimal(row.get("available") or 0))
363
+ return Decimal(0), Decimal(0)
364
+
365
+
366
+ async def base_balance(c, sym, m):
367
+ """Total base held (trading) via REST. Raises on failure."""
368
+ base = sym.split("-")[0]
369
+ held, _ = rest_asset_balance(base)
370
+ return held
371
+
372
+
373
+ async def main():
374
+ global ACCOUNT
375
+ last_wall, last_mono = time.time(), time.monotonic()
376
+ c = None
377
+ for attempt in range(12):
378
+ try:
379
+ if not ACCOUNT:
380
+ ACCOUNT = str((rest_me() or {}).get("accountId") or "")
381
+ if not ACCOUNT:
382
+ raise RuntimeError("the exchange returned no account id")
383
+ print("account:", ACCOUNT, flush=True)
384
+ c = AsyncPolyester(environment=POLYESTER_TESTNET_ENVIRONMENT,
385
+ api_key_id=KEY,
386
+ api_private_key=os.environ["POLYESTER_ED25519_SEED"],
387
+ default_account_id=ACCOUNT)
388
+ await c.wait_for_catalogs()
389
+ break
390
+ except Exception as e:
391
+ print(f"startup retry {attempt + 1}/12: {repr(e)}", flush=True)
392
+ if c is not None:
393
+ try:
394
+ await c.aclose()
395
+ except Exception:
396
+ pass
397
+ c = None
398
+ await asyncio.sleep(5)
399
+ if c is None:
400
+ raise SystemExit("API unreachable after 12 retries; supervisor will restart us")
401
+ try:
402
+ await c.wait_for_catalogs()
403
+ cfg = (await c.market_data.get_spot_config()).raw["pairs"]
404
+ pairs = {p["symbol"]: p for p in cfg
405
+ if p.get("quoteAsset") == QUOTE
406
+ and p.get("status") in ("PAIR_STATUS_ENABLED", "PAIR_STATUS_POST_ONLY")}
407
+ meta = {s: {"tick": Decimal(p["tickSize"]), "step": Decimal(p["stepSize"]),
408
+ "min_notional": Decimal(p.get("minNotionalQuote") or "0"),
409
+ "min_qty": Decimal(p.get("minQtyBase") or "0"),
410
+ "scale": int(p.get("baseQuantityScale") or 8)}
411
+ for s, p in pairs.items()}
412
+
413
+ async def resync(tag):
414
+ """Cancel our stale orders, re-read balances.
415
+
416
+ Returns (held, ok). ok=False means the exchange was unreachable, so
417
+ the caller must keep its position instead of forgetting it.
418
+ """
419
+ held, ok = [], False
420
+ for attempt in range(24):
421
+ try:
422
+ held = []
423
+ for row in rest_balances():
424
+ sym = row.get("symbol", "")
425
+ if sym != QUOTE and Decimal(row.get("trading") or 0) > 0 \
426
+ and (sym + "-" + QUOTE) in pairs:
427
+ held.append((sym + "-" + QUOTE, Decimal(row["trading"])))
428
+ ok = True
429
+ break
430
+ except Exception as e:
431
+ print(f"balances read retry {attempt + 1}/24: {repr(e)}", flush=True)
432
+ await asyncio.sleep(5)
433
+ if not ok:
434
+ notify("⚠️", "RESYNC FAILED",
435
+ "balances unreadable | keeping the position")
436
+ return [], False
437
+
438
+ left = []
439
+ for attempt in range(4):
440
+ try:
441
+ ours = [o for o in rest_open_orders().get("orders", [])
442
+ if str(o.get("clientOrderId", "")).startswith("gap1-")]
443
+ for o in ours:
444
+ await c.orders.cancel(key=ClientOrderId(o["clientOrderId"]),
445
+ symbol=o.get("symbol"))
446
+ await asyncio.sleep(3)
447
+ left = [o for o in rest_open_orders().get("orders", [])
448
+ if str(o.get("clientOrderId", "")).startswith("gap1-")]
449
+ if not left:
450
+ break
451
+ except Exception as e:
452
+ print(f"sweep retry {attempt + 1}/4:", repr(e), flush=True)
453
+ await asyncio.sleep(3)
454
+ if left:
455
+ notify("⚠️", "ORDERS STILL OPEN",
456
+ f"{len(left)} of our orders survived the sweep")
457
+ return held, True
458
+
459
+ adopt, boot_ok = await resync("gap1-boot")
460
+
461
+ notify("🤖", "GAP HUNTER ONLINE",
462
+ f"gap ≥ {GAP_MIN_PCT}% | depth ≥ {money(DEPTH_MIN_USD)} USDT | "
463
+ + (", ".join(f"{s} {num(q)}" for s, q in adopt) or "nothing to adopt"),
464
+ wallet())
465
+
466
+ state = "idle"
467
+ adopt_q = list(adopt)
468
+ pos = {}
469
+ carry = {}
470
+ dust = {}
471
+ pending_resync = not boot_ok
472
+ next_scan = 0.0
473
+ next_fallback = 0.0
474
+ next_block_alert = 0.0
475
+
476
+ vol, next_10k = None, None
477
+ try:
478
+ with open(state_file("volume_state.json"), encoding="utf-8") as f:
479
+ s = json.load(f)
480
+ vol = Decimal(s["total"])
481
+ next_10k = int(s["next_milestone"])
482
+ notify("📊", "VOLUME TRACKER RESUMED",
483
+ f"{money(vol)} USDT | next milestone {next_10k:,}")
484
+ except (OSError, ValueError, KeyError):
485
+ pass
486
+ if vol is None:
487
+ env_base = os.environ.get("VOLUME_BASELINE", "").strip()
488
+ if env_base:
489
+ try:
490
+ vol = Decimal(env_base)
491
+ except Exception:
492
+ vol = None
493
+ if vol is None:
494
+ vol = season_volume()
495
+ if vol is None:
496
+ vol = Decimal(50000)
497
+ notify("⚠️", "VOLUME BASELINE",
498
+ "scorecard unavailable | starting from 50,000")
499
+ if next_10k is None:
500
+ next_10k = (int(vol // 10000) + 1) * 10000
501
+ notify("📊", "VOLUME TRACKER ONLINE",
502
+ f"season baseline {money(vol)} USDT | next milestone {next_10k:,}")
503
+
504
+ def save_vol():
505
+ try:
506
+ with open(state_file("volume_state.json"), "w",
507
+ encoding="utf-8") as f:
508
+ json.dump({"total": str(vol), "next_milestone": next_10k}, f)
509
+ except OSError:
510
+ pass
511
+
512
+ def beat(now):
513
+ """Heartbeat for the external keepalive: a stale file means we are wedged."""
514
+ try:
515
+ with open(HEARTBEAT, "w", encoding="utf-8") as f:
516
+ f.write(str(int(now)))
517
+ except OSError:
518
+ pass
519
+
520
+ def cycle_done(sym, sell_px):
521
+ """Close a cycle from balance truth; alert with balances + volume."""
522
+ nonlocal vol, next_10k, pos, state
523
+ base_sym = sym.split("-")[0]
524
+ base_held, _ = rest_asset_balance(base_sym)
525
+ qty0 = pos.get("qty0") or pos.get("qty") or Decimal(0)
526
+ sold = max(Decimal(0), qty0 - base_held)
527
+ sell_px = sell_px or pos.get("last_avg") or pos.get("ask_px") or Decimal(0)
528
+ basis = pos.get("buy_px") or Decimal(0)
529
+ sell_usd = sell_px * sold
530
+ buy_usd = basis * sold
531
+ fees = (sell_usd + buy_usd) * Decimal("0.001")
532
+ net = sell_usd - buy_usd - fees
533
+ if sell_px > 0:
534
+ net_txt = f"net {net:+.2f} USDT"
535
+ else:
536
+ net_txt = "P&L unknown (fill price not observed)"
537
+ notify("✅", "CYCLE DONE",
538
+ f"{sym} | sold {num(sold)} @ "
539
+ f"{num(sell_px) if sell_px else '?'} | fee {money(fees)} USDT",
540
+ f"{net_txt} | {wallet(base_sym)}")
541
+ buy_side = basis if basis > 0 else sell_px
542
+ vol += sold * (buy_side + sell_px)
543
+ while vol >= next_10k:
544
+ notify("🏆", "MILESTONE",
545
+ f"{next_10k:,} USDT season volume | tracker {money(vol)} USDT",
546
+ f"{money(vol - next_10k)} USDT over the line | {wallet()}")
547
+ next_10k += 10000
548
+ save_vol()
549
+ pos, state = {}, "idle"
550
+
551
+ while True:
552
+ now = time.time()
553
+ try:
554
+ mono = time.monotonic()
555
+ gap = frozen_seconds(now, mono, last_wall, last_mono)
556
+ stall = now - last_wall
557
+ last_wall, last_mono = now, mono
558
+ beat(now)
559
+ if (gap > FREEZE_SECONDS or stall > STALL_SECONDS) and not pending_resync:
560
+ pending_resync = True
561
+ notify("❄️", "FREEZE DETECTED",
562
+ f"sandbox paused {max(gap, stall) / 60:.1f} min "
563
+ f"| re-syncing orders and balances",
564
+ wallet())
565
+ if pending_resync:
566
+ held, ok = await resync(f"gap1-freeze-{int(now)}")
567
+ if ok:
568
+ pending_resync = False
569
+ adopt_q = list(held)
570
+ pos, state = {}, "idle"
571
+ next_scan = 0.0
572
+ now = time.time()
573
+ elif state == "idle" and adopt_q:
574
+ sym, qty = adopt_q.pop(0)
575
+ basis, qty0 = carry.get(sym, (None, None))
576
+ pos = {"symbol": sym, "qty": qty, "qty0": qty0 or qty,
577
+ "buy_px": basis if basis is not None
578
+ else COST_BASIS.get(sym, Decimal(0)),
579
+ "sell_cid": None, "better": {}, "since": now}
580
+ state = "selling"
581
+ carry[sym] = (pos["buy_px"], pos["qty0"])
582
+ notify("♻️", "ADOPT",
583
+ f"{sym} {num(qty)} | basis "
584
+ f"{num(pos['buy_px']) if pos['buy_px'] else 'unknown'}",
585
+ wallet(sym.split("-")[0]))
586
+
587
+ elif state == "idle":
588
+ if now >= next_scan:
589
+ next_scan = now + SCAN_SECONDS
590
+ rows = await scan_books(c, pairs)
591
+ ours = [o for o in rest_open_orders().get("orders", [])
592
+ if str(o.get("clientOrderId", "")).startswith("gap1-")]
593
+ held = []
594
+ for r in rest_balances():
595
+ psym = r.get("symbol", "") + "-" + QUOTE
596
+ qty = Decimal(r.get("trading") or 0)
597
+ if r.get("symbol") == QUOTE or qty <= 0:
598
+ continue
599
+ if psym not in pairs or dust.get(psym) == qty:
600
+ continue
601
+ held.append(r)
602
+ if held and not adopt_q:
603
+ adopt_q.extend((r["symbol"] + "-" + QUOTE,
604
+ Decimal(r["trading"])) for r in held)
605
+ notify("♻️", "ADOPT QUEUED",
606
+ f"{len(held)} position(s) to sell back | "
607
+ + ", ".join(f"{r['symbol']} {num(r['trading'])}"
608
+ for r in held),
609
+ wallet())
610
+ picked, tag = None, ""
611
+ gaps = [r for r in rows
612
+ if r[0] >= GAP_MIN_PCT
613
+ and min(r[4], r[5]) >= DEPTH_MIN_USD]
614
+ if gaps:
615
+ picked, tag = gaps[0], "GAP"
616
+ elif now >= next_fallback:
617
+ fb = [r for r in rows
618
+ if r[0] >= FALLBACK_MIN_GAP_PCT
619
+ and min(r[4], r[5]) >= FALLBACK_DEPTH_MIN_USD]
620
+ if FALLBACK_SYMBOL:
621
+ fb = [r for r in fb if r[1] == FALLBACK_SYMBOL]
622
+ if fb:
623
+ picked, tag = fb[0], "VOLUME"
624
+ next_fallback = now + FALLBACK_COOLDOWN
625
+ if picked:
626
+ gap, sym, bid, ask, bid_usd, ask_usd = picked
627
+ hot, chg = False, Decimal(0)
628
+ if ours or held:
629
+ try:
630
+ cr = await c.market_data.get_candles(
631
+ symbol=sym, timeframe="1h", limit=25)
632
+ cs = list(cr.candles)
633
+ if len(cs) >= 2 and Decimal(cs[0].close) > 0:
634
+ chg = abs((Decimal(cs[-1].close)
635
+ / Decimal(cs[0].close) - 1) * 100)
636
+ hot = chg > 20
637
+ except Exception as e:
638
+ print("hot check failed:", repr(e), flush=True)
639
+ if (ours or held) and not hot:
640
+ if now >= next_block_alert:
641
+ next_block_alert = now + 600
642
+ notify("⚠️", "ONE-POSITION",
643
+ f"{sym} | {len(ours)} open order(s) | "
644
+ f"{len(held)} held position(s) | 24h {chg:.1f}%",
645
+ f"not starting a new trade | {wallet()}")
646
+ picked = None
647
+ elif hot and (ours or held):
648
+ try:
649
+ await c.orders.cancel_all(
650
+ request_id="gap1-hot-" + uuid.uuid4().hex[:10])
651
+ except Exception:
652
+ pass
653
+ adopt_q.extend((r["symbol"] + "-" + QUOTE,
654
+ Decimal(r["trading"])) for r in held)
655
+ notify("🔥", "HOT OVERRIDE",
656
+ f"{sym} | 24h {chg:.1f}% > 20% | "
657
+ f"{len(ours)} order(s) | {len(held)} held",
658
+ "starting anyway | queued for adoption")
659
+ picked = None
660
+ if picked:
661
+ gap, sym, bid, ask, bid_usd, ask_usd = picked
662
+ m = meta[sym]
663
+ price = bid if (tag == "VOLUME" and not FALLBACK_PENNY) \
664
+ else min(bid + m["tick"], ask - m["tick"])
665
+ usdt = next((Decimal(r["available"]) for r in rest_balances()
666
+ if r.get("symbol") == QUOTE), Decimal(0))
667
+ q = floor_step(usdt / (price * FEE_BUFFER), m["step"])
668
+ if q > 0 and q >= m["min_qty"] and q * price >= m["min_notional"]:
669
+ cid, err = await place(c, sym, "buy", q, price)
670
+ if cid:
671
+ pos = {"symbol": sym, "buy_cid": cid, "buy_px": price,
672
+ "buy_at": now, "qty": None,
673
+ "sell_cid": None, "better": {},
674
+ "join_ask": tag == "VOLUME"}
675
+ state = "buying"
676
+ if tag == "GAP":
677
+ notify("🟢", "BUY",
678
+ f"{sym} | gap {gap:.2f}% "
679
+ f"(net +{gap - RT_FEE_PCT:.2f}%) | "
680
+ f"{num(q)} @ {num(price)} | "
681
+ f"depth {money(bid_usd)}/{money(ask_usd)}",
682
+ f"expected +{q * price * (gap - RT_FEE_PCT) / 100:.2f}"
683
+ f" USDT | {wallet(sym.split('-')[0])}")
684
+ else:
685
+ loss = q * price * (RT_FEE_PCT - gap) / 100
686
+ notify("🟡", "VOLUME BUY",
687
+ f"{sym} | gap {gap:.3f}% "
688
+ f"(fee {RT_FEE_PCT}%) | "
689
+ f"{num(q)} @ {num(price)} | "
690
+ f"volume {money(q * price * 2)} USDT",
691
+ f"expected -{loss:.2f} USDT "
692
+ f"| {wallet(sym.split('-')[0])}")
693
+ else:
694
+ notify("⚠️", "SKIPPED",
695
+ f"{sym} | {tag} | below pair minimums")
696
+
697
+ elif state == "buying":
698
+ sym = pos["symbol"]
699
+ m = meta[sym]
700
+ st, cum, avg = await order_state(c, sym, pos["buy_cid"],
701
+ m["scale"])
702
+ if st in TERMINAL:
703
+ held_qty = floor_step(await base_balance(c, sym, m), m["step"])
704
+ qty = held_qty or floor_step(cum, m["step"])
705
+ if st == "filled" or cum > 0:
706
+ pos["qty"] = qty
707
+ pos["qty0"] = qty
708
+ pos["buy_px"] = avg if avg > 0 else pos["buy_px"]
709
+ pos["since"] = now
710
+ carry[sym] = (pos["buy_px"], pos["qty0"])
711
+ notify("✅", "BUY FILLED",
712
+ f"{sym} | {num(pos['qty'])} @ {num(pos['buy_px'])}",
713
+ wallet(sym.split("-")[0]))
714
+ state = "selling"
715
+ else:
716
+ pos["qty"] = held_qty
717
+ notify("⚠️", "BUY UNFILLED",
718
+ f"{sym} | order ended {st}",
719
+ wallet(sym.split("-")[0]))
720
+ state = "idle"
721
+ elif now - pos["buy_at"] > BUY_TIMEOUT_SECONDS:
722
+ try:
723
+ await c.orders.cancel(key=ClientOrderId(pos["buy_cid"]),
724
+ symbol=sym)
725
+ except Exception:
726
+ pass
727
+ st, cum, avg = await order_state(c, sym, pos["buy_cid"],
728
+ m["scale"])
729
+ notify("⚠️", "BUY TIMEOUT",
730
+ f"{sym} | no fill after {BUY_TIMEOUT_SECONDS:.0f}s "
731
+ f"| cancelled",
732
+ wallet(sym.split("-")[0]))
733
+ pos["qty"] = floor_step(await base_balance(c, sym, m), m["step"])
734
+ if pos["qty"] > 0 or cum > 0:
735
+ pos["qty"] = pos["qty"] or floor_step(cum, m["step"])
736
+ pos["qty0"] = pos["qty"]
737
+ pos["buy_px"] = avg if avg > 0 else pos["buy_px"]
738
+ pos["since"] = now
739
+ carry[sym] = (pos["buy_px"], pos["qty0"])
740
+ notify("✅", "BUY FILLED",
741
+ f"{sym} | {num(pos['qty'])} @ {num(pos['buy_px'])}",
742
+ wallet(sym.split("-")[0]))
743
+ state = "selling"
744
+ else:
745
+ state = "idle"
746
+
747
+ elif state == "selling":
748
+ sym = pos["symbol"]
749
+ m = meta[sym]
750
+ held, avail = rest_asset_balance(sym.split("-")[0])
751
+ price_est = pos.get("last_avg") or pos.get("ask_px") or Decimal(0)
752
+ if held > 0:
753
+ pos["seen"] = True
754
+ if held <= 0 and not pos.get("seen"):
755
+ if now - pos.get("since", 0) < BALANCE_GRACE:
756
+ pass
757
+ else:
758
+ cycle_done(sym, price_est)
759
+ elif held < m["min_qty"] or (price_est > 0 and
760
+ held * price_est < m["min_notional"]):
761
+ if held > 0:
762
+ dust[sym] = held
763
+ cycle_done(sym, price_est)
764
+ elif pos["sell_cid"] is None:
765
+ pos["qty"] = min(pos.get("qty") or held, held)
766
+ placed = await place_sell(c, pos, meta)
767
+ if placed:
768
+ pos["too_small"] = False
769
+ elif pos.get("too_small"):
770
+ dust[sym] = held
771
+ notify("⚠️", "DUST",
772
+ f"{sym} | {num(held)} left | too small to sell",
773
+ wallet(sym.split("-")[0]))
774
+ pos, state = {}, "idle"
775
+ else:
776
+ st, cum, avg = await order_state(c, sym, pos["sell_cid"], m["scale"])
777
+ if avg and avg > 0:
778
+ pos["last_avg"] = avg
779
+ if st in TERMINAL:
780
+ if st == "rejected" and cum <= 0:
781
+ pos["no_postonly"] = True
782
+ pos["sell_cid"] = None
783
+ pos["canceling"] = False
784
+ elif pos.get("canceling"):
785
+ if any(o.get("clientOrderId") == pos["sell_cid"]
786
+ for o in rest_open_orders().get("orders", [])):
787
+ try:
788
+ await c.orders.cancel(key=ClientOrderId(pos["sell_cid"]),
789
+ symbol=sym)
790
+ except Exception:
791
+ pass
792
+ else:
793
+ pos["sell_cid"] = None
794
+ pos["canceling"] = False
795
+ else:
796
+ book = await c.orderbook.get(symbol=sym, depth=10)
797
+ better = {pdec(a.price) for a in (book.asks or [])
798
+ if pdec(a.price) < pos["ask_px"]}
799
+ track = pos["better"]
800
+ for p in list(track):
801
+ if p not in better:
802
+ del track[p]
803
+ for p in better:
804
+ track.setdefault(p, now)
805
+ aged = sum(1 for p, t0 in track.items()
806
+ if now - t0 >= PERSIST_SECONDS)
807
+ if aged > BETTER_ASKS_LIMIT:
808
+ try:
809
+ await c.orders.cancel(key=ClientOrderId(pos["sell_cid"]),
810
+ symbol=sym)
811
+ except Exception:
812
+ pass
813
+ notify("⚠️", "ASK BAIL-OUT",
814
+ f"{sym} | {aged} better asks held "
815
+ f"{PERSIST_SECONDS:.0f}s | re-asking under them",
816
+ wallet(sym.split("-")[0]))
817
+ pos["canceling"] = True
818
+ pos["bail"] = True
819
+ except Exception as e:
820
+ print("loop error:", repr(e), flush=True)
821
+ await asyncio.sleep(POLL_SECONDS)
822
+ finally:
823
+ await c.aclose()
824
+
825
+
826
+ def cli():
827
+ if sys.argv[1:2] == ["setup"]:
828
+ if __package__:
829
+ from .setup import cli as setup_cli
830
+ else:
831
+ from setup import cli as setup_cli
832
+ setup_cli()
833
+ return
834
+ missing = [k for k in ("POLYESTER_API_KEY_ID", "POLYESTER_ED25519_SEED")
835
+ if not os.environ.get(k, "").strip()]
836
+ if missing:
837
+ print("missing credentials: " + ", ".join(missing)
838
+ + " (put them in .env next to the bot or in the environment)")
839
+ sys.exit(1)
840
+ try:
841
+ asyncio.run(main())
842
+ except KeyboardInterrupt:
843
+ pass
844
+
845
+
846
+ if __name__ == "__main__":
847
+ cli()