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/LICENSE +21 -0
- package/README.md +273 -2
- package/bin/polyester-bot.js +7 -0
- package/bin/polyester-farm.js +4 -0
- package/bin/setup.js +75 -0
- package/bin/venv.js +12 -0
- package/bot/__init__.py +1 -0
- package/bot/big_buy.py +75 -0
- package/bot/cancel_all.py +72 -0
- package/bot/check_orders.py +47 -0
- package/bot/hunter_forever.py +18 -0
- package/bot/kill_bot.py +11 -0
- package/bot/monitor.py +847 -0
- package/bot/paths.py +43 -0
- package/bot/polyester_farm.py +434 -0
- package/bot/scan_spread.py +76 -0
- package/bot/setup.py +300 -0
- package/bot/status_all.py +22 -0
- package/install.js +5 -0
- package/package.json +41 -4
- package/polyester-bot.png +0 -0
- package/pyproject.toml +40 -0
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()
|