polyester-bot 1.1.0 → 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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- <img src="polyester-bot.png" alt="Polyester" width="120" height="120" style="border-radius:50%; object-fit:cover;" />
3
+ <img src="https://raw.githubusercontent.com/natanim-kemal/polyester-bot/main/polyester-bot.png" alt="Polyester" width="120" height="120" style="border-radius:50%; object-fit:cover;" />
4
4
 
5
5
  # Polyester Gap Hunter
6
6
 
@@ -20,15 +20,35 @@ scan pairs → find gap → BUY at the bid → ASK under the offer → CYCLE DON
20
20
  ```
21
21
 
22
22
  - **Gap mode** — buys the bid, asks one tick under the offer (maker on both legs).
23
- A gap must clear the `0.20%` round-trip fee, so `GAP_MIN_PCT` defaults to `0.21%`.
23
+ Fees are read from the exchange (VIP tier) every 30 min; the gap floor follows
24
+ them (`2 x maker + GAP_MARGIN_PCT`, `0.11%` at VIP3). An explicit `GAP_MIN_PCT`
25
+ pins it instead.
24
26
  - **Volume fallback** — when nothing clears the fee bar, the deepest tightest pair is
25
- cycled anyway to keep season volume up.
26
- - **Bail-out rule** — if more than `BETTER_ASKS_LIMIT` better asks sit under our
27
- price for `PERSIST_SECONDS`, the ask is cancelled and re-placed under them
28
- (maker exit, even at a loss). It then rests until the rule fires again.
27
+ cycled anyway to keep season volume up. The candidate with real trade flow wins
28
+ over the widest dead spread. A saturation ladder cuts the waiting:
29
+ after `SAT_PENNY_AFTER` consecutive unfilled fallback buys it pennies the bid,
30
+ after `SAT_TAKER_AFTER` it buys at the ask (taker fee), and the buy timeout
31
+ shrinks to `FALLBACK_BUY_TIMEOUT`.
32
+ - **Chase rule (both sides)** — if `BETTER_ASKS_LIMIT` better asks (sell) or
33
+ `BETTER_BIDS_LIMIT` better bids (buy) beat our price for `PERSIST_SECONDS`,
34
+ the resting order is **amended** one tick more aggressive than them — one
35
+ atomic call, no unfilled window. A cancel happens only when the amend cannot.
36
+ - **Dead-man switch** — `orders.cancel_all_after` is re-armed every 60 s; if the
37
+ bot dies or freezes without re-arming, the exchange cancels all our orders itself.
38
+ - **Pre-flight check** — before a buy is placed, `orders.preview_order` asks the
39
+ exchange whether it would be admissible; only a clear rejection blocks
40
+ (throttled, never an outage blocking trades).
41
+ - **Websocket books** — every pair's orderbook is streamed live (`orderbook.subscribe`);
42
+ readers prefer the fresh cache and fall back to REST when it goes quiet.
29
43
  - **One position** — never stacks trades; the exception is a `>20%` 24h mover.
30
44
  - **Volume tracker** — every cycle adds to `volume_state.json`, milestone alert
31
45
  every 10,000 USDT.
46
+ - **Daily equity line** — once a day one `EQUITY` alert: total in USDT (balances ×
47
+ fresh mids), per-position breakdown, 24 h delta vs yesterday's snapshot
48
+ (`equity_state.json`).
49
+ - **TP triggers** — `TP_TRIGGER_PCT` arms a full-size server-side take-profit on a held
50
+ position; the trigger reserves the whole inventory while armed, so the bot cannot
51
+ sell alongside it. Off by default.
32
52
 
33
53
  ## Install
34
54
 
@@ -97,7 +117,7 @@ polyester-bot
97
117
 
98
118
  ```
99
119
  ├── bot/
100
- │ ├── monitor.py # the bot: scanner, executor, bail-out rule
120
+ │ ├── monitor.py # the bot: scanner, executor, chase rule
101
121
  │ ├── setup.py # guided setup: ask, check, write the .env
102
122
  │ ├── polyester_farm.py # season volume, daily paper-asset claim
103
123
  │ ├── paths.py # where .env and the state files live
@@ -115,12 +135,21 @@ polyester-bot
115
135
  │ ├── polyester-claim.service # daily paper-asset claim
116
136
  │ ├── polyester-claim.timer # 00:10 UTC, persistent
117
137
  │ └── deploy.sh # install code + units, restart
