polyester-bot 0.0.0-stage → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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())