polyester-bot 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +273 -2
- package/bin/polyester-bot.js +7 -0
- package/bin/polyester-farm.js +4 -0
- package/bin/setup.js +75 -0
- package/bin/venv.js +12 -0
- package/bot/__init__.py +1 -0
- package/bot/big_buy.py +75 -0
- package/bot/cancel_all.py +72 -0
- package/bot/check_orders.py +47 -0
- package/bot/hunter_forever.py +18 -0
- package/bot/kill_bot.py +11 -0
- package/bot/monitor.py +847 -0
- package/bot/paths.py +43 -0
- package/bot/polyester_farm.py +434 -0
- package/bot/scan_spread.py +76 -0
- package/bot/setup.py +300 -0
- package/bot/status_all.py +22 -0
- package/install.js +5 -0
- package/package.json +41 -4
- package/polyester-bot.png +0 -0
- package/pyproject.toml +40 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 natanim-kemal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,274 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<img src="polyester-bot.png" alt="Polyester" width="120" height="120" style="border-radius:50%; object-fit:cover;" />
|
|
4
|
+
|
|
5
|
+
# Polyester Gap Hunter
|
|
6
|
+
|
|
7
|
+
**Testnet market-making bot · one position at a time · Telegram alerts · GitHub Actions deploy**
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+

|
|
11
|
+