118
- ├── tests/
119
- │ ├── test_sell_loop.py # regression test for the cancel storm
138
+ ├── tests/ # 14 suites, all run in CI before any deploy:
139
+ │ ├── test_sell_loop.py # the re-ask/cancel machine, end to end
120
140
  │ ├── test_freeze_detect.py # detects a paused sandbox from clock skew
121
141
  │ ├── test_paths.py # .env and state file resolution
122
142
  │ ├── test_stuck.py # a filled buy survives a lagging balance
123
- │ └── test_setup.py # the setup wizard and account derivation
143
+ │ ├── test_setup.py # the setup wizard and account derivation
144
+ │ ├── test_fees.py # fee math, gap floor and entry ladder
145
+ │ ├── test_chase.py # both sides chase; sell trigger on one better ask
146
+ │ ├── test_amend.py # chase amends in place, cancel only as fallback
147
+ │ ├── test_tp_trigger.py # server-side take-profit triggers (opt-in)
148
+ │ ├── test_ws.py # websocket orderbook feed with REST fallback
149
+ │ ├── test_liquidity.py # fallback prefers a live tape over a wide dead book
150
+ │ ├── test_equity.py # daily equity report from balances x mids
151
+ │ ├── test_preflight.py # admissibility check before a buy is placed
152
+ │ └── test_orphan.py # orphan-clear runs hourly, not every poll
124
153
  ├── .github/workflows/
125
154
  │ ├── deploy.yml # push to main → deploy to the sandbox
126
155
  │ └── keepalive.yml # every 10 min: keep the sandbox awake
@@ -136,13 +165,21 @@ The real file stays on the machine and is gitignored — a deploy never overwrit
136
165
 
137
166
  | Key | Default | Meaning |
138
167
  | --- | --- | --- |
139
- | `GAP_MIN_PCT` | `0.21` | minimum spread to take in gap mode |
168
+ | `GAP_MIN_PCT` | fee-driven (`0.11`) | minimum spread to take in gap mode |
169
+ | `GAP_MARGIN_PCT` | `0.01` | profit margin added on top of the round-trip fee |
170
+ | `FEES_REFRESH_SECONDS` | `1800` | how often per-pair fees are re-read from the exchange |
140
171
  | `DEPTH_MIN_USD` | `50` | minimum depth on both sides |
141
172
  | `SCAN_SECONDS` | `10` | how often to scan for a gap |
142
173
  | `POLL_SECONDS` | `2` | how often to manage the open position |
143
- | `PERSIST_SECONDS` | `60` | how long better asks must persist before the bail-out fires |
144
- | `BETTER_ASKS_LIMIT` | `2` | how many better asks trigger the bail-out |
174
+ | `PERSIST_SECONDS` | `60` | how long better prices must persist before a chase fires |
175
+ | `BETTER_ASKS_LIMIT` | `1` | better asks under our ask that trigger the sell chase |
176
+ | `BETTER_BIDS_LIMIT` | `1` | better bids over our bid that trigger the buy chase |
145
177
  | `BUY_TIMEOUT_SECONDS` | `180` | cancel an unfilled buy after this long |
178
+ | `SAT_PENNY_AFTER` | `2` | consecutive fallback buy timeouts before penny bids |
179
+ | `SAT_TAKER_AFTER` | `4` | consecutive fallback buy timeouts before buying at the ask |
180
+ | `FALLBACK_BUY_TIMEOUT` | `60` | fallback buy timeout once the ladder is engaged |
181
+ | `DEADMAN_TIMEOUT` | `300` | auto-cancel window re-armed every 60s; `0` disables |
182
+ | `TP_TRIGGER_PCT` | `0` | arm server-side take-profit on sell entry; `0` = never arm |
146
183
  | `FREEZE_SECONDS` | `300` | wall-vs-monotonic clock skew that counts as a paused sandbox |
147
184
  | `STALL_SECONDS` | `900` | how long the loop may hang before it counts as a pause |
148
185
  | `FALLBACK_*` | see example | volume fallback mode |
@@ -208,44 +245,35 @@ journalctl -u polyester-claim.service
208
245
 
209
246
  ## Tests
210
247
 
