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 +75 -47
- package/bot/monitor.py +510 -64
- package/package.json +2 -3
- package/pyproject.toml +1 -1
- package/polyester-bot.png +0 -0
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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,
|
|
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 #
|
|
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
|
-
│
|
|
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.
|
|
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
|
|
144
|
-
| `BETTER_ASKS_LIMIT` | `
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
`
|
|
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
|
-
|
|
253
|
+
for t in tests/test_*.py; do python "$t" || exit 1; done
|
|
237
254
|
```
|
|
238
255
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
the
|
|
242
|
-
|
|
243
|
-
`
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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", "
|
|
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
|
-
|
|
251
|
-
if not
|
|
598
|
+
bk = await book(c, sym, depth=1)
|
|
599
|
+
if not bk.bids or not bk.asks:
|
|
252
600
|
continue
|
|
253
|
-
bid, ask = pdec(
|
|
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(
|
|
258
|
-
ask_usd = pdec(
|
|
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
|
-
|
|
271
|
-
if not
|
|
618
|
+
bk = await book(c, sym, depth=10)
|
|
619
|
+
if not bk.asks:
|
|
272
620
|
return False
|
|
273
|
-
best_ask = pdec(
|
|
274
|
-
best_bid = pdec(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
664
|
-
|
|
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
|
-
|
|
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 -
|
|
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 -
|
|
1072
|
+
f"expected +{q * price * (gap - rt) / 100:.2f}"
|
|
683
1073
|
f" USDT | {wallet(sym.split('-')[0])}")
|
|
684
1074
|
else:
|
|
685
|
-
loss = q * price * (
|
|
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 {
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
pos["
|
|
737
|
-
pos["
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
797
|
-
better = {pdec(a.price) for a in (
|
|
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
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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.
|
|
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.
|
|
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
|