polyester-bot 0.0.0-stage → 1.2.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,1293 @@
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
+ # Real per-pair fees, read from the exchange every 30 min (observed VIP3:
67
+ # maker 0.05 / taker 0.105). An explicit GAP_MIN_PCT env still pins the floor.
68
+ DEFAULT_MAKER_PCT = Decimal("0.05")
69
+ DEFAULT_TAKER_PCT = Decimal("0.105")
70
+ GAP_MARGIN_PCT = Decimal(os.environ.get("GAP_MARGIN_PCT", "0.01"))
71
+ GAP_MIN_FIXED = (Decimal(os.environ["GAP_MIN_PCT"])
72
+ if os.environ.get("GAP_MIN_PCT") else None)
73
+ FEES = {}
74
+ FEES_REFRESH_SECONDS = float(os.environ.get("FEES_REFRESH_SECONDS", "1800"))
75
+ DEPTH_MIN_USD = Decimal(os.environ.get("DEPTH_MIN_USD", "50"))
76
+ FALLBACK_SYMBOL = os.environ.get("FALLBACK_SYMBOL", "").strip()
77
+ FALLBACK_MIN_GAP_PCT = Decimal(os.environ.get("FALLBACK_MIN_GAP_PCT", "0.05"))
78
+ FALLBACK_DEPTH_MIN_USD = Decimal(os.environ.get("FALLBACK_DEPTH_MIN_USD", "1000"))
79
+ FALLBACK_COOLDOWN = float(os.environ.get("FALLBACK_COOLDOWN", "60"))
80
+ FALLBACK_PENNY = os.environ.get("FALLBACK_PENNY", "0") == "1"
81
+ SAT_PENNY_AFTER = int(os.environ.get("SAT_PENNY_AFTER", "2"))
82
+ SAT_TAKER_AFTER = int(os.environ.get("SAT_TAKER_AFTER", "4"))
83
+ FALLBACK_BUY_TIMEOUT = float(os.environ.get("FALLBACK_BUY_TIMEOUT", "60"))
84
+ TP_TRIGGER_PCT = Decimal(os.environ.get("TP_TRIGGER_PCT", "0"))
85
+ SCAN_SECONDS = float(os.environ.get("SCAN_SECONDS", "10"))
86
+ POLL_SECONDS = float(os.environ.get("POLL_SECONDS", "2"))
87
+ PERSIST_SECONDS = float(os.environ.get("PERSIST_SECONDS", "60"))
88
+ BETTER_ASKS_LIMIT = int(os.environ.get("BETTER_ASKS_LIMIT", "1"))
89
+ BETTER_BIDS_LIMIT = int(os.environ.get("BETTER_BIDS_LIMIT", "1"))
90
+ BUY_TIMEOUT_SECONDS = float(os.environ.get("BUY_TIMEOUT_SECONDS", "180"))
91
+ DEADMAN_TIMEOUT = int(os.environ.get("DEADMAN_TIMEOUT", "120"))
92
+ DEADMAN_REFRESH = 60
93
+ FEE_BUFFER = Decimal("1.0015")
94
+ FREEZE_SECONDS = float(os.environ.get("FREEZE_SECONDS", "300"))
95
+ STALL_SECONDS = float(os.environ.get("STALL_SECONDS", "900"))
96
+ BALANCE_GRACE = float(os.environ.get("BALANCE_GRACE_SECONDS", "120"))
97
+ HEARTBEAT = "/var/tmp/polyester.heartbeat"
98
+
99
+
100
+ def maker_fee_pct(fees, sym):
101
+ """Maker fee % for one pair, falling back to the observed VIP3 rate."""
102
+ return (fees.get(sym) or (DEFAULT_MAKER_PCT, DEFAULT_TAKER_PCT))[0]
103
+
104
+
105
+ def taker_fee_pct(fees, sym):
106
+ return (fees.get(sym) or (DEFAULT_MAKER_PCT, DEFAULT_TAKER_PCT))[1]
107
+
108
+
109
+ def rt_fee_pct(fees, sym):
110
+ """Round-trip % for two maker legs."""
111
+ return 2 * maker_fee_pct(fees, sym)
112
+
113
+
114
+ def gap_min_pct(fixed, fees=None, sym=None):
115
+ """Profitable-gap floor: an explicit GAP_MIN_PCT wins, else 2 x maker + margin."""
116
+ if fixed is not None:
117
+ return fixed
118
+ return 2 * maker_fee_pct(fees or FEES, sym) + GAP_MARGIN_PCT
119
+
120
+
121
+ def round_trip_fees(buy_usd, sell_usd, maker_pct):
122
+ """USDT fees for a maker round trip at maker_pct percent per leg."""
123
+ return (buy_usd + sell_usd) * maker_pct / 100
124
+
125
+
126
+ def entry_price(tag, bid, ask, m, penny, taker):
127
+ """Where the BUY rests: gap buys always penny, a calm fallback joins the
128
+ bid, and the saturation ladder pennies then crosses the spread."""
129
+ if taker:
130
+ return ask
131
+ if tag == "GAP" or penny:
132
+ return min(bid + m["tick"], ask - m["tick"])
133
+ return bid
134
+
135
+
136
+ def buy_timeout_for(tag, sat_n):
137
+ """Fallbacks on the saturation ladder stop waiting sooner."""
138
+ if tag != "GAP" and sat_n >= SAT_PENNY_AFTER:
139
+ return FALLBACK_BUY_TIMEOUT
140
+ return BUY_TIMEOUT_SECONDS
141
+
142
+
143
+ async def deadman_rearm(c):
144
+ """Re-arm the exchange-side auto-cancel: if the bot stops re-arming,
145
+ every open order is cancelled once the deadline passes."""
146
+ try:
147
+ await c.orders.cancel_all_after(timeout_sec=DEADMAN_TIMEOUT)
148
+ return True
149
+ except Exception as e:
150
+ print("deadman re-arm failed:", repr(e), flush=True)
151
+ return False
152
+
153
+
154
+ async def refresh_fees(c):
155
+ """Pull the exchange's real per-pair fee rates; keep the gap floor in step."""
156
+ global GAP_MIN_PCT
157
+ try:
158
+ fr = await c.fees.get_spot_fee_rates()
159
+ for r in fr.fee_rates:
160
+ FEES[r.symbol] = (pdec(r.maker_fee_rate_percent),
161
+ pdec(r.taker_fee_rate_percent))
162
+ GAP_MIN_PCT = gap_min_pct(GAP_MIN_FIXED)
163
+ if FEES:
164
+ s0 = next(iter(FEES))
165
+ print(f"fees refreshed: {len(FEES)} pairs | maker {FEES[s0][0]}% "
166
+ f"taker {FEES[s0][1]}% | gap floor {GAP_MIN_PCT}%", flush=True)
167
+ except Exception as e:
168
+ print("fee refresh failed:", repr(e), flush=True)
169
+
170
+
171
+ GAP_MIN_PCT = gap_min_pct(GAP_MIN_FIXED)
172
+
173
+
174
+ def frozen_seconds(wall, mono, last_wall, last_mono):
175
+ """Seconds the VM spent frozen: wall clock advanced but the guest did not run.
176
+
177
+ A paused sandbox stops the guest kernel, so CLOCK_MONOTONIC freezes while
178
+ the wall clock keeps going. The difference is the freeze duration.
179
+ """
180
+ return (wall - last_wall) - (mono - last_mono)
181
+
182
+ TERMINAL = {"filled", "canceled", "rejected", "expired"}
183
+
184
+ COST_BASIS = {"BTC-USDT": Decimal("76227.38"), "ARB-USDT": Decimal("0.1684")}
185
+
186
+
187
+ def pdec(v):
188
+ if v is None or v == "":
189
+ return Decimal(0)
190
+ return v.as_decimal() if hasattr(v, "as_decimal") else Decimal(str(v))
191
+
192
+
193
+ def qdec(v, scale):
194
+ if v is None or v == "":
195
+ return Decimal(0)
196
+ return v.as_decimal(scale) if hasattr(v, "as_decimal") else Decimal(str(v))
197
+
198
+
199
+ def floor_step(x, step):
200
+ step = Decimal(str(step))
201
+ if step <= 0:
202
+ return x
203
+ return (x / step).to_integral_value(rounding=ROUND_FLOOR) * step
204
+
205
+
206
+ def alert(text):
207
+ print(text, flush=True)
208
+ import threading
209
+
210
+ def send():
211
+ tok = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
212
+ chat = os.environ.get("TELEGRAM_CHAT_ID", "").strip()
213
+ if tok and chat:
214
+ try:
215
+ req = urllib.request.Request(
216
+ "https://api.telegram.org/bot" + tok + "/sendMessage",
217
+ data=urllib.parse.urlencode({"chat_id": chat, "text": text}).encode())
218
+ urllib.request.urlopen(req, timeout=5)
219
+ except Exception as e:
220
+ print("telegram failed:", repr(e), flush=True)
221
+ hook = os.environ.get("DISCORD_WEBHOOK_URL", "").strip()
222
+ if hook:
223
+ try:
224
+ req = urllib.request.Request(
225
+ hook, data=json.dumps({"content": text}).encode(),
226
+ headers={"Content-Type": "application/json"})
227
+ urllib.request.urlopen(req, timeout=10)
228
+ except Exception as e:
229
+ print("discord failed:", repr(e), flush=True)
230
+
231
+ threading.Thread(target=send, daemon=True).start()
232
+
233
+
234
+ def money(x):
235
+ return f"{Decimal(x):,.2f}"
236
+
237
+
238
+ def num(x):
239
+ s = f"{Decimal(x):,.8f}".rstrip("0").rstrip(".")
240
+ return s or "0"
241
+
242
+
243
+ def wallet(base=None):
244
+ """The balances that matter right now: 'USDT 3,094.10 | ARB 8,946.35'."""
245
+ try:
246
+ rows = {r.get("symbol"): r for r in rest_balances()}
247
+ except Exception as e:
248
+ print("wallet read failed:", repr(e), flush=True)
249
+ return "balances unavailable"
250
+ parts = [f"{QUOTE} {money(rows.get(QUOTE, {}).get('trading') or 0)}"]
251
+ if base and base in rows:
252
+ parts.append(f"{base} {num(rows[base].get('trading') or 0)}")
253
+ return " | ".join(parts)
254
+
255
+
256
+ def notify(emoji, action, *lines):
257
+ """Telegram layout: emoji + action, then one line per fact."""
258
+ alert("\n".join([f"{emoji} {action}"] + [l for l in lines if l]))
259
+
260
+
261
+ def rest_open_orders():
262
+ """All open orders via signed REST (carries clientOrderId + symbol)."""
263
+ ts = str(int(time.time() * 1000))
264
+ path = "/v1/orders/open"
265
+ canon = "\n".join([ts, "GET", path, "", hashlib.sha256(b"").hexdigest()])
266
+ req = urllib.request.Request(API + path, method="GET")
267
+ req.add_header("User-Agent", UA)
268
+ req.add_header("Accept", "application/json")
269
+ req.add_header("Origin", "https://testnet.polyester.com")
270
+ req.add_header("X-API-KEY-ID", KEY)
271
+ req.add_header("X-API-TIMESTAMP", ts)
272
+ req.add_header("X-API-SIGNATURE", _SK.sign(canon.encode()).signature.hex())
273
+ with urllib.request.urlopen(req, timeout=30) as r:
274
+ return json.loads(r.read() or b'{"orders": []}')
275
+
276
+
277
+ def rest_balances():
278
+ """GET /v1/balances via signed REST (rows carry asset symbols)."""
279
+ ts = str(int(time.time() * 1000))
280
+ path = "/v1/balances"
281
+ canon = "\n".join([ts, "GET", path, "", hashlib.sha256(b"").hexdigest()])
282
+ req = urllib.request.Request(API + path, method="GET")
283
+ req.add_header("User-Agent", UA)
284
+ req.add_header("Accept", "application/json")
285
+ req.add_header("Origin", "https://testnet.polyester.com")
286
+ req.add_header("X-API-KEY-ID", KEY)
287
+ req.add_header("X-API-TIMESTAMP", ts)
288
+ req.add_header("X-API-SIGNATURE", _SK.sign(canon.encode()).signature.hex())
289
+ with urllib.request.urlopen(req, timeout=30) as r:
290
+ return json.loads(r.read())["balances"]
291
+
292
+
293
+ def rest_me():
294
+ """GET /v1/auth/me via signed REST (the account this key belongs to)."""
295
+ ts = str(int(time.time() * 1000))
296
+ path = "/v1/auth/me"
297
+ canon = "\n".join([ts, "GET", path, "", hashlib.sha256(b"").hexdigest()])
298
+ req = urllib.request.Request(API + path, method="GET")
299
+ req.add_header("User-Agent", UA)
300
+ req.add_header("Accept", "application/json")
301
+ req.add_header("Origin", "https://testnet.polyester.com")
302
+ req.add_header("X-API-KEY-ID", KEY)
303
+ req.add_header("X-API-TIMESTAMP", ts)
304
+ req.add_header("X-API-SIGNATURE", _SK.sign(canon.encode()).signature.hex())
305
+ with urllib.request.urlopen(req, timeout=30) as r:
306
+ return json.loads(r.read() or b"{}")
307
+
308
+
309
+ async def place(c, sym, side, qty, price, post_only=False):
310
+ cid = "gap1-" + side[0] + "-" + uuid.uuid4().hex[:12]
311
+ item = {"symbol": sym, "side": side, "order_type": "limit", "tif": "gtc",
312
+ "qty": format(qty, "f"), "price": format(price, "f"),
313
+ "client_order_id": cid}
314
+ if post_only:
315
+ item["post_only"] = True
316
+ res = await c.orders.batch_create(symbol=sym, items=[item],
317
+ request_id="gap1-" + uuid.uuid4().hex[:12])
318
+ r = res.results[0]
319
+ if r.status == "rejected":
320
+ return None, str(getattr(r, "code", "") or "REJECTED")
321
+ return cid, None
322
+
323
+
324
+ async def order_state(c, sym, cid, scale):
325
+ """(status_lower, cum_qty, avg_px) for one of our orders."""
326
+ res = await c.orders.get(key=ClientOrderId(cid))
327
+ o = getattr(res, "order", None)
328
+ if o is None:
329
+ return "pending", Decimal(0), Decimal(0)
330
+ return (str(o.status).lower(), qdec(o.cum_qty, scale), pdec(o.avg_px))
331
+
332
+
333
+ async def chase_buy(c, pos, m, now):
334
+ """Mirror of the ask bail-out: when competitors outbid our resting BUY
335
+ for too long, cancel and rebid one tick above the best bid, never into
336
+ the ask. Resets the timeout clock on every chase."""
337
+ sym = pos["symbol"]
338
+ bk = await book(c, sym, depth=10)
339
+ better = {pdec(b.price) for b in (bk.bids or [])
340
+ if pdec(b.price) > pos["buy_px"]}
341
+ track = pos["better"]
342
+ for p in list(track):
343
+ if p not in better:
344
+ del track[p]
345
+ for p in better:
346
+ track.setdefault(p, now)
347
+ aged = sum(1 for p, t0 in track.items() if now - t0 >= PERSIST_SECONDS)
348
+ if aged < BETTER_BIDS_LIMIT:
349
+ return
350
+ best = max(better)
351
+ new_px = best + m["tick"]
352
+ top = min((pdec(a.price) for a in (bk.asks or []) if pdec(a.price) > 0),
353
+ default=None)
354
+ if top is not None:
355
+ new_px = min(new_px, top - m["tick"])
356
+ if new_px <= pos["buy_px"]:
357
+ return
358
+ st, cum, avg = await order_state(c, sym, pos["buy_cid"], m["scale"])
359
+ left = floor_step((pos.get("qty_target") or Decimal(0))
360
+ - floor_step(cum, m["step"]), m["step"])
361
+ if st not in TERMINAL:
362
+ try:
363
+ await c.orders.modify(key=ClientOrderId(pos["buy_cid"]),
364
+ symbol=sym, new_price=new_px)
365
+ pos["buy_px"] = new_px
366
+ pos["buy_at"] = now
367
+ pos["better"] = {}
368
+ notify("🔄", "BID CHASE",
369
+ f"{sym} | outbid by {aged} bid(s) held {PERSIST_SECONDS:.0f}s "
370
+ f"| amending to {num(new_px)}",
371
+ wallet(sym.split("-")[0]))
372
+ return
373
+ except Exception as e:
374
+ print("amend fell back to cancel:", repr(e), flush=True)
375
+ try:
376
+ await c.orders.cancel(key=ClientOrderId(pos["buy_cid"]), symbol=sym)
377
+ except Exception:
378
+ pass
379
+ st, cum, avg = await order_state(c, sym, pos["buy_cid"], m["scale"])
380
+ left = floor_step((pos.get("qty_target") or Decimal(0))
381
+ - floor_step(cum, m["step"]), m["step"])
382
+ cid = None
383
+ if left > 0 and left >= m["min_qty"] and left * new_px >= m["min_notional"]:
384
+ cid, _ = await place(c, sym, "buy", left, new_px)
385
+ if cid:
386
+ pos["buy_cid"] = cid
387
+ pos["buy_px"] = new_px
388
+ pos["buy_at"] = now
389
+ pos["better"] = {}
390
+ notify("🔄", "BID CHASE",
391
+ f"{sym} | outbid by {aged} bid(s) held {PERSIST_SECONDS:.0f}s "
392
+ f"| re-bidding {num(left)} @ {num(new_px)}",
393
+ wallet(sym.split("-")[0]))
394
+ # a failed or unneeded re-place is reconciled by the terminal/timeout paths
395
+
396
+
397
+ async def place_tp_trigger(c, pos, m):
398
+ """Arm a server-side take-profit sell: the exchange dumps the position
399
+ itself when price rallies TP_TRIGGER_PCT above entry, bot frozen or not."""
400
+ if TP_TRIGGER_PCT <= 0:
401
+ return None
402
+ sym = pos["symbol"]
403
+ qty = floor_step(pos.get("qty") or Decimal(0), m["step"])
404
+ if qty <= 0 or qty < m["min_qty"]:
405
+ return None
406
+ basis = pos.get("buy_px") or Decimal(0)
407
+ ref = basis
408
+ if ref <= 0:
409
+ try:
410
+ bk = await book(c, sym, depth=1)
411
+ ref = pdec(bk.asks[0].price) if bk.asks else Decimal(0)
412
+ except Exception as e:
413
+ print("tp trigger skipped: book unreadable:", repr(e), flush=True)
414
+ return None
415
+ if ref <= 0:
416
+ return None
417
+ tp = ref * (1 + TP_TRIGGER_PCT / 100)
418
+ if qty * tp < m["min_notional"]:
419
+ return None
420
+ cid = "gap1-tp-" + uuid.uuid4().hex[:12]
421
+ try:
422
+ res = await c.triggers.create(symbol=sym, trigger_type="take_profit",
423
+ side="sell", qty=format(qty, "f"),
424
+ trigger_price=format(tp, "f"),
425
+ order_type="market",
426
+ client_trigger_id=cid)
427
+ pos["tp_cid"] = cid
428
+ pos["tp_tid"] = getattr(res, "trigger_id", None)
429
+ notify("🎯", "TP ARMED",
430
+ f"{sym} | exchange sells {num(qty)} @ {num(tp)} "
431
+ f"| entry {num(ref)}")
432
+ return res
433
+ except Exception as e:
434
+ print("tp trigger failed:", repr(e), flush=True)
435
+ return None
436
+
437
+
438
+ async def cancel_stale_symbol_orders(c, sym):
439
+ """Cancel our own gap1- orders resting on this symbol. The TP trigger can
440
+ beat our own ask to the fill; a leftover sell would be unbacked."""
441
+ try:
442
+ stale = [o for o in rest_open_orders().get("orders", [])
443
+ if str(o.get("clientOrderId", "")).startswith("gap1-")
444
+ and o.get("symbol") == sym]
445
+ for o in stale:
446
+ try:
447
+ await c.orders.cancel(key=ClientOrderId(o["clientOrderId"]),
448
+ symbol=sym)
449
+ except Exception:
450
+ pass
451
+ return len(stale)
452
+ except Exception as e:
453
+ print("stale order sweep failed:", repr(e), flush=True)
454
+ return 0
455
+
456
+
457
+ async def cancel_tp_trigger(c, pos):
458
+ """Best-effort disarm once the cycle closes."""
459
+ tid = pos.get("tp_tid")
460
+ if tid is not None:
461
+ try:
462
+ await c.triggers.cancel(trigger_id=tid)
463
+ except Exception:
464
+ pass
465
+ pos["tp_tid"] = None
466
+
467
+
468
+ FALLBACK_CANDIDATES = 4
469
+
470
+
471
+ async def pick_best_liquid(c, fb, top=FALLBACK_CANDIDATES):
472
+ """Among the tightest fallback candidates prefer the one whose tape is
473
+ alive; an unreadable tape counts as zero flow. Ties keep gap order."""
474
+ if not fb:
475
+ return None
476
+ best, best_n = None, -1
477
+ for row in fb[:top]:
478
+ try:
479
+ tr = await c.market_data.get_trades(symbol=row[1], limit=20)
480
+ n = len(tr.trades)
481
+ except Exception as e:
482
+ print("trade count skip", row[1], repr(e), flush=True)
483
+ n = 0
484
+ if n > best_n:
485
+ best, best_n = row, n
486
+ return best or fb[0]
487
+
488
+
489
+ async def preflight_buy(c, sym, side, qty, price):
490
+ """Ask the exchange whether the buy would be admissible. Fail-open: only
491
+ a clear rejection blocks; an outage proceeds."""
492
+ try:
493
+ res = await c.orders.preview_order(symbol=sym, side=side,
494
+ order_type="limit", tif="gtc",
495
+ qty=format(qty, "f"),
496
+ price=format(price, "f"))
497
+ adm = getattr(res, "admissible", None)
498
+ if adm is None or adm:
499
+ return True, None
500
+ rej = getattr(res, "rejection", None)
501
+ code = getattr(rej, "code", None) or getattr(rej, "message", None) or "rejected"
502
+ return False, str(code)
503
+ except Exception as e:
504
+ print("pre-flight failed (proceeding):", repr(e), flush=True)
505
+ return True, None
506
+
507
+
508
+ WS_BOOKS = {}
509
+
510
+
511
+ def ws_book(sym, max_age=15.0):
512
+ """The last WS book snapshot for sym, if fresh enough to trust."""
513
+ rec = WS_BOOKS.get(sym)
514
+ if rec and (time.monotonic() - rec[1]) <= max_age:
515
+ return rec[0]
516
+ return None
517
+
518
+
519
+ async def book(c, sym, depth=10, max_age=15.0):
520
+ """Orderbook for one pair: the WS-fed snapshot when fresh, REST otherwise."""
521
+ cached = ws_book(sym, max_age)
522
+ if cached is not None:
523
+ return cached
524
+ bk = await c.orderbook.get(symbol=sym, depth=depth)
525
+ WS_BOOKS[sym] = (bk, time.monotonic())
526
+ return bk
527
+
528
+
529
+ async def equity_report(c):
530
+ """Daily equity line from real balances x fresh mids; keeps its own snapshot."""
531
+ try:
532
+ total = Decimal(0)
533
+ parts = []
534
+ for row in rest_balances():
535
+ sym0 = row.get("symbol")
536
+ amt = Decimal(row.get("trading") or 0)
537
+ if amt <= 0:
538
+ continue
539
+ if sym0 == QUOTE:
540
+ total += amt
541
+ continue
542
+ psym = sym0 + "-" + QUOTE
543
+ try:
544
+ bk = await book(c, psym, depth=1)
545
+ if bk.bids and bk.asks:
546
+ mid = (pdec(bk.bids[0].price) + pdec(bk.asks[0].price)) / 2
547
+ total += amt * mid
548
+ parts.append(f"{sym0} {money(amt * mid)}")
549
+ except Exception:
550
+ print("equity skip", sym0, flush=True)
551
+ prev = None
552
+ try:
553
+ with open(state_file("equity_state.json"), encoding="utf-8") as f:
554
+ prev = Decimal(json.load(f)["equity"])
555
+ except (OSError, ValueError, KeyError):
556
+ pass
557
+ delta = f" | 24h {total - prev:+.2f} USDT" if prev is not None else " | no prior snapshot"
558
+ notify("📈", "EQUITY",
559
+ f"total {money(total)} USDT{delta}",
560
+ " | ".join(parts) or "all in USDT")
561
+ try:
562
+ with open(state_file("equity_state.json"), "w",
563
+ encoding="utf-8") as f:
564
+ json.dump({"equity": str(total)}, f)
565
+ except OSError:
566
+ pass
567
+ except Exception as e:
568
+ print("equity report failed:", repr(e), flush=True)
569
+
570
+
571
+ async def run_books_ws(c, ids, stop):
572
+ """Feed WS_BOOKS continuously, one subscription per pair; REST stays the
573
+ fallback when a stream goes quiet. ids maps symbol -> symbolId."""
574
+ async def keep_one(sym, symbol_id):
575
+ while not stop.is_set():
576
+ try:
577
+ sub = await c.orderbook.subscribe(symbol=sym,
578
+ symbol_id=symbol_id, depth=10)
579
+ async for data in sub:
580
+ if getattr(data, "available", True):
581
+ WS_BOOKS[sym] = (data, time.monotonic())
582
+ if stop.is_set():
583
+ break
584
+ except Exception as e:
585
+ print("ws book feed", sym, "down:", repr(e), flush=True)
586
+ try:
587
+ await asyncio.wait_for(stop.wait(), 5.0)
588
+ except asyncio.TimeoutError:
589
+ pass
590
+ await asyncio.gather(*(keep_one(s, i) for s, i in ids.items()))
591
+
592
+
593
+ async def scan_books(c, pairs):
594
+ """Every USDT pair with a two-sided book: (gap_pct, sym, bid, ask, bid$, ask$)."""
595
+ rows = []
596
+ for sym in pairs:
597
+ try:
598
+ bk = await book(c, sym, depth=1)
599
+ if not bk.bids or not bk.asks:
600
+ continue
601
+ bid, ask = pdec(bk.bids[0].price), pdec(bk.asks[0].price)
602
+ if bid <= 0 or ask <= bid:
603
+ continue
604
+ gap = (ask - bid) / ((ask + bid) / 2) * 100
605
+ bid_usd = pdec(bk.bids[0].qty) * bid
606
+ ask_usd = pdec(bk.asks[0].qty) * ask
607
+ rows.append((gap, sym, bid, ask, bid_usd, ask_usd))
608
+ except Exception as e:
609
+ print("scan skip", sym, repr(e), flush=True)
610
+ rows.sort(key=lambda r: r[0], reverse=True)
611
+ return rows
612
+
613
+
614
+ async def place_sell(c, pos, meta):
615
+ sym = pos["symbol"]
616
+ base = sym.split("-")[0]
617
+ tick, step, min_not = meta[sym]["tick"], meta[sym]["step"], meta[sym]["min_notional"]
618
+ bk = await book(c, sym, depth=10)
619
+ if not bk.asks:
620
+ return False
621
+ best_ask = pdec(bk.asks[0].price)
622
+ best_bid = pdec(bk.bids[0].price) if bk.bids else Decimal(0)
623
+ spread = ((best_ask - best_bid) / ((best_ask + best_bid) / 2) * 100
624
+ if best_bid else Decimal(0))
625
+ basis = pos.get("buy_px") or Decimal(0)
626
+ if pos.get("bail"):
627
+ target = best_ask - tick
628
+ elif pos.get("join_ask"):
629
+ target = max(best_ask, basis) if basis > 0 else best_ask
630
+ elif basis > 0:
631
+ target = max(best_ask - tick, basis)
632
+ else:
633
+ target = best_ask - tick
634
+ q = floor_step(pos["qty"], step)
635
+ held = None
636
+ try:
637
+ for row in rest_balances():
638
+ if row.get("symbol") == base:
639
+ held = floor_step(Decimal(row.get("trading") or 0), step)
640
+ q = min(q, floor_step(Decimal(row.get("available") or 0), step))
641
+ break
642
+ except Exception:
643
+ held = None
644
+ if q <= 0 or q * best_ask < min_not:
645
+ if held and held * best_ask >= min_not:
646
+ last = pos.get("orphan_noted")
647
+ if last is not None and time.time() - last < 3600:
648
+ return False
649
+ pos["orphan_noted"] = time.time()
650
+ try:
651
+ await c.orders.cancel_all(symbol=sym,
652
+ request_id="gap1-ca-" + uuid.uuid4().hex[:10])
653
+ pos["skip_noted"] = False
654
+ notify("⚠️", "ORPHAN CLEARED",
655
+ f"{sym} | {num(held)} reserved by an orphan order | retrying")
656
+ except Exception as e:
657
+ print("orphan clear failed:", repr(e), flush=True)
658
+ elif not pos.get("skip_noted"):
659
+ pos["too_small"] = True
660
+ pos["skip_noted"] = True
661
+ notify("⚠️", "SELL SKIPPED",
662
+ f"{sym} | below pair minimums | qty {num(q)}")
663
+ return False
664
+ po = not pos.get("no_postonly")
665
+ for price, post in ((target, po), (best_ask, False)):
666
+ if price <= 0:
667
+ continue
668
+ cid, err = await place(c, sym, "sell", q, price, post_only=post)
669
+ if cid:
670
+ pos["sell_cid"], pos["ask_px"] = cid, price
671
+ pos["better"], pos["ask_at"] = {}, time.time()
672
+ pnl_txt = (f"expected {(price - basis) * q * Decimal('0.998'):+.2f} USDT"
673
+ if basis > 0 else "expected P&L unknown (no basis)")
674
+ notify("🔵", "ASK",
675
+ f"{sym} | gap {spread:.3f}% | {num(q)} @ {num(price)} | "
676
+ f"bought @ {num(basis) if basis else '?'}",
677
+ f"{pnl_txt} | {wallet(base)}")
678
+ return True
679
+ if err and "insufficient" in err.lower():
680
+ try:
681
+ await c.orders.cancel_all(symbol=sym,
682
+ request_id="gap1-ca-" + uuid.uuid4().hex[:10])
683
+ pos["skip_noted"] = False
684
+ notify("⚠️", "INSUFFICIENT FUNDS",
685
+ f"{sym} | orphaned orders cleared | retrying")
686
+ except Exception as e:
687
+ print("orphan clear failed:", repr(e), flush=True)
688
+ elif pos.get("last_err") != err:
689
+ pos["last_err"] = err
690
+ notify("⚠️", "ASK REJECTED", f"{sym} | {err}")
691
+ return False
692
+
693
+
694
+ def season_volume():
695
+ """Season volume from the checkpoint scorecard, or None if unavailable."""
696
+ try:
697
+ out = subprocess.run(
698
+ [sys.executable, os.path.join(HERE, "polyester_farm.py"), "checkpoints"],
699
+ capture_output=True, text=True, timeout=180, cwd=HERE)
700
+ m = re.search(r"volume=\$([\d.]+)", out.stdout or "")
701
+ if m:
702
+ return Decimal(m.group(1))
703
+ print("volume parse failed:", (out.stderr or out.stdout)[-200:], flush=True)
704
+ except Exception as e:
705
+ print("volume baseline fetch failed:", repr(e), flush=True)
706
+ return None
707
+
708
+
709
+ def rest_asset_balance(base):
710
+ """(trading, available) for one asset via signed REST. Raises on failure."""
711
+ for row in rest_balances():
712
+ if row.get("symbol") == base:
713
+ return (Decimal(row.get("trading") or 0),
714
+ Decimal(row.get("available") or 0))
715
+ return Decimal(0), Decimal(0)
716
+
717
+
718
+ async def base_balance(c, sym, m):
719
+ """Total base held (trading) via REST. Raises on failure."""
720
+ base = sym.split("-")[0]
721
+ held, _ = rest_asset_balance(base)
722
+ return held
723
+
724
+
725
+ async def main():
726
+ global ACCOUNT
727
+ last_wall, last_mono = time.time(), time.monotonic()
728
+ c = None
729
+ for attempt in range(12):
730
+ try:
731
+ if not ACCOUNT:
732
+ ACCOUNT = str((rest_me() or {}).get("accountId") or "")
733
+ if not ACCOUNT:
734
+ raise RuntimeError("the exchange returned no account id")
735
+ print("account:", ACCOUNT, flush=True)
736
+ c = AsyncPolyester(environment=POLYESTER_TESTNET_ENVIRONMENT,
737
+ api_key_id=KEY,
738
+ api_private_key=os.environ["POLYESTER_ED25519_SEED"],
739
+ default_account_id=ACCOUNT)
740
+ await c.wait_for_catalogs()
741
+ break
742
+ except Exception as e:
743
+ print(f"startup retry {attempt + 1}/12: {repr(e)}", flush=True)
744
+ if c is not None:
745
+ try:
746
+ await c.aclose()
747
+ except Exception:
748
+ pass
749
+ c = None
750
+ await asyncio.sleep(5)
751
+ if c is None:
752
+ raise SystemExit("API unreachable after 12 retries; supervisor will restart us")
753
+ try:
754
+ await c.wait_for_catalogs()
755
+ cfg = (await c.market_data.get_spot_config()).raw["pairs"]
756
+ pairs = {p["symbol"]: p for p in cfg
757
+ if p.get("quoteAsset") == QUOTE
758
+ and p.get("status") in ("PAIR_STATUS_ENABLED", "PAIR_STATUS_POST_ONLY")}
759
+ meta = {s: {"tick": Decimal(p["tickSize"]), "step": Decimal(p["stepSize"]),
760
+ "min_notional": Decimal(p.get("minNotionalQuote") or "0"),
761
+ "min_qty": Decimal(p.get("minQtyBase") or "0"),
762
+ "scale": int(p.get("baseQuantityScale") or 8)}
763
+ for s, p in pairs.items()}
764
+ ws_stop = asyncio.Event()
765
+ ws_ids = {s: p["symbolId"] for s, p in pairs.items() if "symbolId" in p}
766
+ ws_task = asyncio.create_task(run_books_ws(c, ws_ids, ws_stop))
767
+
768
+ async def resync(tag):
769
+ """Cancel our stale orders, re-read balances.
770
+
771
+ Returns (held, ok). ok=False means the exchange was unreachable, so
772
+ the caller must keep its position instead of forgetting it.
773
+ """
774
+ held, ok = [], False
775
+ for attempt in range(24):
776
+ try:
777
+ held = []
778
+ for row in rest_balances():
779
+ sym = row.get("symbol", "")
780
+ if sym != QUOTE and Decimal(row.get("trading") or 0) > 0 \
781
+ and (sym + "-" + QUOTE) in pairs:
782
+ held.append((sym + "-" + QUOTE, Decimal(row["trading"])))
783
+ ok = True
784
+ break
785
+ except Exception as e:
786
+ print(f"balances read retry {attempt + 1}/24: {repr(e)}", flush=True)
787
+ await asyncio.sleep(5)
788
+ if not ok:
789
+ notify("⚠️", "RESYNC FAILED",
790
+ "balances unreadable | keeping the position")
791
+ return [], False
792
+
793
+ left = []
794
+ for attempt in range(4):
795
+ try:
796
+ ours = [o for o in rest_open_orders().get("orders", [])
797
+ if str(o.get("clientOrderId", "")).startswith("gap1-")]
798
+ for o in ours:
799
+ await c.orders.cancel(key=ClientOrderId(o["clientOrderId"]),
800
+ symbol=o.get("symbol"))
801
+ await asyncio.sleep(3)
802
+ left = [o for o in rest_open_orders().get("orders", [])
803
+ if str(o.get("clientOrderId", "")).startswith("gap1-")]
804
+ if not left:
805
+ break
806
+ except Exception as e:
807
+ print(f"sweep retry {attempt + 1}/4:", repr(e), flush=True)
808
+ await asyncio.sleep(3)
809
+ if left:
810
+ notify("⚠️", "ORDERS STILL OPEN",
811
+ f"{len(left)} of our orders survived the sweep")
812
+ return held, True
813
+
814
+ adopt, boot_ok = await resync("gap1-boot")
815
+ await refresh_fees(c)
816
+
817
+ notify("🤖", "GAP HUNTER ONLINE",
818
+ f"gap ≥ {GAP_MIN_PCT}% | depth ≥ {money(DEPTH_MIN_USD)} USDT | "
819
+ + (", ".join(f"{s} {num(q)}" for s, q in adopt) or "nothing to adopt"),
820
+ wallet())
821
+
822
+ state = "idle"
823
+ adopt_q = list(adopt)
824
+ pos = {}
825
+ carry = {}
826
+ dust = {}
827
+ pending_resync = not boot_ok
828
+ next_scan = 0.0
829
+ next_fallback = 0.0
830
+ next_block_alert = 0.0
831
+ sat = {"n": 0}
832
+ next_fees = 0.0
833
+ fees_seen = False
834
+ last_deadman = 0.0
835
+ last_equity_day = ""
836
+
837
+ vol, next_10k = None, None
838
+ try:
839
+ with open(state_file("volume_state.json"), encoding="utf-8") as f:
840
+ s = json.load(f)
841
+ vol = Decimal(s["total"])
842
+ next_10k = int(s["next_milestone"])
843
+ notify("📊", "VOLUME TRACKER RESUMED",
844
+ f"{money(vol)} USDT | next milestone {next_10k:,}")
845
+ except (OSError, ValueError, KeyError):
846
+ pass
847
+ if vol is None:
848
+ env_base = os.environ.get("VOLUME_BASELINE", "").strip()
849
+ if env_base:
850
+ try:
851
+ vol = Decimal(env_base)
852
+ except Exception:
853
+ vol = None
854
+ if vol is None:
855
+ vol = season_volume()
856
+ if vol is None:
857
+ vol = Decimal(50000)
858
+ notify("⚠️", "VOLUME BASELINE",
859
+ "scorecard unavailable | starting from 50,000")
860
+ if next_10k is None:
861
+ next_10k = (int(vol // 10000) + 1) * 10000
862
+ notify("📊", "VOLUME TRACKER ONLINE",
863
+ f"season baseline {money(vol)} USDT | next milestone {next_10k:,}")
864
+
865
+ def save_vol():
866
+ try:
867
+ with open(state_file("volume_state.json"), "w",
868
+ encoding="utf-8") as f:
869
+ json.dump({"total": str(vol), "next_milestone": next_10k}, f)
870
+ except OSError:
871
+ pass
872
+
873
+ def beat(now):
874
+ """Heartbeat for the external keepalive: a stale file means we are wedged."""
875
+ try:
876
+ with open(HEARTBEAT, "w", encoding="utf-8") as f:
877
+ f.write(str(int(now)))
878
+ except OSError:
879
+ pass
880
+
881
+ async def cycle_done(sym, sell_px):
882
+ """Close a cycle from balance truth; alert with balances + volume."""
883
+ nonlocal vol, next_10k, pos, state
884
+ await cancel_tp_trigger(c, pos)
885
+ await cancel_stale_symbol_orders(c, sym)
886
+ base_sym = sym.split("-")[0]
887
+ base_held, _ = rest_asset_balance(base_sym)
888
+ qty0 = pos.get("qty0") or pos.get("qty") or Decimal(0)
889
+ sold = max(Decimal(0), qty0 - base_held)
890
+ sell_px = sell_px or pos.get("last_avg") or pos.get("ask_px") or Decimal(0)
891
+ basis = pos.get("buy_px") or Decimal(0)
892
+ sell_usd = sell_px * sold
893
+ buy_usd = basis * sold
894
+ mk = maker_fee_pct(FEES, sym)
895
+ fees = round_trip_fees(buy_usd, sell_usd, mk)
896
+ if pos.get("buy_fee_pct") and pos["buy_fee_pct"] != mk:
897
+ fees += buy_usd * (pos["buy_fee_pct"] - mk) / 100
898
+ net = sell_usd - buy_usd - fees
899
+ if sell_px > 0:
900
+ net_txt = f"net {net:+.2f} USDT"
901
+ else:
902
+ net_txt = "P&L unknown (fill price not observed)"
903
+ notify("✅", "CYCLE DONE",
904
+ f"{sym} | sold {num(sold)} @ "
905
+ f"{num(sell_px) if sell_px else '?'} | fee {money(fees)} USDT",
906
+ f"{net_txt} | {wallet(base_sym)}")
907
+ buy_side = basis if basis > 0 else sell_px
908
+ vol += sold * (buy_side + sell_px)
909
+ while vol >= next_10k:
910
+ notify("🏆", "MILESTONE",
911
+ f"{next_10k:,} USDT season volume | tracker {money(vol)} USDT",
912
+ f"{money(vol - next_10k)} USDT over the line | {wallet()}")
913
+ next_10k += 10000
914
+ save_vol()
915
+ pos, state = {}, "idle"
916
+
917
+ while True:
918
+ now = time.time()
919
+ try:
920
+ mono = time.monotonic()
921
+ gap = frozen_seconds(now, mono, last_wall, last_mono)
922
+ stall = now - last_wall
923
+ last_wall, last_mono = now, mono
924
+ beat(now)
925
+ if (gap > FREEZE_SECONDS or stall > STALL_SECONDS) and not pending_resync:
926
+ pending_resync = True
927
+ notify("❄️", "FREEZE DETECTED",
928
+ f"sandbox paused {max(gap, stall) / 60:.1f} min "
929
+ f"| re-syncing orders and balances",
930
+ wallet())
931
+ if pending_resync:
932
+ held, ok = await resync(f"gap1-freeze-{int(now)}")
933
+ if ok:
934
+ pending_resync = False
935
+ adopt_q = list(held)
936
+ pos, state = {}, "idle"
937
+ next_scan = 0.0
938
+ now = time.time()
939
+ elif state == "idle" and adopt_q:
940
+ sym, qty = adopt_q.pop(0)
941
+ basis, qty0 = carry.get(sym, (None, None))
942
+ pos = {"symbol": sym, "qty": qty, "qty0": qty0 or qty,
943
+ "buy_px": basis if basis is not None
944
+ else COST_BASIS.get(sym, Decimal(0)),
945
+ "sell_cid": None, "better": {}, "since": now}
946
+ state = "selling"
947
+ carry[sym] = (pos["buy_px"], pos["qty0"])
948
+ if sym in meta:
949
+ await place_tp_trigger(c, pos, meta[sym])
950
+ notify("♻️", "ADOPT",
951
+ f"{sym} {num(qty)} | basis "
952
+ f"{num(pos['buy_px']) if pos['buy_px'] else 'unknown'}",
953
+ wallet(sym.split("-")[0]))
954
+
955
+ elif state == "idle":
956
+ if now >= next_scan:
957
+ next_scan = now + SCAN_SECONDS
958
+ rows = await scan_books(c, pairs)
959
+ ours = [o for o in rest_open_orders().get("orders", [])
960
+ if str(o.get("clientOrderId", "")).startswith("gap1-")]
961
+ held = []
962
+ for r in rest_balances():
963
+ psym = r.get("symbol", "") + "-" + QUOTE
964
+ qty = Decimal(r.get("trading") or 0)
965
+ if r.get("symbol") == QUOTE or qty <= 0:
966
+ continue
967
+ if psym not in pairs or dust.get(psym) == qty:
968
+ continue
969
+ held.append(r)
970
+ if held and not adopt_q:
971
+ adopt_q.extend((r["symbol"] + "-" + QUOTE,
972
+ Decimal(r["trading"])) for r in held)
973
+ notify("♻️", "ADOPT QUEUED",
974
+ f"{len(held)} position(s) to sell back | "
975
+ + ", ".join(f"{r['symbol']} {num(r['trading'])}"
976
+ for r in held),
977
+ wallet())
978
+ picked, tag = None, ""
979
+ gaps = [r for r in rows
980
+ if r[0] >= GAP_MIN_PCT
981
+ and min(r[4], r[5]) >= DEPTH_MIN_USD]
982
+ if gaps:
983
+ picked, tag = gaps[0], "GAP"
984
+ elif now >= next_fallback:
985
+ fb = [r for r in rows
986
+ if r[0] >= FALLBACK_MIN_GAP_PCT
987
+ and min(r[4], r[5]) >= FALLBACK_DEPTH_MIN_USD]
988
+ if FALLBACK_SYMBOL:
989
+ fb = [r for r in fb if r[1] == FALLBACK_SYMBOL]
990
+ if fb:
991
+ if FALLBACK_SYMBOL:
992
+ picked, tag = fb[0], "VOLUME"
993
+ else:
994
+ picked, tag = await pick_best_liquid(c, fb), "VOLUME"
995
+ next_fallback = now + FALLBACK_COOLDOWN
996
+ if picked:
997
+ gap, sym, bid, ask, bid_usd, ask_usd = picked
998
+ hot, chg = False, Decimal(0)
999
+ if ours or held:
1000
+ try:
1001
+ cr = await c.market_data.get_candles(
1002
+ symbol=sym, timeframe="1h", limit=25)
1003
+ cs = list(cr.candles)
1004
+ if len(cs) >= 2 and Decimal(cs[0].close) > 0:
1005
+ chg = abs((Decimal(cs[-1].close)
1006
+ / Decimal(cs[0].close) - 1) * 100)
1007
+ hot = chg > 20
1008
+ except Exception as e:
1009
+ print("hot check failed:", repr(e), flush=True)
1010
+ if (ours or held) and not hot:
1011
+ if now >= next_block_alert:
1012
+ next_block_alert = now + 600
1013
+ notify("⚠️", "ONE-POSITION",
1014
+ f"{sym} | {len(ours)} open order(s) | "
1015
+ f"{len(held)} held position(s) | 24h {chg:.1f}%",
1016
+ f"not starting a new trade | {wallet()}")
1017
+ picked = None
1018
+ elif hot and (ours or held):
1019
+ try:
1020
+ await c.orders.cancel_all(
1021
+ request_id="gap1-hot-" + uuid.uuid4().hex[:10])
1022
+ except Exception:
1023
+ pass
1024
+ adopt_q.extend((r["symbol"] + "-" + QUOTE,
1025
+ Decimal(r["trading"])) for r in held)
1026
+ notify("🔥", "HOT OVERRIDE",
1027
+ f"{sym} | 24h {chg:.1f}% > 20% | "
1028
+ f"{len(ours)} order(s) | {len(held)} held",
1029
+ "starting anyway | queued for adoption")
1030
+ picked = None
1031
+ if picked:
1032
+ gap, sym, bid, ask, bid_usd, ask_usd = picked
1033
+ m = meta[sym]
1034
+ if tag == "GAP":
1035
+ sat["n"] = 0
1036
+ penny, take = False, False
1037
+ else:
1038
+ penny = FALLBACK_PENNY or sat["n"] >= SAT_PENNY_AFTER
1039
+ take = sat["n"] >= SAT_TAKER_AFTER
1040
+ price = entry_price(tag, bid, ask, m, penny, take)
1041
+ buy_fee = (taker_fee_pct(FEES, sym) if take
1042
+ else maker_fee_pct(FEES, sym))
1043
+ rt = maker_fee_pct(FEES, sym) + buy_fee
1044
+ usdt = next((Decimal(r["available"]) for r in rest_balances()
1045
+ if r.get("symbol") == QUOTE), Decimal(0))
1046
+ q = floor_step(usdt / (price * FEE_BUFFER), m["step"])
1047
+ if q > 0 and q >= m["min_qty"] and q * price >= m["min_notional"]:
1048
+ pv_ok, pv_why = await preflight_buy(c, sym, "buy", q, price)
1049
+ cid = None
1050
+ if not pv_ok:
1051
+ if now >= next_block_alert:
1052
+ next_block_alert = now + 600
1053
+ notify("⚠️", "SKIPPED",
1054
+ f"{sym} | {tag} | pre-flight: {pv_why}",
1055
+ wallet(sym.split("-")[0]))
1056
+ else:
1057
+ cid, err = await place(c, sym, "buy", q, price)
1058
+ if cid:
1059
+ pos = {"symbol": sym, "buy_cid": cid, "buy_px": price,
1060
+ "buy_at": now, "qty": None,
1061
+ "qty_target": q, "tag": tag,
1062
+ "buy_fee_pct": buy_fee,
1063
+ "sell_cid": None, "better": {},
1064
+ "join_ask": tag == "VOLUME"}
1065
+ state = "buying"
1066
+ if tag == "GAP":
1067
+ notify("🟢", "BUY",
1068
+ f"{sym} | gap {gap:.2f}% "
1069
+ f"(net +{gap - rt:.2f}%) | "
1070
+ f"{num(q)} @ {num(price)} | "
1071
+ f"depth {money(bid_usd)}/{money(ask_usd)}",
1072
+ f"expected +{q * price * (gap - rt) / 100:.2f}"
1073
+ f" USDT | {wallet(sym.split('-')[0])}")
1074
+ else:
1075
+ loss = q * price * (rt - gap) / 100
1076
+ stage = ("taker ask" if take else
1077
+ "penny bid" if penny else "bid")
1078
+ notify("🟡", "VOLUME BUY",
1079
+ f"{sym} | gap {gap:.3f}% "
1080
+ f"(fee {rt}%) | "
1081
+ f"{num(q)} @ {num(price)} | "
1082
+ f"volume {money(q * price * 2)} USDT",
1083
+ f"{stage} | expected -{loss:.2f} USDT "
1084
+ f"| {wallet(sym.split('-')[0])}")
1085
+ else:
1086
+ notify("⚠️", "SKIPPED",
1087
+ f"{sym} | {tag} | below pair minimums")
1088
+
1089
+ elif state == "buying":
1090
+ sym = pos["symbol"]
1091
+ m = meta[sym]
1092
+ st, cum, avg = await order_state(c, sym, pos["buy_cid"],
1093
+ m["scale"])
1094
+ if st in TERMINAL:
1095
+ held_qty = floor_step(await base_balance(c, sym, m), m["step"])
1096
+ qty = held_qty or floor_step(cum, m["step"])
1097
+ if st == "filled" or cum > 0:
1098
+ pos["qty"] = qty
1099
+ pos["qty0"] = qty
1100
+ pos["buy_px"] = avg if avg > 0 else pos["buy_px"]
1101
+ pos["since"] = now
1102
+ carry[sym] = (pos["buy_px"], pos["qty0"])
1103
+ sat["n"] = 0
1104
+ notify("✅", "BUY FILLED",
1105
+ f"{sym} | {num(pos['qty'])} @ {num(pos['buy_px'])}",
1106
+ wallet(sym.split("-")[0]))
1107
+ await place_tp_trigger(c, pos, m)
1108
+ state = "selling"
1109
+ else:
1110
+ pos["qty"] = held_qty
1111
+ notify("⚠️", "BUY UNFILLED",
1112
+ f"{sym} | order ended {st}",
1113
+ wallet(sym.split("-")[0]))
1114
+ state = "idle"
1115
+ else:
1116
+ await chase_buy(c, pos, m, now)
1117
+ tmo = buy_timeout_for(pos.get("tag", "GAP"), sat["n"])
1118
+ if now - pos["buy_at"] > tmo:
1119
+ try:
1120
+ await c.orders.cancel(key=ClientOrderId(pos["buy_cid"]),
1121
+ symbol=sym)
1122
+ except Exception:
1123
+ pass
1124
+ st, cum, avg = await order_state(c, sym, pos["buy_cid"],
1125
+ m["scale"])
1126
+ notify("⚠️", "BUY TIMEOUT",
1127
+ f"{sym} | no fill after {tmo:.0f}s "
1128
+ f"| cancelled",
1129
+ f"sat {sat['n']} | {wallet(sym.split('-')[0])}")
1130
+ pos["qty"] = floor_step(await base_balance(c, sym, m), m["step"])
1131
+ if pos["qty"] > 0 or cum > 0:
1132
+ pos["qty"] = pos["qty"] or floor_step(cum, m["step"])
1133
+ pos["qty0"] = pos["qty"]
1134
+ pos["buy_px"] = avg if avg > 0 else pos["buy_px"]
1135
+ pos["since"] = now
1136
+ carry[sym] = (pos["buy_px"], pos["qty0"])
1137
+ sat["n"] = 0
1138
+ notify("✅", "BUY FILLED",
1139
+ f"{sym} | {num(pos['qty'])} @ {num(pos['buy_px'])}",
1140
+ wallet(sym.split("-")[0]))
1141
+ await place_tp_trigger(c, pos, m)
1142
+ state = "selling"
1143
+ else:
1144
+ state = "idle"
1145
+ if pos.get("tag") == "VOLUME":
1146
+ sat["n"] += 1
1147
+
1148
+ elif state == "selling":
1149
+ sym = pos["symbol"]
1150
+ m = meta[sym]
1151
+ held, avail = rest_asset_balance(sym.split("-")[0])
1152
+ price_est = pos.get("last_avg") or pos.get("ask_px") or Decimal(0)
1153
+ if held > 0:
1154
+ pos["seen"] = True
1155
+ if held <= 0 and not pos.get("seen"):
1156
+ if now - pos.get("since", 0) < BALANCE_GRACE:
1157
+ pass
1158
+ else:
1159
+ await cycle_done(sym, price_est)
1160
+ elif held < m["min_qty"] or (price_est > 0 and
1161
+ held * price_est < m["min_notional"]):
1162
+ if held > 0:
1163
+ dust[sym] = held
1164
+ await cycle_done(sym, price_est)
1165
+ elif pos["sell_cid"] is None:
1166
+ pos["qty"] = min(pos.get("qty") or held, held)
1167
+ placed = await place_sell(c, pos, meta)
1168
+ if placed:
1169
+ pos["too_small"] = False
1170
+ elif pos.get("too_small"):
1171
+ dust[sym] = held
1172
+ notify("⚠️", "DUST",
1173
+ f"{sym} | {num(held)} left | too small to sell",
1174
+ wallet(sym.split("-")[0]))
1175
+ pos, state = {}, "idle"
1176
+ else:
1177
+ st, cum, avg = await order_state(c, sym, pos["sell_cid"], m["scale"])
1178
+ if avg and avg > 0:
1179
+ pos["last_avg"] = avg
1180
+ if st in TERMINAL:
1181
+ if st == "rejected" and cum <= 0:
1182
+ pos["no_postonly"] = True
1183
+ pos["sell_cid"] = None
1184
+ pos["canceling"] = False
1185
+ elif pos.get("canceling"):
1186
+ if any(o.get("clientOrderId") == pos["sell_cid"]
1187
+ for o in rest_open_orders().get("orders", [])):
1188
+ try:
1189
+ await c.orders.cancel(key=ClientOrderId(pos["sell_cid"]),
1190
+ symbol=sym)
1191
+ except Exception:
1192
+ pass
1193
+ else:
1194
+ pos["sell_cid"] = None
1195
+ pos["canceling"] = False
1196
+ else:
1197
+ bk = await book(c, sym, depth=10)
1198
+ better = {pdec(a.price) for a in (bk.asks or [])
1199
+ if pdec(a.price) < pos["ask_px"]}
1200
+ track = pos["better"]
1201
+ for p in list(track):
1202
+ if p not in better:
1203
+ del track[p]
1204
+ for p in better:
1205
+ track.setdefault(p, now)
1206
+ aged = sum(1 for p, t0 in track.items()
1207
+ if now - t0 >= PERSIST_SECONDS)
1208
+ if aged >= BETTER_ASKS_LIMIT:
1209
+ asks2 = [pdec(a.price) for a in (bk.asks or [])
1210
+ if pdec(a.price) > 0]
1211
+ new_px = min(asks2) - m["tick"] if asks2 else None
1212
+ amended = False
1213
+ if new_px is not None and new_px != pos["ask_px"]:
1214
+ try:
1215
+ await c.orders.modify(
1216
+ key=ClientOrderId(pos["sell_cid"]),
1217
+ symbol=sym, new_price=new_px)
1218
+ pos["ask_px"] = new_px
1219
+ pos["better"] = {}
1220
+ pos["bail"] = True
1221
+ notify("🔄", "ASK CHASE",
1222
+ f"{sym} | {aged} better ask(s) held "
1223
+ f"{PERSIST_SECONDS:.0f}s | "
1224
+ f"amending to {num(new_px)}",
1225
+ wallet(sym.split("-")[0]))
1226
+ amended = True
1227
+ except Exception as e:
1228
+ print("amend fell back to cancel:", repr(e),
1229
+ flush=True)
1230
+ if not amended and new_px != pos["ask_px"]:
1231
+ try:
1232
+ await c.orders.cancel(key=ClientOrderId(pos["sell_cid"]),
1233
+ symbol=sym)
1234
+ except Exception:
1235
+ pass
1236
+ notify("⚠️", "ASK BAIL-OUT",
1237
+ f"{sym} | {aged} better asks held "
1238
+ f"{PERSIST_SECONDS:.0f}s | re-asking under them",
1239
+ wallet(sym.split("-")[0]))
1240
+ pos["canceling"] = True
1241
+ pos["bail"] = True
1242
+ if now >= next_fees:
1243
+ next_fees = now + FEES_REFRESH_SECONDS
1244
+ await refresh_fees(c)
1245
+ if not fees_seen and FEES:
1246
+ fees_seen = True
1247
+ s0 = next(iter(FEES))
1248
+ notify("💱", "FEES LIVE",
1249
+ f"VIP fee {FEES[s0][0]}% maker / "
1250
+ f"{FEES[s0][1]}% taker",
1251
+ f"gap floor {GAP_MIN_PCT}% | {len(FEES)} pairs "
1252
+ f"| {wallet()}")
1253
+ if DEADMAN_TIMEOUT > 0 and now - last_deadman >= DEADMAN_REFRESH:
1254
+ last_deadman = now
1255
+ await deadman_rearm(c)
1256
+ today = time.strftime("%Y-%m-%d", time.gmtime())
1257
+ if today != last_equity_day:
1258
+ last_equity_day = today
1259
+ await equity_report(c)
1260
+ except Exception as e:
1261
+ print("loop error:", repr(e), flush=True)
1262
+ await asyncio.sleep(POLL_SECONDS)
1263
+ finally:
1264
+ ws_stop.set()
1265
+ try:
1266
+ await asyncio.wait_for(asyncio.gather(ws_task), 3)
1267
+ except Exception:
1268
+ ws_task.cancel()
1269
+ await c.aclose()
1270
+
1271
+
1272
+ def cli():
1273
+ if sys.argv[1:2] == ["setup"]:
1274
+ if __package__:
1275
+ from .setup import cli as setup_cli
1276
+ else:
1277
+ from setup import cli as setup_cli
1278
+ setup_cli()
1279
+ return
1280
+ missing = [k for k in ("POLYESTER_API_KEY_ID", "POLYESTER_ED25519_SEED")
1281
+ if not os.environ.get(k, "").strip()]
1282
+ if missing:
1283
+ print("missing credentials: " + ", ".join(missing)
1284
+ + " (put them in .env next to the bot or in the environment)")
1285
+ sys.exit(1)
1286
+ try:
1287
+ asyncio.run(main())
1288
+ except KeyboardInterrupt:
1289
+ pass
1290
+
1291
+
1292
+ if __name__ == "__main__":
1293
+ cli()