211
- `tests/test_sell_loop.py` replays the sell state machine against a fake exchange.
212
- It reproduces the bug where the bot cancelled the ask it had just placed — the
213
- `canceling` flag was never cleared once the order reached a terminal state, so
214
- every re-ask was cancelled again on the next poll.
215
-
216
- ```bash
217
- python tests/test_sell_loop.py
218
- ```
219
-
220
- Exits non-zero if the bot cancels more than once per bail-out.
221
-
222
- `tests/test_freeze_detect.py` pulls the real freeze-detection code out of
223
- `bot/monitor.py` and drives it: a normal poll does nothing, the real 1h47m
224
- pause triggers a re-sync, a stalled loop triggers it too, and an unreachable
225
- exchange keeps the position instead of forgetting it.
226
-
227
- ```bash
228
- python tests/test_freeze_detect.py
229
- ```
230
-
231
- `tests/test_paths.py` checks where the bot looks for its `.env` and its state
232
- files: the working directory wins, the script directory is the fallback, and an
233
- existing state file is never moved.
248
+ All 14 suites run in CI on every push, before anything is deployed, together with a
249
+ build of the Python package and a smoke test of its entry point.
250
+ `python tests/test_<name>.py` runs any one locally.
234
251
 
235
252
  ```bash
236
- python tests/test_paths.py
253
+ for t in tests/test_*.py; do python "$t" || exit 1; done
237
254
  ```
238
255
 
239
- `tests/test_stuck.py` replays the bug where a filled buy was invisible because
240
- the balance lagged behind the order, which froze the bot for hours. It fails on
241
- the old code and passes on the new.
242
-
243
- `tests/test_setup.py` checks the setup wizard: seed formats, the `.env` merge,
244
- the home copy, and that the bot asks the exchange for its account id instead of
245
- having one baked in.
246
-
247
- All five run in CI on every push, before anything is deployed, together with a
248
- build of the Python package and a smoke test of its entry point.
256
+ Exits non-zero on the first failing suite. Coverage map:
257
+
258
+ `test_sell_loop.py` replays the sell state machine against a fake exchange — the
259
+ bug where the bot cancelled the ask it had just placed.
260
+ `test_freeze_detect.py` drives the freeze guard — the real 1h47m pause triggers a
261
+ re-sync, an unreachable exchange keeps the position.
262
+ `test_stuck.py` replays the bug where a filled buy was invisible because the
263
+ balance lagged behind the order.
264
+ `test_setup.py` checks the setup wizard and that the account id is derived, never
265
+ baked in.
266
+ `test_paths.py` checks `.env` and state file resolution.
267
+ `test_fees.py` pins the fee math: the gap floor, the entry ladder, cycle fees.
268
+ `test_chase.py` checks both sides chase one tick past the competitor, and the
269
+ flicking-competitor guard.
270
+ `test_amend.py` checks orders amend atomically and cancel only when unavoidable.
271
+ `test_tp_trigger.py` arms a server-side take-profit when enabled (off by default).
272
+ `test_ws.py` checks the websocket book feed populates the cache and REST falls back.
273
+ `test_liquidity.py` checks the fallback prefers a live tape.
274
+ `test_equity.py` checks the daily equity report and its snapshot file.
275
+ `test_preflight.py` checks the admissibility check blocks a clear rejection only.
276
+ `test_orphan.py` checks the orphan-clear runs hourly, not every poll.
249
277
 
250
278
  ## Ops
251
279
 