|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
</div>
|
|
15
|
+
|
|
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.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
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
|
+
```bash
|
|
45
|
+
pip install "polyester-bot @ git+https://github.com/natanim-kemal/polyester-bot.git@v1.1.0"
|
|
46
|
+
polyester-bot setup
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**source**:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
git clone https://github.com/natanim-kemal/polyester-bot.git
|
|
53
|
+
cd polyester-bot
|
|
54
|
+
pip install -e .
|
|
55
|
+
polyester-bot setup
|
|
56
|
+
```
|
|
57
|
+
|
|
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:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
polyester-bot
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`POLYESTER_STATE_DIR` moves the working directory somewhere else. The bot trades
|
|
94
|
+
**testnet** funds only.
|
|
95
|
+
|
|
96
|
+
## Project Structure
|
|
97
|
+
|
|
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
|
+
```
|
|
131
|
+
|
|
132
|
+
## Configuration
|
|
133
|
+
|
|
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.
|
|
136
|
+
|
|
137
|
+
| 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
|
+
```
|
|
162
|
+
|
|
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.
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
python tests/test_sell_loop.py
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Exits non-zero if the bot cancels more than once per bail-out.
|
|
221
|
+
|
|
222
|
+
`tests/test_freeze_detect.py` pulls the real freeze-detection code out of
|
|
223
|
+
`bot/monitor.py` and drives it: a normal poll does nothing, the real 1h47m
|
|
224
|
+
pause triggers a re-sync, a stalled loop triggers it too, and an unreachable
|
|
225
|
+
exchange keeps the position instead of forgetting it.
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
python tests/test_freeze_detect.py
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`tests/test_paths.py` checks where the bot looks for its `.env` and its state
|
|
232
|
+
files: the working directory wins, the script directory is the fallback, and an
|
|
233
|
+
existing state file is never moved.
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
python tests/test_paths.py
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`tests/test_stuck.py` replays the bug where a filled buy was invisible because
|
|
240
|
+
the balance lagged behind the order, which froze the bot for hours. It fails on
|
|
241
|
+
the old code and passes on the new.
|
|
242
|
+
|
|
243
|
+
`tests/test_setup.py` checks the setup wizard: seed formats, the `.env` merge,
|
|
244
|
+
the home copy, and that the bot asks the exchange for its account id instead of
|
|
245
|
+
having one baked in.
|
|
246
|
+
|
|
247
|
+
All five run in CI on every push, before anything is deployed, together with a
|
|
248
|
+
build of the Python package and a smoke test of its entry point.
|
|
249
|
+
|
|
250
|
+
## Ops
|
|
251
|
+
|
|
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>
|
package/bin/setup.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require("child_process");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
|
|
7
|
+
const root = path.join(__dirname, "..");
|
|
8
|
+
const venv = path.join(root, ".venv");
|
|
9
|
+
|
|
10
|
+
function python() {
|
|
11
|
+
return process.platform === "win32"
|
|
12
|
+
? path.join(venv, "Scripts", "python.exe")
|
|
13
|
+
: path.join(venv, "bin", "python");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function run(cmd, args, label) {
|
|
17
|
+
const r = spawnSync(cmd, args, {
|
|
18
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
19
|
+
encoding: "utf8"
|
|
20
|
+
});
|
|
21
|
+
if (r.status !== 0) {
|
|
22
|
+
console.error("polyester-bot: " + label + " failed");
|
|
23
|
+
if (r.stderr) console.error(r.stderr.trim());
|
|
24
|
+
console.error("Install it by hand:");
|
|
25
|
+
console.error(" python3 -m venv .venv && .venv/bin/pip install .");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
return r.stdout || "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function findPython() {
|
|
32
|
+
const candidates = process.platform === "win32"
|
|
33
|
+
? [["py", ["-3"]], ["python", []], ["python3", []]]
|
|
34
|
+
: [["python3", []], ["python", []]];
|
|
35
|
+
for (const [cmd, args] of candidates) {
|
|
36
|
+
const probe = spawnSync(
|
|
37
|
+
cmd,
|
|
38
|
+
args.concat(["-c", "import sys; print(sys.version_info >= (3, 11))"]),
|
|
39
|
+
{ encoding: "utf8" }
|
|
40
|
+
);
|
|
41
|
+
if (probe.status === 0 && probe.stdout.trim() === "True") {
|
|
42
|
+
return [cmd, args];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ready() {
|
|
49
|
+
if (!fs.existsSync(python())) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return spawnSync(python(), ["-c", "import polyester_bot"], { encoding: "utf8" }).status === 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ensurePython() {
|
|
56
|
+
if (ready()) {
|
|
57
|
+
return python();
|
|
58
|
+
}
|
|
59
|
+
const found = findPython();
|
|
60
|
+
if (!found) {
|
|
61
|
+
console.error("polyester-bot: Python 3.11 or newer is required and was not found.");
|
|
62
|
+
console.error("Install Python 3.11+, then run: npm rebuild polyester-bot");
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
console.log("polyester-bot: setting up the Python environment (takes a minute)...");
|
|
66
|
+
run(found[0], found[1].concat(["-m", "venv", venv]),
|
|
67
|
+
"creating the Python environment");
|
|
68
|
+
run(python(), ["-m", "pip", "install", "--upgrade", "--quiet", "pip"],
|
|
69
|
+
"upgrading pip");
|
|
70
|
+
run(python(), ["-m", "pip", "install", "--quiet", root],
|
|
71
|
+
"installing the bot");
|
|
72
|
+
return python();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { root, venv, python, ensurePython };
|
package/bin/venv.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require("child_process");
|
|
4
|
+
const { ensurePython } = require("./setup");
|
|
5
|
+
|
|
6
|
+
function run(moduleName, args) {
|
|
7
|
+
const py = ensurePython();
|
|
8
|
+
const r = spawnSync(py, ["-m", moduleName].concat(args), { stdio: "inherit" });
|
|
9
|
+
process.exit(r.status === null ? 1 : r.status);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = { run };
|
package/bot/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Polyester testnet gap-hunter bot."""
|
package/bot/big_buy.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""One-shot: place a full-balance BUY one tick above best bid on BTC-USDT."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import uuid
|
|
6
|
+
from decimal import Decimal, ROUND_FLOOR
|
|
7
|
+
|
|
8
|
+
from polyester import POLYESTER_TESTNET_ENVIRONMENT, AsyncPolyester
|
|
9
|
+
|
|
10
|
+
if __package__:
|
|
11
|
+
from .paths import env_files
|
|
12
|
+
else:
|
|
13
|
+
from paths import env_files
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_env():
|
|
17
|
+
for path in env_files():
|
|
18
|
+
try:
|
|
19
|
+
with open(path, encoding="utf-8") as f:
|
|
20
|
+
for line in f:
|
|
21
|
+
line = line.strip()
|
|
22
|
+
if line and not line.startswith("#") and "=" in line:
|
|
23
|
+
k, _, v = line.partition("=")
|
|
24
|
+
os.environ.setdefault(k.strip(), v.strip())
|
|
25
|
+
except OSError:
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
load_env()
|
|
30
|
+
|
|
31
|
+
SYMBOL = "BTC-USDT"
|
|
32
|
+
STEP = Decimal("0.00000001")
|
|
33
|
+
FEE_BUFFER = Decimal("1.0015")
|
|
34
|
+
|
|
35
|
+
CID = "man1-" + uuid.uuid4().hex[:16]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def main():
|
|
39
|
+
c = AsyncPolyester(environment=POLYESTER_TESTNET_ENVIRONMENT,
|
|
40
|
+
api_key_id=os.environ["POLYESTER_API_KEY_ID"],
|
|
41
|
+
api_private_key=os.environ["POLYESTER_ED25519_SEED"],
|
|
42
|
+
default_account_id=os.environ["POLYESTER_ACCOUNT_ID"])
|
|
43
|
+
try:
|
|
44
|
+
await c.wait_for_catalogs()
|
|
45
|
+
b = await c.balances.list()
|
|
46
|
+
usdt = next(a for a in b.balances if a.asset_id == 1)
|
|
47
|
+
avail = Decimal(usdt.available) / Decimal(10 ** 18)
|
|
48
|
+
|
|
49
|
+
book = await c.orderbook.get(symbol=SYMBOL, depth=1)
|
|
50
|
+
bid = book.bids[0].price.as_decimal()
|
|
51
|
+
price = bid + Decimal("0.01")
|
|
52
|
+
|
|
53
|
+
qty = (avail / (price * FEE_BUFFER)).quantize(STEP, rounding=ROUND_FLOOR)
|
|
54
|
+
notional = qty * price
|
|
55
|
+
|
|
56
|
+
print(f"USDT available : {avail}")
|
|
57
|
+
print(f"best bid : {bid}")
|
|
58
|
+
print(f"order price : {price} (bid + 1 tick)")
|
|
59
|
+
print(f"order qty : {qty} BTC")
|
|
60
|
+
print(f"notional : ~{notional.quantize(Decimal('0.01'))} USDT")
|
|
61
|
+
print(f"maker fee 0.1% : ~{(notional * Decimal('0.001')).quantize(Decimal('0.01'))} USDT on fill")
|
|
62
|
+
|
|
63
|
+
res = await c.orders.batch_create(symbol=SYMBOL, items=[{
|
|
64
|
+
"symbol": SYMBOL, "side": "buy", "order_type": "limit", "tif": "gtc",
|
|
65
|
+
"qty": format(qty, "f"), "price": format(price, "f"),
|
|
66
|
+
"client_order_id": CID,
|
|
67
|
+
}], request_id="man1-" + uuid.uuid4().hex[:16])
|
|
68
|
+
for item in res.results:
|
|
69
|
+
print("RESULT:", item.status, getattr(item, "code", "") or "",
|
|
70
|
+
getattr(item, "order_id", ""))
|
|
71
|
+
finally:
|
|
72
|
+
await c.aclose()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""List ALL open orders on the account, cancel everything, verify empty."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import urllib.request
|
|
8
|
+
import urllib.error
|
|
9
|
+
|
|
10
|
+
import nacl.signing
|
|
11
|
+
|
|
12
|
+
if __package__:
|
|
13
|
+
from .paths import env_files
|
|
14
|
+
else:
|
|
15
|
+
from paths import env_files
|
|
16
|
+
|
|
17
|
+
for path in env_files():
|
|
18
|
+
with open(path, encoding="utf-8") as f:
|
|
19
|
+
for line in f:
|
|
20
|
+
line = line.strip()
|
|
21
|
+
if line and not line.startswith("#") and "=" in line:
|
|
22
|
+
k, _, v = line.partition("=")
|
|
23
|
+
os.environ.setdefault(k.strip(), v.strip())
|
|
24
|
+
|
|
25
|
+
KEY = os.environ["POLYESTER_API_KEY_ID"]
|
|
26
|
+
sk = nacl.signing.SigningKey(bytes.fromhex(os.environ["POLYESTER_ED25519_SEED"]))
|
|
27
|
+
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
28
|
+
"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")
|
|
29
|
+
API = "https://api.testnet.polyester.com"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def signed(method, path, query="", body=b""):
|
|
33
|
+
ts = str(int(time.time() * 1000))
|
|
34
|
+
canon = "\n".join([ts, method.upper(), path, query,
|
|
35
|
+
hashlib.sha256(body).hexdigest()])
|
|
36
|
+
req = urllib.request.Request(API + path + (("?") + query if query else ""),
|
|
37
|
+
data=body or None, method=method)
|
|
38
|
+
req.add_header("User-Agent", UA)
|
|
39
|
+
req.add_header("Accept", "application/json")
|
|
40
|
+
req.add_header("Origin", "https://testnet.polyester.com")
|
|
41
|
+
if body:
|
|
42
|
+
req.add_header("Content-Type", "application/json")
|
|
43
|
+
req.add_header("X-API-KEY-ID", KEY)
|
|
44
|
+
req.add_header("X-API-TIMESTAMP", ts)
|
|
45
|
+
req.add_header("X-API-SIGNATURE", sk.sign(canon.encode()).signature.hex())
|
|
46
|
+
try:
|
|
47
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
48
|
+
return r.status, json.loads(r.read() or b"{}")
|
|
49
|
+
except urllib.error.HTTPError as e:
|
|
50
|
+
return e.code, {"raw": e.read().decode(errors="replace")[:200]}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
s, before = signed("GET", "/v1/orders/open")
|
|
54
|
+
orders = before.get("orders", []) if isinstance(before, dict) else []
|
|
55
|
+
print(f"open orders BEFORE: {len(orders)}")
|
|
56
|
+
for o in orders:
|
|
57
|
+
print(" ", o.get("orderId"), o.get("symbol"), o.get("side"), o.get("origQty"),
|
|
58
|
+
"@", o.get("price"), o.get("status"), o.get("clientOrderId", "")[:14])
|
|
59
|
+
|
|
60
|
+
if orders:
|
|
61
|
+
import uuid
|
|
62
|
+
body = json.dumps({"requestId": "manual-" + uuid.uuid4().hex[:12]}).encode()
|
|
63
|
+
s, res = signed("POST", "/v1/orders/cancel-all", body=body)
|
|
64
|
+
print("cancel-all:", s, str(res)[:200])
|
|
65
|
+
time.sleep(2)
|
|
66
|
+
s, after = signed("GET", "/v1/orders/open")
|
|
67
|
+
left = after.get("orders", []) if isinstance(after, dict) else []
|
|
68
|
+
print(f"open orders AFTER: {len(left)}")
|
|
69
|
+
for o in left:
|
|
70
|
+
print(" ", o.get("orderId"), o.get("symbol"), o.get("side"), o.get("status"))
|
|
71
|
+
else:
|
|
72
|
+
print("nothing open — nothing to cancel")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""One-shot check: list the bot's open BTC-USDT quotes."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
import urllib.request
|
|
7
|
+
import urllib.error
|
|
8
|
+
|
|
9
|
+
import nacl.signing
|
|
10
|
+
|
|
11
|
+
if __package__:
|
|
12
|
+
from .paths import env_files
|
|
13
|
+
else:
|
|
14
|
+
from paths import env_files
|
|
15
|
+
|
|
16
|
+
for path in env_files():
|
|
17
|
+
with open(path, encoding="utf-8") as f:
|
|
18
|
+
for line in f:
|
|
19
|
+
line = line.strip()
|
|
20
|
+
if line and not line.startswith("#") and "=" in line:
|
|
21
|
+
k, _, v = line.partition("=")
|
|
22
|
+
os.environ.setdefault(k.strip(), v.strip())
|
|
23
|
+
|
|
24
|
+
KEY = os.environ["POLYESTER_API_KEY_ID"]
|
|
25
|
+
sk = nacl.signing.SigningKey(bytes.fromhex(os.environ["POLYESTER_ED25519_SEED"]))
|
|
26
|
+
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
27
|
+
"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")
|
|
28
|
+
API = "https://api.testnet.polyester.com"
|
|
29
|
+
|
|
30
|
+
path, query = "/v1/orders/open", "symbol=BTC-USDT"
|
|
31
|
+
ts = str(int(time.time() * 1000))
|
|
32
|
+
canon = "\n".join([ts, "GET", path, query, hashlib.sha256(b"").hexdigest()])
|
|
33
|
+
req = urllib.request.Request(f"{API}{path}?{query}", method="GET")
|
|
34
|
+
req.add_header("User-Agent", UA)
|
|
35
|
+
req.add_header("Accept", "application/json")
|
|
36
|
+
req.add_header("Origin", "https://testnet.polyester.com")
|
|
37
|
+
req.add_header("X-API-KEY-ID", KEY)
|
|
38
|
+
req.add_header("X-API-TIMESTAMP", ts)
|
|
39
|
+
req.add_header("X-API-SIGNATURE", sk.sign(canon.encode()).signature.hex())
|
|
40
|
+
try:
|
|
41
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
42
|
+
data = json.loads(r.read())
|
|
43
|
+
for o in data.get("orders", []):
|
|
44
|
+
print(o["side"], o["origQty"], "@", o["price"], o["status"], o["clientOrderId"][:12])
|
|
45
|
+
print(len(data.get("orders", [])), "open orders")
|
|
46
|
+
except urllib.error.HTTPError as e:
|
|
47
|
+
print("ERR", e.code, e.read().decode(errors="replace")[:300])
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Supervisor: restart the gap hunter whenever it exits (crash, WiFi drop)."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
if __package__:
|
|
9
|
+
from .paths import HERE
|
|
10
|
+
else:
|
|
11
|
+
from paths import HERE
|
|
12
|
+
|
|
13
|
+
print("[supervisor] gap hunter supervisor started", flush=True)
|
|
14
|
+
|
|
15
|
+
while True:
|
|
16
|
+
p = subprocess.run([sys.executable, os.path.join(HERE, "monitor.py")], cwd=HERE)
|
|
17
|
+
print(f"[supervisor] hunter exited ({p.returncode}); restarting in 10s", flush=True)
|
|
18
|
+
time.sleep(10)
|
package/bot/kill_bot.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Kill the gap hunter, its supervisor, and any leftover bot processes."""
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
ps = r"""
|
|
6
|
+
Get-CimInstance Win32_Process |
|
|
7
|
+
Where-Object { $_.CommandLine -match 'monitor|hunter_forever' -and $_.ProcessId -ne $PID } |
|
|
8
|
+
ForEach-Object { Write-Output $_.ProcessId; Stop-Process -Id $_.ProcessId -Force }
|
|
9
|
+
"""
|
|
10
|
+
subprocess.run(["powershell", "-NoProfile", "-Command", ps])
|
|
11
|
+
print("done")
|