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 +51 -231
- 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,52 +1,27 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<img src="polyester-bot.png" alt="Polyester" width="
|
|
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
|
|
8
|
-
|
|
9
|
-

|
|
10
|
-

|
|
11
|
-

|
|
12
|
-

|
|
7
|
+
**Testnet market-making bot. One position at a time. Telegram alerts.**
|
|
13
8
|
|
|
14
9
|
</div>
|
|
15
10
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
##
|
|
42
|
+
## How it works
|
|
97
43
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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`
|
|
135
|
-
The
|
|
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.
|
|
140
|
-
| `DEPTH_MIN_USD` | `50` |
|
|
141
|
-
| `
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
145
|
-
| `
|
|
146
|
-
| `FREEZE_SECONDS` | `300` | wall-vs-monotonic
|
|
147
|
-
| `
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
+
for t in tests/test_*.py; do python "$t" || exit 1; done
|
|
237
83
|
```
|
|
238
84
|
|
|
239
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
##
|
|
92
|
+
## License
|
|
251
93
|
|
|
252
|
-
|
|
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
|
-
|
|
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.1
|
|
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
|
|
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
|