package/bot/monitor.py CHANGED
@@ -63,19 +63,33 @@ _SK = nacl.signing.SigningKey(bytes.fromhex(os.environ.get("POLYESTER_ED25519_SE
63
63
  ACCOUNT = os.environ.get("POLYESTER_ACCOUNT_ID", "")
64
64
 
65
65
  QUOTE = "USDT"
66
- RT_FEE_PCT = Decimal("0.20")
67
- GAP_MIN_PCT = Decimal(os.environ.get("GAP_MIN_PCT", "0.21"))
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"))
68
75
  DEPTH_MIN_USD = Decimal(os.environ.get("DEPTH_MIN_USD", "50"))
69
76
  FALLBACK_SYMBOL = os.environ.get("FALLBACK_SYMBOL", "").strip()
70
77
  FALLBACK_MIN_GAP_PCT = Decimal(os.environ.get("FALLBACK_MIN_GAP_PCT", "0.05"))
71
78
  FALLBACK_DEPTH_MIN_USD = Decimal(os.environ.get("FALLBACK_DEPTH_MIN_USD", "1000"))
72
79
  FALLBACK_COOLDOWN = float(os.environ.get("FALLBACK_COOLDOWN", "60"))
73
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"))
74
85
  SCAN_SECONDS = float(os.environ.get("SCAN_SECONDS", "10"))
75
86
  POLL_SECONDS = float(os.environ.get("POLL_SECONDS", "2"))
76
87
  PERSIST_SECONDS = float(os.environ.get("PERSIST_SECONDS", "60"))
77
- BETTER_ASKS_LIMIT = int(os.environ.get("BETTER_ASKS_LIMIT", "2"))
88
+ BETTER_ASKS_LIMIT = int(os.environ.get("BETTER_ASKS_LIMIT", "1"))
89
+ BETTER_BIDS_LIMIT = int(os.environ.get("BETTER_BIDS_LIMIT", "1"))
78
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
79
93
  FEE_BUFFER = Decimal("1.0015")
80
94
  FREEZE_SECONDS = float(os.environ.get("FREEZE_SECONDS", "300"))
81
95
  STALL_SECONDS = float(os.environ.get("STALL_SECONDS", "900"))
@@ -83,6 +97,80 @@ BALANCE_GRACE = float(os.environ.get("BALANCE_GRACE_SECONDS", "120"))
83
97
  HEARTBEAT = "/var/tmp/polyester.heartbeat"
84
98
 
85
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
+
86
174
  def frozen_seconds(wall, mono, last_wall, last_mono):
87
175
  """Seconds the VM spent frozen: wall clock advanced but the guest did not run.
88
176
 
@@ -242,20 +330,280 @@ async def order_state(c, sym, cid, scale):
242
330
  return (str(o.status).lower(), qdec(o.cum_qty, scale), pdec(o.avg_px))
243
331
 
244
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
+
245
593
  async def scan_books(c, pairs):
246
594
  """Every USDT pair with a two-sided book: (gap_pct, sym, bid, ask, bid$, ask$)."""
247
595
  rows = []
248
596
  for sym in pairs:
249
597
  try:
250
- book = await c.orderbook.get(symbol=sym, depth=1)
251
- if not book.bids or not book.asks:
598
+ bk = await book(c, sym, depth=1)
599
+ if not bk.bids or not bk.asks:
252
600
  continue
253
- bid, ask = pdec(book.bids[0].price), pdec(book.asks[0].price)
601
+ bid, ask = pdec(bk.bids[0].price), pdec(bk.asks[0].price)
254
602
  if bid <= 0 or ask <= bid:
255
603
  continue
256
604
  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
605
+ bid_usd = pdec(bk.bids[0].qty) * bid
606
+ ask_usd = pdec(bk.asks[0].qty) * ask
259
607
  rows.append((gap, sym, bid, ask, bid_usd, ask_usd))
260
608
  except Exception as e:
261
609
  print("scan skip", sym, repr(e), flush=True)
@@ -267,11 +615,11 @@ async def place_sell(c, pos, meta):
267
615
  sym = pos["symbol"]
268
616
  base = sym.split("-")[0]
269
617
  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:
618
+ bk = await book(c, sym, depth=10)
619
+ if not bk.asks:
272
620
  return False
273
- best_ask = pdec(book.asks[0].price)
274
- best_bid = pdec(book.bids[0].price) if book.bids else Decimal(0)
621
+ best_ask = pdec(bk.asks[0].price)
622
+ best_bid = pdec(bk.bids[0].price) if bk.bids else Decimal(0)
275
623
  spread = ((best_ask - best_bid) / ((best_ask + best_bid) / 2) * 100
276
624
  if best_bid else Decimal(0))
277
625
  basis = pos.get("buy_px") or Decimal(0)
@@ -295,6 +643,10 @@ async def place_sell(c, pos, meta):
295
643
  held = None
296
644
  if q <= 0 or q * best_ask < min_not:
297
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()
298
650
  try:
299
651
  await c.orders.cancel_all(symbol=sym,
300
652
  request_id="gap1-ca-" + uuid.uuid4().hex[:10])
@@ -409,6 +761,9 @@ async def main():
409
761
  "min_qty": Decimal(p.get("minQtyBase") or "0"),
410
762
  "scale": int(p.get("baseQuantityScale") or 8)}
411
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))
412
767
 
413
768
  async def resync(tag):
414
769
  """Cancel our stale orders, re-read balances.
@@ -457,6 +812,7 @@ async def main():
457
812
  return held, True
458
813
 
459
814
  adopt, boot_ok = await resync("gap1-boot")
815
+ await refresh_fees(c)
460
816
 
461
817
  notify("🤖", "GAP HUNTER ONLINE",
462
818
  f"gap ≥ {GAP_MIN_PCT}% | depth ≥ {money(DEPTH_MIN_USD)} USDT | "
@@ -472,6 +828,11 @@ async def main():
472
828
  next_scan = 0.0
473
829
  next_fallback = 0.0
474
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 = ""
475
836
 
476
837
  vol, next_10k = None, None
477
838
  try:
@@ -517,9 +878,11 @@ async def main():
517
878
  except OSError:
518
879
  pass
519
880
 
520
- def cycle_done(sym, sell_px):
881
+ async def cycle_done(sym, sell_px):
521
882
  """Close a cycle from balance truth; alert with balances + volume."""
522
883
  nonlocal vol, next_10k, pos, state
884
+ await cancel_tp_trigger(c, pos)
885
+ await cancel_stale_symbol_orders(c, sym)
523
886
  base_sym = sym.split("-")[0]
524
887
  base_held, _ = rest_asset_balance(base_sym)
525
888
  qty0 = pos.get("qty0") or pos.get("qty") or Decimal(0)
@@ -528,7 +891,10 @@ async def main():
528
891
  basis = pos.get("buy_px") or Decimal(0)
529
892
  sell_usd = sell_px * sold
530
893
  buy_usd = basis * sold
531
- fees = (sell_usd + buy_usd) * Decimal("0.001")
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
532
898
  net = sell_usd - buy_usd - fees
533
899
  if sell_px > 0:
534
900
  net_txt = f"net {net:+.2f} USDT"
@@ -579,6 +945,8 @@ async def main():
579
945
  "sell_cid": None, "better": {}, "since": now}
580
946
  state = "selling"
581
947
  carry[sym] = (pos["buy_px"], pos["qty0"])
948
+ if sym in meta:
949
+ await place_tp_trigger(c, pos, meta[sym])
582
950
  notify("♻️", "ADOPT",
583
951
  f"{sym} {num(qty)} | basis "
584
952
  f"{num(pos['buy_px']) if pos['buy_px'] else 'unknown'}",
@@ -620,7 +988,10 @@ async def main():
620
988
  if FALLBACK_SYMBOL:
621
989
  fb = [r for r in fb if r[1] == FALLBACK_SYMBOL]
622
990
  if fb:
623
- picked, tag = fb[0], "VOLUME"
991
+ if FALLBACK_SYMBOL:
992
+ picked, tag = fb[0], "VOLUME"
993
+ else:
994
+ picked, tag = await pick_best_liquid(c, fb), "VOLUME"
624
995
  next_fallback = now + FALLBACK_COOLDOWN
625
996
  if picked:
626
997
  gap, sym, bid, ask, bid_usd, ask_usd = picked
@@ -660,35 +1031,56 @@ async def main():
660
1031
  if picked:
661
1032
  gap, sym, bid, ask, bid_usd, ask_usd = picked
662
1033
  m = meta[sym]
663
- price = bid if (tag == "VOLUME" and not FALLBACK_PENNY) \
664
- else min(bid + m["tick"], ask - m["tick"])
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
665
1044
  usdt = next((Decimal(r["available"]) for r in rest_balances()
666
1045
  if r.get("symbol") == QUOTE), Decimal(0))
667
1046
  q = floor_step(usdt / (price * FEE_BUFFER), m["step"])
668
1047
  if q > 0 and q >= m["min_qty"] and q * price >= m["min_notional"]:
669
- cid, err = await place(c, sym, "buy", q, price)
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)
670
1058
  if cid:
671
1059
  pos = {"symbol": sym, "buy_cid": cid, "buy_px": price,
672
1060
  "buy_at": now, "qty": None,
1061
+ "qty_target": q, "tag": tag,
1062
+ "buy_fee_pct": buy_fee,
673
1063
  "sell_cid": None, "better": {},
674
1064
  "join_ask": tag == "VOLUME"}
675
1065
  state = "buying"
676
1066
  if tag == "GAP":
677
1067
  notify("🟢", "BUY",
678
1068
  f"{sym} | gap {gap:.2f}% "
679
- f"(net +{gap - RT_FEE_PCT:.2f}%) | "
1069
+ f"(net +{gap - rt:.2f}%) | "
680
1070
  f"{num(q)} @ {num(price)} | "
681
1071
  f"depth {money(bid_usd)}/{money(ask_usd)}",
682
- f"expected +{q * price * (gap - RT_FEE_PCT) / 100:.2f}"
1072
+ f"expected +{q * price * (gap - rt) / 100:.2f}"
683
1073
  f" USDT | {wallet(sym.split('-')[0])}")
684
1074
  else:
685
- loss = q * price * (RT_FEE_PCT - gap) / 100
1075
+ loss = q * price * (rt - gap) / 100
1076
+ stage = ("taker ask" if take else
1077
+ "penny bid" if penny else "bid")
686
1078
  notify("🟡", "VOLUME BUY",
687
1079
  f"{sym} | gap {gap:.3f}% "
688
- f"(fee {RT_FEE_PCT}%) | "
1080
+ f"(fee {rt}%) | "
689
1081
  f"{num(q)} @ {num(price)} | "
690
1082
  f"volume {money(q * price * 2)} USDT",
691
- f"expected -{loss:.2f} USDT "
1083
+ f"{stage} | expected -{loss:.2f} USDT "
692
1084
  f"| {wallet(sym.split('-')[0])}")
693
1085
  else:
694
1086
  notify("⚠️", "SKIPPED",
@@ -708,9 +1100,11 @@ async def main():
708
1100
  pos["buy_px"] = avg if avg > 0 else pos["buy_px"]
709
1101
  pos["since"] = now
710
1102
  carry[sym] = (pos["buy_px"], pos["qty0"])
1103
+ sat["n"] = 0
711
1104
  notify("✅", "BUY FILLED",
712
1105
  f"{sym} | {num(pos['qty'])} @ {num(pos['buy_px'])}",
713
1106
  wallet(sym.split("-")[0]))
1107
+ await place_tp_trigger(c, pos, m)
714
1108
  state = "selling"
715
1109
  else:
716
1110
  pos["qty"] = held_qty
@@ -718,31 +1112,38 @@ async def main():
718
1112
  f"{sym} | order ended {st}",
719
1113
  wallet(sym.split("-")[0]))
720
1114
  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"
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
746
1147
 
747
1148
  elif state == "selling":
748
1149
  sym = pos["symbol"]
@@ -755,12 +1156,12 @@ async def main():
755
1156
  if now - pos.get("since", 0) < BALANCE_GRACE:
756
1157
  pass
757
1158
  else:
758
- cycle_done(sym, price_est)
1159
+ await cycle_done(sym, price_est)
759
1160
  elif held < m["min_qty"] or (price_est > 0 and
760
1161
  held * price_est < m["min_notional"]):
761
1162
  if held > 0:
762
1163
  dust[sym] = held
763
- cycle_done(sym, price_est)
1164
+ await cycle_done(sym, price_est)
764
1165
  elif pos["sell_cid"] is None:
765
1166
  pos["qty"] = min(pos.get("qty") or held, held)
766
1167
  placed = await place_sell(c, pos, meta)
@@ -793,8 +1194,8 @@ async def main():
793
1194
  pos["sell_cid"] = None
794
1195
  pos["canceling"] = False
795
1196
  else:
796
- book = await c.orderbook.get(symbol=sym, depth=10)
797
- better = {pdec(a.price) for a in (book.asks or [])
1197
+ bk = await book(c, sym, depth=10)
1198
+ better = {pdec(a.price) for a in (bk.asks or [])
798
1199
  if pdec(a.price) < pos["ask_px"]}
799
1200
  track = pos["better"]
800
1201
  for p in list(track):
@@ -804,22 +1205,67 @@ async def main():
804
1205
  track.setdefault(p, now)
805
1206
  aged = sum(1 for p, t0 in track.items()
806
1207
  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
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)
819
1260
  except Exception as e:
820
1261
  print("loop error:", repr(e), flush=True)
821
1262
  await asyncio.sleep(POLL_SECONDS)
822
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()
823
1269
  await c.aclose()
824
1270
 
825
1271
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polyester-bot",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Polyester testnet gap hunter: buys orderbook gaps, asks them back, alerts on Telegram",
5
5
  "keywords": [
6
6
  "polyester",
@@ -34,8 +34,7 @@
34
34
  "pyproject.toml",
35
35
  "bot/*.py",
36
36
  "LICENSE",
37
- "README.md",
38
- "polyester-bot.png"
37
+ "README.md"
39
38
  ],
40
39
  "engines": {
41
40
  "node": ">=14"
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "polyester-bot"
7
- version = "1.1.0"
7
+ version = "1.2.0"
8
8
  description = "Polyester testnet gap hunter: buys orderbook gaps, asks them back, alerts on Telegram"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
package/polyester-bot.png DELETED
Binary file