polyester-bot 1.1.0 → 1.2.1

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,52 +1,27 @@
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="90" height="90" style="border-radius:50%;" />
4
4
 
5
5
  # Polyester Gap Hunter
6
6
 
7
- **Testnet market-making bot · one position at a time · Telegram alerts · GitHub Actions deploy**
8
-
9
- ![Python](https://img.shields.io/badge/Python-3.13-blue?logo=python)
10
- ![asyncio](https://img.shields.io/badge/asyncio-trading-2b5f8a?logo=asyncio)
11
- ![systemd](https://img.shields.io/badge/systemd-service-81dafb?logo=systemd)
12
- ![GitHub Actions](https://img.shields.io/badge/deploy-GitHub_Actions-2088ff?logo=github-actions)
7
+ **Testnet market-making bot. One position at a time. Telegram alerts.**
13
8
 
14
9
  </div>
15
10
 
16
- ## How it works
17
-
18
- ```
19
- scan pairs → find gap → BUY at the bid → ASK under the offer → CYCLE DONE
20
- ```
21
-
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%`.
24
- - **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.
29
- - **One position** — never stacks trades; the exception is a `>20%` 24h mover.
30
- - **Volume tracker** — every cycle adds to `volume_state.json`, milestone alert
31
- every 10,000 USDT.
11
+ Scans every USDT pair for spreads ≥ `GAP_MIN_PCT`, buys the bid, asks one tick under the
12
+ best offer — or falls back to market-making churn when spreads are below the fee bar, to
13
+ keep season volume up. Fees are live — read from the exchange (your VIP tier) every 30
14
+ minutes — so the gap floor and every cost line use the real rates, not a baked-in 0.20%.
32
15
 
33
16
  ## Install
34
17
 
35
- **npm** — the wrapper builds its own Python environment on first run:
36
-
37
- ```bash
38
- npm install -g polyester-bot
39
- polyester-bot setup
40
- ```
41
-
42
- **pip** — straight from the release tag:
43
-
44
18
  ```bash
45
- pip install "polyester-bot @ git+https://github.com/natanim-kemal/polyester-bot.git@v1.1.0"
46
- polyester-bot setup
19
+ npm install -g polyester-bot # builds its own Python env on first run
20
+ polyester-bot setup # wizard: key, Telegram, writes ~/.polyester-bot/.env
21
+ polyester-bot # trades testnet
47
22
  ```
48
23
 
49
- **source**:
24
+ Or from source:
50
25
 
51
26
  ```bash
52
27
  git clone https://github.com/natanim-kemal/polyester-bot.git
@@ -55,220 +30,65 @@ pip install -e .
55
30
  polyester-bot setup
56
31
  ```
57
32
 
58
- Python 3.11 or newer.
59
-
60
- ## Setup
61
-
62
- One command walks you through the credentials and leaves a working `.env` behind:
63
-
64
- ```bash
65
- polyester-bot setup
66
- ```
67
-
68
- It asks for:
69
-
70
- | Question | Where to get it |
71
- | --- | --- |
72
- | Polyester API key id (`ak_...`) | Polyester dashboard → API keys |
73
- | Ed25519 seed (64 hex chars or base64) | the same API key, shown once when you create it |
74
- | Telegram bot token | [@BotFather](https://t.me/BotFather) → `/newbot` |
75
- | Telegram chat id | send `/start` to your bot; the wizard finds it, or paste the id |
76
- | EOA private key | optional, only for the daily paper-asset claim |
77
-
78
- The account id is **not** asked for: the wizard signs a `GET /v1/auth/me` with
79
- your key and derives it. The exchange and Telegram are both checked before
80
- anything is written, and the wizard sends one test message so you know the
81
- alerts will arrive.
82
-
83
- Credentials land in `~/.polyester-bot/.env` (and in a `.env` next to the bot if
84
- one is already there), never in the git repository. The bot reads the working
85
- directory first, then the home copy, so it works from anywhere.
86
-
87
- Run the bot the same way:
33
+ or straight from the release tag:
88
34
 
89
35
  ```bash
90
- polyester-bot
36
+ pip install "polyester-bot @ git+https://github.com/natanim-kemal/polyester-bot.git@v1.2.0"
91
37
  ```
92
38
 
93
- `POLYESTER_STATE_DIR` moves the working directory somewhere else. The bot trades
94
- **testnet** funds only.
39
+ The wizard asks for your API key id + Ed25519 seed, derives the account id itself, and
40
+ verifies the exchange and Telegram before writing anything.
95
41
 
96
- ## Project Structure
42
+ ## How it works
97
43
 
98
- ```
99
- ├── bot/
100
- │ ├── monitor.py # the bot: scanner, executor, bail-out rule
101
- │ ├── setup.py # guided setup: ask, check, write the .env
102
- │ ├── polyester_farm.py # season volume, daily paper-asset claim
103
- │ ├── paths.py # where .env and the state files live
104
- │ ├── check_orders.py # print open orders
105
- │ ├── cancel_all.py # cancel every open order
106
- │ ├── status_all.py # open orders + hot pairs
107
- │ ├── scan_spread.py # spread of every pair
108
- │ ├── big_buy.py # one-off market buy
109
- │ ├── hunter_forever.py # run monitor.py in a loop
110
- │ ├── kill_bot.py # stop the bot
111
- │ └── .env.example # every setting, no secrets
112
- ├── bin/ # npm wrapper: polyester-bot, polyester-farm
113
- ├── deploy/
114
- │ ├── polyester-bot.service # systemd unit for the bot
115
- │ ├── polyester-claim.service # daily paper-asset claim
116
- │ ├── polyester-claim.timer # 00:10 UTC, persistent
117
- │ └── deploy.sh # install code + units, restart
118
- ├── tests/
119
- │ ├── test_sell_loop.py # regression test for the cancel storm
120
- │ ├── test_freeze_detect.py # detects a paused sandbox from clock skew
121
- │ ├── test_paths.py # .env and state file resolution
122
- │ ├── test_stuck.py # a filled buy survives a lagging balance
123
- │ └── test_setup.py # the setup wizard and account derivation
124
- ├── .github/workflows/
125
- │ ├── deploy.yml # push to main → deploy to the sandbox
126
- │ └── keepalive.yml # every 10 min: keep the sandbox awake
127
- ├── pyproject.toml # the Python package (pip install .)
128
- ├── package.json # the npm package (npm install -g polyester-bot)
129
- └── polyester-bot.png
130
- ```
44
+ - **Fees are live** — read from the exchange every 30 min; the gap floor follows them
45
+ (`2 x maker + 0.01` margin, `0.11%` at VIP3). Set `GAP_MIN_PCT` to pin it.
46
+ - **Gap mode** — buys the bid, asks under the offer, bot chasing every 2s:
47
+ two contiguous lines per action, Telegram alerts on fills, skips, milestones.
48
+ - **Chase (both sides)** — orders are **amended** one tick past a competing ask/bid
49
+ (atomic, order id kept), cancel only when the amend cannot.
50
+ - **Volume fallback** — deepest market-maker churn if no profitable gap; escalation
51
+ ladder pennies the bid then crosses the spread as timeouts repeat.
52
+ - **Dead-man switch** — `cancel_all_after` re-arms every 60s; if the VM freezes/dies,
53
+ the exchange cancels everything itself.
54
+ - **Freeze-resilient** — sandbox pause stops the guest clock; wall-vs-monotonic skew
55
+ >`FREEZE_SECONDS` triggers a full re-sync (cancel own orders, adopt balances).
131
56
 
132
57
  ## Configuration
133
58
 
134
- `bot/.env` holds the credentials and tuning; `bot/.env.example` lists every key.
135
- The real file stays on the machine and is gitignored — a deploy never overwrites it.
59
+ Everything tunable lives in `bot/.env` (created by `setup`) and documented there.
60
+ The ones that matter most:
136
61
 
137
62
  | Key | Default | Meaning |
138
- | --- | --- | --- |
139
- | `GAP_MIN_PCT` | `0.21` | minimum spread to take in gap mode |
140
- | `DEPTH_MIN_USD` | `50` | minimum depth on both sides |
141
- | `SCAN_SECONDS` | `10` | how often to scan for a gap |
142
- | `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 |
145
- | `BUY_TIMEOUT_SECONDS` | `180` | cancel an unfilled buy after this long |
146
- | `FREEZE_SECONDS` | `300` | wall-vs-monotonic clock skew that counts as a paused sandbox |
147
- | `STALL_SECONDS` | `900` | how long the loop may hang before it counts as a pause |
148
- | `FALLBACK_*` | see example | volume fallback mode |
149
- | `VOLUME_BASELINE` | `57000` | season volume to start from with no state file |
150
-
151
- Alerts go to **Telegram** (every alert) and **Discord** if `DISCORD_WEBHOOK_URL`
152
- is set. Everything is also printed to the journal.
153
-
154
- Every Telegram alert is three lines — the action, the market with its spread,
155
- then the money:
156
-
157
- ```
158
- 🟢 BUY
159
- BTC-USDT | gap 0.85% (net +0.65%) | 0.0123 @ 41,250.00 | depth 12,450.00/12,556.00
160
- expected +8.20 USDT | USDT 3,094.10 | BTC 0.0123
161
- ```
63
+ |---|---|---|
64
+ | `GAP_MIN_PCT` | fee-driven (`0.11`) | spread to take in gap mode |
65
+ | `DEPTH_MIN_USD` | `50` | min depth each side to enter |
66
+ | `BETTER_ASKS_LIMIT` / `BIDS_LIMIT` | `1` | better prices beating ours to trigger a chase |
67
+ | `PERSIST_SECONDS` | `60` | how long those must hold |
68
+ | `SAT_PENNY_AFTER` / `SAT_TAKER_AFTER` | `2` / `4` | fallback-ladder triggers |
69
+ | `FALLBACK_BUY_TIMEOUT` | `60` | fallback buy timeout on the ladder |
70
+ | `DEADMAN_TIMEOUT` | `120` | silent deadline before the exchange cancels everything |
71
+ | `FREEZE_SECONDS` | `300` | wall-vs-monotonic skew that counts as a paused sandbox |
72
+ | `TP_TRIGGER_PCT` | `0` | optional full-size server-side take-profit (off by default) |
162
73
 
163
- ```
164
- 🔵 ASK
165
- ARB-USDT | gap 0.118% | 8,946.0 @ 0.167 | bought @ 0.167
166
- expected +1.21 USDT | USDT 2,983.94 | ARB 8,946.35
167
- ```
168
-
169
- `🟡 VOLUME BUY`, `✅ BUY FILLED`, `✅ CYCLE DONE`, `🏆 MILESTONE`, `❄️ FREEZE
170
- DETECTED` and `⚠️` warnings use the same shape.
171
-
172
- ## Deploy
173
-
174
- Push to `main` → GitHub Actions → SSH → `deploy/deploy.sh` → systemd restart → verify.
175
-
176
- | Name | Kind | Value |
177
- | --- | --- | --- |
178
- | `INSTABOX_SSH_KEY` | secret | private key with access to the sandbox |
179
- | `INSTABOX_HOST` | variable | `ssh.instabox.sh` |
180
- | `INSTABOX_PORT` | variable | `2222` |
181
- | `INSTABOX_USER` | variable | sandbox user |
182
-
183
- Secrets, `.session` and `volume_state.json` are never touched by a deploy.
184
-
185
- `keepalive.yml` runs every 10 minutes and opens an SSH session: the sandbox pauses
186
- itself after 30 idle minutes, and SSH traffic counts as activity, so it stays up.
187
- It also restarts the bot when its heartbeat file (`/var/tmp/polyester.heartbeat`)
188
- goes stale, which catches a bot that is alive but wedged.
189
-
190
- The bot defends itself as well. A paused sandbox stops the guest clock, so the
191
- wall clock jumps while the monotonic clock does not; when the bot sees that
192
- skew (or a loop that hung for minutes), it cancels its **own** stale orders,
193
- re-reads the balances and re-adopts the real position. If the exchange is
194
- unreachable it keeps the position it has and retries on the next poll instead
195
- of trading on stale state.
196
-
197
- ## Daily claim
198
-
199
- `polyester-claim.timer` claims the daily paper asset at **00:10 UTC** with
200
- `Persistent=true`, so a run missed while the machine was down happens on next boot.
201
- Whatever it pays out arrives as extra balances, which the bot adopts and sells back
202
- into USDT before looking for the next gap.
203
-
204
- ```bash
205
- systemctl list-timers polyester-claim.timer
206
- journalctl -u polyester-claim.service
207
- ```
208
-
209
- ## Tests
210
-
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.
74
+ ## Alerts
215
75
 
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.
76
+ Every alert is 1 header + 1 fact line + 1 money line (`🟢 BUY` / `🔵 ASK` / `✅ CYCLE DONE` /
77
+ `🏆 MILESTONE`). Telegram gets everything; journal keeps a copy too.
226
78
 
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.
79
+ ## Verify
234
80
 
235
81
  ```bash
236
- python tests/test_paths.py
82
+ for t in tests/test_*.py; do python "$t" || exit 1; done
237
83
  ```
238
84
 
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.
85
+ ## Release history
246
86
 
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.
87
+ - **v1.2.0** — live VIP fee rates, buy-side chase with atomic amend, websocket orderbook,
88
+ liquidity-aware fallback, dead-man switch, daily equity line, opt-in TP triggers,
89
+ orphan/cycle fixes (found on the live book: 500M SHIB stuck).
90
+ - v1.1.0 — guided setup wizard; test suites; scanner + re-sync.
249
91
 
250
- ## Ops
92
+ ## License
251
93
 
252
- | Script | What it does |
253
- | --- | --- |
254
- | `check_orders.py` | print all open orders with client ids |
255
- | `cancel_all.py` | cancel every open order |
256
- | `status_all.py` | open orders plus hot pairs |
257
- | `scan_spread.py` | print the spread of every pair |
258
- | `big_buy.py` | buy a fixed notional at market |
259
- | `hunter_forever.py` | keep `monitor.py` running |
260
- | `kill_bot.py` | stop the bot processes |
261
-
262
- ## Remote / Links
263
-
264
- - GitHub: [@natanim-kemal](https://github.com/natanim-kemal)
265
- - Telegram: [@phnatanim](https://t.me/phnatanim)
266
- - Email: reachnatanim@gmail.com
267
-
268
- ---
269
-
270
- <div align="center">
271
-
272
- Built for the <a href="https://polyester.com">Polyester</a> testnet by <a href="https://github.com/natanim-kemal">@natanim-kemal</a>
273
-
274
- </div>
94
+ MIT
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.1",
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.1"
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