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/bot/paths.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Where the bot finds its .env and keeps its runtime state.
|
|
2
|
+
|
|
3
|
+
An installed copy lives in site-packages, which is read-only, so the .env and
|
|
4
|
+
the state files live next to the project that runs the bot. STATE_DIR is the
|
|
5
|
+
working directory unless POLYESTER_STATE_DIR says otherwise. HOME_ENV is the
|
|
6
|
+
copy the setup wizard writes, so the bot finds its credentials from any
|
|
7
|
+
directory.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
13
|
+
STATE_DIR = os.environ.get("POLYESTER_STATE_DIR") or os.getcwd()
|
|
14
|
+
HOME_ENV = os.path.join(os.path.expanduser("~"), ".polyester-bot", ".env")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def env_file():
|
|
18
|
+
"""The .env to read: the state dir first, the home copy, then the script dir."""
|
|
19
|
+
for p in (os.path.join(STATE_DIR, ".env"), HOME_ENV, os.path.join(HERE, ".env")):
|
|
20
|
+
if os.path.isfile(p):
|
|
21
|
+
return p
|
|
22
|
+
return os.path.join(STATE_DIR, ".env")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def env_files():
|
|
26
|
+
"""Every .env to read, most specific first: the state dir, home, script dir."""
|
|
27
|
+
out = []
|
|
28
|
+
for p in (os.path.join(STATE_DIR, ".env"), HOME_ENV, os.path.join(HERE, ".env")):
|
|
29
|
+
if os.path.isfile(p) and p not in out:
|
|
30
|
+
out.append(p)
|
|
31
|
+
return out
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def state_file(name):
|
|
35
|
+
"""A writable path for a state file, keeping an existing one where it is."""
|
|
36
|
+
for d in (STATE_DIR, HERE):
|
|
37
|
+
p = os.path.join(d, name)
|
|
38
|
+
if os.path.isfile(p):
|
|
39
|
+
return p
|
|
40
|
+
for d in (STATE_DIR, HERE):
|
|
41
|
+
if os.path.isdir(d) and os.access(d, os.W_OK):
|
|
42
|
+
return os.path.join(d, name)
|
|
43
|
+
return os.path.join(STATE_DIR, name)
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Polyester testnet farming helper: daily claim + award poller + checkpoint reader.
|
|
3
|
+
|
|
4
|
+
Usage (env vars):
|
|
5
|
+
POLYESTER_SESSION_JWT use an existing session token (skips wallet login), or
|
|
6
|
+
POLYESTER_PRIVATE_KEY EOA private key that controls your smart account, and
|
|
7
|
+
POLYESTER_SMART_ACCOUNT your Polyester smart-account address (0x...)
|
|
8
|
+
POLYESTER_SIGNER_ADDRESS optional; defaults to the address derived from the key
|
|
9
|
+
|
|
10
|
+
Commands:
|
|
11
|
+
python polyester_farm.py selftest # verify endpoint reachability (no creds needed)
|
|
12
|
+
python polyester_farm.py status # today's daily-claim status
|
|
13
|
+
python polyester_farm.py claim # claim today's reward, poll until settled
|
|
14
|
+
python polyester_farm.py awards # list campaign awards (real-asset payouts)
|
|
15
|
+
python polyester_farm.py set-destination --award-id ID --address 0x.. --confirm
|
|
16
|
+
python polyester_farm.py checkpoints # season recap stats (airdrop scorecard)
|
|
17
|
+
python polyester_farm.py daily # status + claim + awards in one run (cron)
|
|
18
|
+
"""
|
|
19
|
+
import argparse
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.request
|
|
27
|
+
import uuid
|
|
28
|
+
|
|
29
|
+
if __package__:
|
|
30
|
+
from .paths import env_files, state_file
|
|
31
|
+
else:
|
|
32
|
+
from paths import env_files, state_file
|
|
33
|
+
|
|
34
|
+
API = "https://api.testnet.polyester.com"
|
|
35
|
+
SITE = "https://testnet.polyester.com"
|
|
36
|
+
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
37
|
+
"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_env():
|
|
41
|
+
"""Load KEY=VALUE pairs from every .env the bot reads (no override)."""
|
|
42
|
+
for path in env_files():
|
|
43
|
+
try:
|
|
44
|
+
with open(path, encoding="utf-8") as f:
|
|
45
|
+
for line in f:
|
|
46
|
+
line = line.strip()
|
|
47
|
+
if line and not line.startswith("#") and "=" in line:
|
|
48
|
+
k, _, v = line.partition("=")
|
|
49
|
+
os.environ.setdefault(k.strip(), v.strip())
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
load_env()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def api_key_headers(method, path, body_bytes):
|
|
58
|
+
"""Ed25519-signed headers: X-API-KEY-ID / TIMESTAMP / SIGNATURE."""
|
|
59
|
+
seed = os.environ.get("POLYESTER_ED25519_SEED", "")
|
|
60
|
+
key_id = os.environ.get("POLYESTER_API_KEY_ID", "")
|
|
61
|
+
if not (seed and key_id):
|
|
62
|
+
die("set POLYESTER_ED25519_SEED and POLYESTER_API_KEY_ID in .env "
|
|
63
|
+
"for API-key calls")
|
|
64
|
+
try:
|
|
65
|
+
import nacl.signing
|
|
66
|
+
except ImportError:
|
|
67
|
+
die("pip install pynacl (needed for API-key signing)")
|
|
68
|
+
ts = str(int(time.time() * 1000))
|
|
69
|
+
canon = "\n".join([ts, method.upper(), path, "",
|
|
70
|
+
hashlib.sha256(body_bytes).hexdigest()])
|
|
71
|
+
sk = nacl.signing.SigningKey(bytes.fromhex(seed))
|
|
72
|
+
return {"X-API-KEY-ID": key_id, "X-API-TIMESTAMP": ts,
|
|
73
|
+
"X-API-SIGNATURE": sk.sign(canon.encode()).signature.hex()}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ApiError(Exception):
|
|
77
|
+
def __init__(self, status, code, payload):
|
|
78
|
+
super().__init__(f"HTTP {status} {code}: {json.dumps(payload)[:300]}")
|
|
79
|
+
self.status, self.code, self.payload = status, code, payload
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def http(method, path, body=None, token=None, connect=False, api_key=False,
|
|
83
|
+
retries=2):
|
|
84
|
+
if not path.startswith("/"):
|
|
85
|
+
path = "/" + path
|
|
86
|
+
body_b = json.dumps(body).encode() if body is not None else None
|
|
87
|
+
req = urllib.request.Request(API + path, data=body_b, method=method)
|
|
88
|
+
req.add_header("User-Agent", UA)
|
|
89
|
+
req.add_header("Accept", "application/json")
|
|
90
|
+
req.add_header("Origin", SITE)
|
|
91
|
+
req.add_header("Referer", SITE + "/")
|
|
92
|
+
req.add_header("Accept-Language", "en-US,en;q=0.9")
|
|
93
|
+
if body is not None:
|
|
94
|
+
req.add_header("Content-Type", "application/json")
|
|
95
|
+
if token:
|
|
96
|
+
req.add_header("Authorization", "Bearer " + token)
|
|
97
|
+
elif api_key:
|
|
98
|
+
for k, v in api_key_headers(method, path, body_b or b"").items():
|
|
99
|
+
req.add_header(k, v)
|
|
100
|
+
if connect:
|
|
101
|
+
req.add_header("Connect-Protocol-Version", "1")
|
|
102
|
+
last = None
|
|
103
|
+
for attempt in range(retries + 1):
|
|
104
|
+
try:
|
|
105
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
106
|
+
raw = r.read()
|
|
107
|
+
return json.loads(raw) if raw else {}
|
|
108
|
+
except urllib.error.HTTPError as e:
|
|
109
|
+
payload = e.read().decode(errors="replace")
|
|
110
|
+
try:
|
|
111
|
+
parsed = json.loads(payload)
|
|
112
|
+
except ValueError:
|
|
113
|
+
if ("Just a moment" in payload or "cf_chl" in payload
|
|
114
|
+
or "Attention Required" in payload
|
|
115
|
+
or "<title>Cloudflare" in payload):
|
|
116
|
+
raise ApiError(e.code, "CLOUDFLARE_CHALLENGE",
|
|
117
|
+
{"hint": "retry shortly or from another network"})
|
|
118
|
+
parsed = {"raw": payload[:300]}
|
|
119
|
+
code = parsed.get("code", "")
|
|
120
|
+
last = ApiError(e.code, code, parsed)
|
|
121
|
+
if e.code in (429, 503) or code in ("RATE_LIMIT_EXCEEDED", "UNAVAILABLE"):
|
|
122
|
+
time.sleep(2 * (attempt + 1))
|
|
123
|
+
continue
|
|
124
|
+
raise last
|
|
125
|
+
except (urllib.error.URLError, OSError) as e:
|
|
126
|
+
last = ApiError(0, "NETWORK", {"error": str(e)})
|
|
127
|
+
time.sleep(2 * (attempt + 1))
|
|
128
|
+
continue
|
|
129
|
+
raise last or ApiError(0, "NETWORK", {})
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def die(msg, code=1):
|
|
133
|
+
print("ERROR: " + msg, file=sys.stderr)
|
|
134
|
+
hints = {
|
|
135
|
+
"UNAUTHENTICATED": "log in first (set POLYESTER_SESSION_JWT or wallet env vars)",
|
|
136
|
+
"AUTH_SESSION_KIND_NOT_ALLOWED": "API keys can't call this; need a session JWT",
|
|
137
|
+
"SOCIAL_VERIFICATION_REQUIRED": "link X or Discord once in the web app, then retry",
|
|
138
|
+
"CLAIM_UNAVAILABLE": "no reward available today; see 'status' for resetAt",
|
|
139
|
+
"CLAIM_TEMPORARILY_UNAVAILABLE": "payout paused; claim is recorded, auto-retried later",
|
|
140
|
+
"CLAIM_PAYOUT_UNAVAILABLE": "payout paused; claim is recorded, auto-retried later",
|
|
141
|
+
"CLOUDFLARE_CHALLENGE": "Cloudflare rate-limited us; wait a minute and retry",
|
|
142
|
+
}
|
|
143
|
+
if msg in hints:
|
|
144
|
+
print("HINT: " + hints[msg], file=sys.stderr)
|
|
145
|
+
sys.exit(code)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_token():
|
|
150
|
+
jwt = os.environ.get("POLYESTER_SESSION_JWT")
|
|
151
|
+
if jwt:
|
|
152
|
+
return jwt.strip()
|
|
153
|
+
cache = state_file(".session")
|
|
154
|
+
try:
|
|
155
|
+
with open(cache, encoding="utf-8") as f:
|
|
156
|
+
saved = json.load(f)
|
|
157
|
+
exp = time.mktime(time.strptime(saved["expiresAt"], "%Y-%m-%dT%H:%M:%SZ")) - 300
|
|
158
|
+
if saved.get("accessToken") and time.time() < exp:
|
|
159
|
+
return saved["accessToken"]
|
|
160
|
+
except (OSError, ValueError, KeyError):
|
|
161
|
+
pass
|
|
162
|
+
key = os.environ.get("POLYESTER_PRIVATE_KEY")
|
|
163
|
+
smart = os.environ.get("POLYESTER_SMART_ACCOUNT")
|
|
164
|
+
if not key or not smart:
|
|
165
|
+
die("set POLYESTER_SESSION_JWT, or both POLYESTER_PRIVATE_KEY and "
|
|
166
|
+
"POLYESTER_SMART_ACCOUNT")
|
|
167
|
+
try:
|
|
168
|
+
from eth_account import Account
|
|
169
|
+
from eth_account.messages import encode_defunct
|
|
170
|
+
except ImportError:
|
|
171
|
+
die("pip install eth-account (needed for wallet login)")
|
|
172
|
+
signer = os.environ.get("POLYESTER_SIGNER_ADDRESS") or Account.from_key(key).address
|
|
173
|
+
ch = http("POST", "/v1/auth/wallet-challenge", {
|
|
174
|
+
"smartAccountAddress": smart, "signerAddress": signer,
|
|
175
|
+
"uri": "https://testnet.polyester.com", "purpose": "LOGIN"})
|
|
176
|
+
message = ch.get("message")
|
|
177
|
+
if not message:
|
|
178
|
+
die("wallet-challenge returned no message: " + json.dumps(ch))
|
|
179
|
+
sig = "0x" + Account.sign_message(encode_defunct(text=message), key).signature.hex()
|
|
180
|
+
res = http("POST", "/v1/auth/login/wallet", {
|
|
181
|
+
"smartAccountAddress": smart, "message": message, "signature": sig})
|
|
182
|
+
token = res.get("accessToken")
|
|
183
|
+
if not token:
|
|
184
|
+
die("login failed: " + json.dumps(res))
|
|
185
|
+
with open(cache, "w", encoding="utf-8") as f:
|
|
186
|
+
json.dump({"accessToken": token,
|
|
187
|
+
"expiresAt": res.get("expiresAt", "")}, f)
|
|
188
|
+
return token
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_status(token):
|
|
193
|
+
return http("GET", "/v1/trading/daily-claim", token=token)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def find_state(node):
|
|
197
|
+
"""Best-effort navigation of the claim state across shape variants."""
|
|
198
|
+
if not isinstance(node, dict):
|
|
199
|
+
return None
|
|
200
|
+
for k in ("state", "status", "claimState"):
|
|
201
|
+
if isinstance(node.get(k), str):
|
|
202
|
+
return node[k]
|
|
203
|
+
claim = node.get("claim")
|
|
204
|
+
if isinstance(claim, dict):
|
|
205
|
+
return find_state(claim)
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def print_status(st):
|
|
210
|
+
print(json.dumps(st, indent=2))
|
|
211
|
+
reset = st.get("resetAt") or (st.get("campaign") or {}).get("resetAt")
|
|
212
|
+
state = find_state(st)
|
|
213
|
+
print(f"\nclaim state: {state or 'unknown'} next reset: {reset or 'unknown'}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def do_claim(token, subaccount=None):
|
|
217
|
+
st = get_status(token)
|
|
218
|
+
state = find_state(st)
|
|
219
|
+
if state == "CLAIMED":
|
|
220
|
+
print("already claimed today")
|
|
221
|
+
print_status(st)
|
|
222
|
+
return st
|
|
223
|
+
body = {"subaccountId": subaccount} if subaccount else {}
|
|
224
|
+
try:
|
|
225
|
+
res = http("POST", "/v1/trading/daily-claim/claim", body, token=token)
|
|
226
|
+
except ApiError as e:
|
|
227
|
+
if e.code in ("CLAIM_UNAVAILABLE", "CONFLICT", "RATE_LIMIT_EXCEEDED"):
|
|
228
|
+
print(f"claim not submitted ({e.code}); current status:")
|
|
229
|
+
print_status(get_status(token))
|
|
230
|
+
return None
|
|
231
|
+
die(e.code or str(e))
|
|
232
|
+
print("claim submitted:")
|
|
233
|
+
print(json.dumps(res, indent=2))
|
|
234
|
+
for _ in range(12):
|
|
235
|
+
time.sleep(5)
|
|
236
|
+
st = get_status(token)
|
|
237
|
+
state = find_state(st)
|
|
238
|
+
if state in ("CLAIMED", "PAYOUT_DELAYED", "UNAVAILABLE"):
|
|
239
|
+
break
|
|
240
|
+
print_status(st)
|
|
241
|
+
return st
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def list_awards(token):
|
|
246
|
+
awards, page = [], None
|
|
247
|
+
while True:
|
|
248
|
+
body = {"limit": 100}
|
|
249
|
+
if page:
|
|
250
|
+
body["pageToken"] = page
|
|
251
|
+
res = http("POST", "rewards.v1.RewardCampaignService/ListMyRewardAwards",
|
|
252
|
+
body, token=token, connect=True)
|
|
253
|
+
awards += res.get("awards", [])
|
|
254
|
+
page = res.get("nextPageToken")
|
|
255
|
+
if not page:
|
|
256
|
+
return awards, res.get("awardsMayChange")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def do_awards(token):
|
|
260
|
+
awards, may_change = list_awards(token)
|
|
261
|
+
if not awards:
|
|
262
|
+
print("no campaign awards yet" + (
|
|
263
|
+
"" if may_change is None else f" (more may come: {may_change})"))
|
|
264
|
+
return awards
|
|
265
|
+
for a in awards:
|
|
266
|
+
need = " <-- NEEDS DESTINATION" if a.get("fulfillmentState") == "AWAITING_DESTINATION" else ""
|
|
267
|
+
print(f"[{a.get('fulfillmentState')}] {a.get('campaignName')} "
|
|
268
|
+
f"{a.get('amountBaseUnits')} assetId={a.get('assetId')} "
|
|
269
|
+
f"network={a.get('network')} awardId={a.get('awardId')}"
|
|
270
|
+
+ (f" dest={a.get('destinationAddress')}" if a.get("destinationAddress") else "")
|
|
271
|
+
+ (f" tx={a.get('transactionId')}" if a.get("transactionId") else "") + need)
|
|
272
|
+
return awards
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def do_set_destination(token, award_id, address, confirm):
|
|
276
|
+
if not confirm:
|
|
277
|
+
die("destination is WRITE-ONCE and cannot be changed; re-run with --confirm")
|
|
278
|
+
awards, _ = list_awards(token)
|
|
279
|
+
match = next((a for a in awards if a.get("awardId") == award_id), None)
|
|
280
|
+
if not match:
|
|
281
|
+
die(f"award {award_id} not found; run 'awards' to list ids")
|
|
282
|
+
if match.get("destinationAddress"):
|
|
283
|
+
die("award already has a destination: " + match["destinationAddress"])
|
|
284
|
+
res = http("POST", "rewards.v1.RewardCampaignService/SetMyRewardDestination", {
|
|
285
|
+
"awardId": award_id,
|
|
286
|
+
"destinationAddress": address,
|
|
287
|
+
"expectedRevision": str(match.get("fulfillmentRevision") or "1"),
|
|
288
|
+
"requestId": str(uuid.uuid4()),
|
|
289
|
+
}, token=token, connect=True)
|
|
290
|
+
print("destination recorded:")
|
|
291
|
+
print(json.dumps(res, indent=2))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def do_checkpoints(token, subaccount=None):
|
|
296
|
+
body = {"subaccountId": subaccount} if subaccount else {}
|
|
297
|
+
res = http("POST", "checkpoints.v1.CheckpointService/ListMyCheckpoints",
|
|
298
|
+
body, token=token, connect=True)
|
|
299
|
+
cps = res.get("checkpoints", [])
|
|
300
|
+
if not cps:
|
|
301
|
+
print("no checkpoints yet (no recorded season activity)")
|
|
302
|
+
return
|
|
303
|
+
for c in cps:
|
|
304
|
+
print(f"season #{c.get('number')} {c.get('seasonStart')} -> {c.get('seasonEnd')} "
|
|
305
|
+
f"(taken {c.get('takenAt')})")
|
|
306
|
+
print(f" volume=${c.get('volumeUsd')} trades={c.get('trades')} "
|
|
307
|
+
f"orders={c.get('orders')} fillRate={c.get('fillRate')}")
|
|
308
|
+
print(f" dailyClaims={c.get('dailyClaims')} longestStreak={c.get('longestStreak')} "
|
|
309
|
+
f"daysActive={c.get('daysActive')} markets={c.get('marketsTraded')}")
|
|
310
|
+
print(f" maker={c.get('makerTrades')} taker={c.get('takerTrades')} "
|
|
311
|
+
f"cancelRatio={c.get('cancelRatio')} selfTrades={c.get('selfTrades')}")
|
|
312
|
+
badges = ", ".join(b.get("badge", "?") for b in c.get("badges", [])) or "-"
|
|
313
|
+
print(f" badges: {badges} incomplete: {', '.join(c.get('incomplete', [])) or '-'}")
|
|
314
|
+
tops = c.get("topPairs", [])[:5]
|
|
315
|
+
if tops:
|
|
316
|
+
print(" top pairs: " + ", ".join(
|
|
317
|
+
f"{t.get('symbol')}(${t.get('volumeUsd')})" for t in tops))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def do_selftest():
|
|
322
|
+
"""Assert unauthenticated reachability + wallet-challenge shape (no creds)."""
|
|
323
|
+
checks = []
|
|
324
|
+
|
|
325
|
+
def check(name, fn, expect):
|
|
326
|
+
try:
|
|
327
|
+
got = fn()
|
|
328
|
+
ok = got in expect
|
|
329
|
+
checks.append(ok)
|
|
330
|
+
print(f"{'PASS' if ok else 'FAIL'} {name}: got {got}")
|
|
331
|
+
except ApiError as e:
|
|
332
|
+
ok = e.status in expect
|
|
333
|
+
checks.append(ok)
|
|
334
|
+
print(f"{'PASS' if ok else 'FAIL'} {name}: HTTP {e.status} {e.code}")
|
|
335
|
+
|
|
336
|
+
check("GET /v1/trading/daily-claim",
|
|
337
|
+
lambda: http("GET", "/v1/trading/daily-claim") and 200, [401])
|
|
338
|
+
check("POST ListMyRewardAwards",
|
|
339
|
+
lambda: http("POST", "rewards.v1.RewardCampaignService/ListMyRewardAwards",
|
|
340
|
+
{}, connect=True) and 200, [401])
|
|
341
|
+
check("POST ListMyCheckpoints",
|
|
342
|
+
lambda: http("POST", "checkpoints.v1.CheckpointService/ListMyCheckpoints",
|
|
343
|
+
{}, connect=True) and 200, [401])
|
|
344
|
+
check("POST wallet-challenge (dummy)",
|
|
345
|
+
lambda: http("POST", "/v1/auth/wallet-challenge", {
|
|
346
|
+
"smartAccountAddress": "0x1111111111111111111111111111111111111111",
|
|
347
|
+
"signerAddress": "0x2222222222222222222222222222222222222222",
|
|
348
|
+
"uri": "https://testnet.polyester.com", "purpose": "LOGIN"}) and 200,
|
|
349
|
+
[200])
|
|
350
|
+
if all(checks):
|
|
351
|
+
print("selftest: all checks passed")
|
|
352
|
+
return 0
|
|
353
|
+
print("selftest: FAILED", file=sys.stderr)
|
|
354
|
+
return 1
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def do_me():
|
|
358
|
+
print(json.dumps(http("GET", "/v1/auth/me", api_key=True), indent=2))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def do_balances():
|
|
362
|
+
rows = http("GET", "/v1/balances", api_key=True).get("balances", [])
|
|
363
|
+
for b in rows:
|
|
364
|
+
try:
|
|
365
|
+
total = sum(float(b.get(k) or 0) for k in
|
|
366
|
+
("trading", "reserved", "funding"))
|
|
367
|
+
except ValueError:
|
|
368
|
+
total = 1
|
|
369
|
+
if total:
|
|
370
|
+
print("{:8} trading={:>24} reserved={:>20} funding={}".format(
|
|
371
|
+
b.get("symbol", "?"), b.get("trading", "0"),
|
|
372
|
+
b.get("reserved", "0"), b.get("funding", "0")))
|
|
373
|
+
if not rows:
|
|
374
|
+
print("(no balance rows returned)")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def main():
|
|
379
|
+
p = argparse.ArgumentParser(description=__doc__,
|
|
380
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
381
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
382
|
+
sub.add_parser("status")
|
|
383
|
+
c = sub.add_parser("claim")
|
|
384
|
+
c.add_argument("--subaccount", help="public ID of an owned subaccount")
|
|
385
|
+
sub.add_parser("awards")
|
|
386
|
+
d = sub.add_parser("set-destination")
|
|
387
|
+
d.add_argument("--award-id", required=True)
|
|
388
|
+
d.add_argument("--address", required=True, help="MAINNET payout address, correct chain")
|
|
389
|
+
d.add_argument("--confirm", action="store_true",
|
|
390
|
+
help="acknowledge the destination is permanent")
|
|
391
|
+
k = sub.add_parser("checkpoints")
|
|
392
|
+
k.add_argument("--subaccount")
|
|
393
|
+
sub.add_parser("daily")
|
|
394
|
+
sub.add_parser("selftest")
|
|
395
|
+
sub.add_parser("me")
|
|
396
|
+
sub.add_parser("balances")
|
|
397
|
+
args = p.parse_args()
|
|
398
|
+
|
|
399
|
+
if args.cmd == "selftest":
|
|
400
|
+
sys.exit(do_selftest())
|
|
401
|
+
if args.cmd == "me":
|
|
402
|
+
do_me()
|
|
403
|
+
return
|
|
404
|
+
if args.cmd == "balances":
|
|
405
|
+
do_balances()
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
token = get_token()
|
|
409
|
+
try:
|
|
410
|
+
if args.cmd == "status":
|
|
411
|
+
print_status(get_status(token))
|
|
412
|
+
elif args.cmd == "claim":
|
|
413
|
+
do_claim(token, args.subaccount)
|
|
414
|
+
elif args.cmd == "awards":
|
|
415
|
+
do_awards(token)
|
|
416
|
+
elif args.cmd == "set-destination":
|
|
417
|
+
do_set_destination(token, args.award_id, args.address, args.confirm)
|
|
418
|
+
elif args.cmd == "checkpoints":
|
|
419
|
+
do_checkpoints(token, args.subaccount)
|
|
420
|
+
elif args.cmd == "daily":
|
|
421
|
+
st = get_status(token)
|
|
422
|
+
state = find_state(st)
|
|
423
|
+
if state in (None, "AVAILABLE", "PROCESSING"):
|
|
424
|
+
do_claim(token)
|
|
425
|
+
else:
|
|
426
|
+
print(f"claim state today: {state}")
|
|
427
|
+
print()
|
|
428
|
+
do_awards(token)
|
|
429
|
+
except ApiError as e:
|
|
430
|
+
die(e.code or str(e))
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
if __name__ == "__main__":
|
|
434
|
+
main()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Rank every pair by bid-ask gap vs the 0.2% maker round-trip fee."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
|
|
7
|
+
from polyester import POLYESTER_TESTNET_ENVIRONMENT, AsyncPolyester
|
|
8
|
+
|
|
9
|
+
if __package__:
|
|
10
|
+
from .paths import env_files
|
|
11
|
+
else:
|
|
12
|
+
from paths import env_files
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_env():
|
|
16
|
+
for path in env_files():
|
|
17
|
+
try:
|
|
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
|
+
except OSError:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
load_env()
|
|
29
|
+
|
|
30
|
+
RT_FEE_PCT = Decimal("0.20")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def dec(v):
|
|
34
|
+
return v.as_decimal() if hasattr(v, "as_decimal") else Decimal(str(v))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def main():
|
|
38
|
+
c = AsyncPolyester(environment=POLYESTER_TESTNET_ENVIRONMENT,
|
|
39
|
+
api_key_id=os.environ["POLYESTER_API_KEY_ID"],
|
|
40
|
+
api_private_key=os.environ["POLYESTER_ED25519_SEED"])
|
|
41
|
+
try:
|
|
42
|
+
await c.wait_for_catalogs()
|
|
43
|
+
cfg = await c.market_data.get_spot_config()
|
|
44
|
+
pairs = [p for p in cfg.raw["pairs"]
|
|
45
|
+
if p.get("status") in ("PAIR_STATUS_ENABLED", "PAIR_STATUS_POST_ONLY")]
|
|
46
|
+
rows = []
|
|
47
|
+
for p in pairs:
|
|
48
|
+
sym = p["symbol"]
|
|
49
|
+
try:
|
|
50
|
+
book = await c.orderbook.get(symbol=sym, depth=1)
|
|
51
|
+
if not book.bids or not book.asks:
|
|
52
|
+
continue
|
|
53
|
+
bid, ask = dec(book.bids[0].price), dec(book.asks[0].price)
|
|
54
|
+
if bid <= 0 or ask <= bid:
|
|
55
|
+
continue
|
|
56
|
+
mid = (bid + ask) / 2
|
|
57
|
+
spread_pct = (ask - bid) / mid * 100
|
|
58
|
+
bid_qty, ask_qty = dec(book.bids[0].qty), dec(book.asks[0].qty)
|
|
59
|
+
rows.append((sym, bid, ask, spread_pct, spread_pct - RT_FEE_PCT,
|
|
60
|
+
bid_qty * bid, ask_qty * ask))
|
|
61
|
+
except Exception as e:
|
|
62
|
+
print("skip", sym, repr(e), flush=True)
|
|
63
|
+
|
|
64
|
+
rows.sort(key=lambda r: r[4], reverse=True)
|
|
65
|
+
print(f"\n{'pair':14} {'bid':>12} {'ask':>12} {'gap%':>7} "
|
|
66
|
+
f"{'net%':>7} {'bid$':>10} {'ask$':>10} verdict")
|
|
67
|
+
for sym, bid, ask, sp, net, bn, an in rows:
|
|
68
|
+
verdict = ("PROFIT" if net > 0 and min(bn, an) >= 50 else
|
|
69
|
+
"profit*thin" if net > 0 else "fee-loss")
|
|
70
|
+
print(f"{sym:14} {bid:>12} {ask:>12} {sp:>7.3f} "
|
|
71
|
+
f"{net:>7.3f} {float(bn):>10.1f} {float(an):>10.1f} {verdict}")
|
|
72
|
+
finally:
|
|
73
|
+
await c.aclose()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
asyncio.run(main())
|