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/LICENSE +21 -0
- package/README.md +301 -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 +1293 -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 +40 -4
- package/pyproject.toml +40 -0
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()
|