potd-trader 0.3.1__py3-none-any.whl
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.
- potd_trader/__init__.py +3 -0
- potd_trader/cli.py +498 -0
- potd_trader/config.py +136 -0
- potd_trader/control.py +51 -0
- potd_trader/ledger.py +162 -0
- potd_trader/oxinsider.py +255 -0
- potd_trader/polymarket.py +170 -0
- potd_trader/storage.py +44 -0
- potd_trader/trader.py +278 -0
- potd_trader/wizard.py +158 -0
- potd_trader-0.3.1.dist-info/METADATA +222 -0
- potd_trader-0.3.1.dist-info/RECORD +14 -0
- potd_trader-0.3.1.dist-info/WHEEL +4 -0
- potd_trader-0.3.1.dist-info/entry_points.txt +3 -0
potd_trader/__init__.py
ADDED
potd_trader/cli.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""`potd-trader` commands: init, status, setup, run, watch, live, ledger."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from polymarket import PolymarketError
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .config import Settings, active_env_file
|
|
21
|
+
from .control import LiveControl
|
|
22
|
+
from .ledger import Ledger, LedgerError
|
|
23
|
+
from .oxinsider import (
|
|
24
|
+
NotModified,
|
|
25
|
+
NotReleased,
|
|
26
|
+
OxinsiderClient,
|
|
27
|
+
OxinsiderError,
|
|
28
|
+
PickResult,
|
|
29
|
+
Slate,
|
|
30
|
+
TryLater,
|
|
31
|
+
)
|
|
32
|
+
from .polymarket import Account, PublicReads, geoblock
|
|
33
|
+
from .trader import Plan, execute, plan_slate
|
|
34
|
+
from .wizard import LIVE_PHRASE, collect, confirm_live, require_tty, set_live
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger("potd-trader")
|
|
37
|
+
|
|
38
|
+
MIN_SLEEP = timedelta(seconds=30)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _settings() -> Settings:
|
|
42
|
+
try:
|
|
43
|
+
return Settings.load()
|
|
44
|
+
except ValidationError as exc:
|
|
45
|
+
missing = ", ".join(
|
|
46
|
+
str(err["loc"][0]).upper() if err["loc"] else "SETTINGS" for err in exc.errors()
|
|
47
|
+
)
|
|
48
|
+
raise SystemExit(
|
|
49
|
+
f"Configuration problem: {missing}. Run `potd-trader init`, or fill in the .env file."
|
|
50
|
+
) from exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _mode_banner(settings: Settings) -> None:
|
|
54
|
+
if LiveControl(settings.control_env_path).enabled(settings.is_live):
|
|
55
|
+
log.warning(
|
|
56
|
+
"LIVE=yes: this run spends real pUSD. STAKE_USD=%s MAX_PRICE=%s DAILY_CAP_USD=%s",
|
|
57
|
+
settings.stake_usd,
|
|
58
|
+
settings.max_price,
|
|
59
|
+
settings.daily_cap_usd,
|
|
60
|
+
)
|
|
61
|
+
else:
|
|
62
|
+
log.info("DRY RUN: live control is disabled for this run. Plans are printed only.")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _fmt_time(value: datetime | None) -> str:
|
|
66
|
+
return value.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%SZ") if value else "unknown"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _print_plans(plans: list[Plan]) -> None:
|
|
70
|
+
for plan in plans:
|
|
71
|
+
pick = plan.pick
|
|
72
|
+
price = pick.odds_display or (f"{pick.backed_price:.3f}" if pick.backed_price else "?")
|
|
73
|
+
if plan.buy:
|
|
74
|
+
log.info(
|
|
75
|
+
"BUY %s | pick price %s | book %s | stake %s pUSD | max %s",
|
|
76
|
+
pick.label,
|
|
77
|
+
price,
|
|
78
|
+
plan.quote,
|
|
79
|
+
plan.stake_usd,
|
|
80
|
+
plan.max_price,
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
log.info("SKIP %s | pick price %s | %s", pick.label, price, plan.reason)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _open_account(settings: Settings) -> Account | None:
|
|
87
|
+
"""Build the account, or say why Polymarket refused the signer and wallet pair."""
|
|
88
|
+
try:
|
|
89
|
+
return Account(settings)
|
|
90
|
+
except PolymarketError as exc:
|
|
91
|
+
log.error(
|
|
92
|
+
"Polymarket refused this signer and wallet pair: %s Check "
|
|
93
|
+
"POLYMARKET_WALLET_ADDRESS (the address in your polymarket.com profile menu) and "
|
|
94
|
+
"POLYMARKET_PRIVATE_KEY (the signer that controls it).",
|
|
95
|
+
type(exc).__name__,
|
|
96
|
+
)
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _preflight(settings: Settings, total_stake: Decimal) -> Account | None:
|
|
101
|
+
"""Everything that must be true before the first live order. Returns None to abort."""
|
|
102
|
+
geo = geoblock()
|
|
103
|
+
if geo.blocked:
|
|
104
|
+
log.error(
|
|
105
|
+
"Polymarket blocks trading from this location (%s %s). Not buying.",
|
|
106
|
+
geo.country,
|
|
107
|
+
geo.region,
|
|
108
|
+
)
|
|
109
|
+
return None
|
|
110
|
+
account = _open_account(settings)
|
|
111
|
+
if account is None:
|
|
112
|
+
return None
|
|
113
|
+
try:
|
|
114
|
+
log.info("Polymarket account %s (%s)", account.wallet, account.wallet_type)
|
|
115
|
+
ready, missing = account.approvals_ready()
|
|
116
|
+
if not ready:
|
|
117
|
+
log.error(
|
|
118
|
+
"Trading approvals are not set for this wallet (%s). Run `potd-trader setup` once "
|
|
119
|
+
"with a Relayer API key, or place one trade on polymarket.com first.",
|
|
120
|
+
missing,
|
|
121
|
+
)
|
|
122
|
+
account.close()
|
|
123
|
+
return None
|
|
124
|
+
balance = account.collateral_balance_usd()
|
|
125
|
+
log.info("pUSD balance %s", balance)
|
|
126
|
+
if balance < total_stake:
|
|
127
|
+
log.error(
|
|
128
|
+
"Balance %s pUSD is below the %s pUSD this run wants to spend. Not buying.",
|
|
129
|
+
balance,
|
|
130
|
+
total_stake,
|
|
131
|
+
)
|
|
132
|
+
account.close()
|
|
133
|
+
return None
|
|
134
|
+
return account
|
|
135
|
+
except BaseException:
|
|
136
|
+
account.close()
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _handle_result(
|
|
141
|
+
result: PickResult, settings: Settings, ledger: Ledger
|
|
142
|
+
) -> tuple[list[Plan], Slate | None]:
|
|
143
|
+
if isinstance(result, NotReleased):
|
|
144
|
+
log.info("No pick released yet. Earliest change: %s", _fmt_time(result.retry_at))
|
|
145
|
+
return [], None
|
|
146
|
+
if isinstance(result, TryLater):
|
|
147
|
+
log.info(
|
|
148
|
+
"0xinsider answered %s (%s). Try again at %s",
|
|
149
|
+
result.status,
|
|
150
|
+
result.reason,
|
|
151
|
+
_fmt_time(result.retry_at),
|
|
152
|
+
)
|
|
153
|
+
return [], None
|
|
154
|
+
if isinstance(result, NotModified):
|
|
155
|
+
log.info("No change since the last read.")
|
|
156
|
+
return [], None
|
|
157
|
+
slate = result
|
|
158
|
+
log.info(
|
|
159
|
+
"Product day %s: %d released pick(s), %d scheduled",
|
|
160
|
+
slate.pick_date,
|
|
161
|
+
len(slate.picks),
|
|
162
|
+
len(slate.scheduled),
|
|
163
|
+
)
|
|
164
|
+
reads = PublicReads()
|
|
165
|
+
try:
|
|
166
|
+
plans = plan_slate(slate, settings=settings, ledger=ledger, reads=reads)
|
|
167
|
+
_print_plans(plans)
|
|
168
|
+
buys = [plan for plan in plans if plan.buy]
|
|
169
|
+
if buys and LiveControl(settings.control_env_path).enabled(settings.is_live):
|
|
170
|
+
total = sum((plan.stake_usd or Decimal("0") for plan in buys), Decimal("0"))
|
|
171
|
+
account = _preflight(settings, total)
|
|
172
|
+
if account is not None:
|
|
173
|
+
try:
|
|
174
|
+
for plan in buys:
|
|
175
|
+
execute(
|
|
176
|
+
plan, account=account, ledger=ledger, settings=settings, reads=reads
|
|
177
|
+
)
|
|
178
|
+
finally:
|
|
179
|
+
account.close()
|
|
180
|
+
elif buys:
|
|
181
|
+
log.info("%d pick(s) would be bought. Live trading is off for this run.", len(buys))
|
|
182
|
+
finally:
|
|
183
|
+
reads.close()
|
|
184
|
+
if slate.next_release_at:
|
|
185
|
+
log.info("Next scheduled release: %s", _fmt_time(slate.next_release_at))
|
|
186
|
+
return plans, slate
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cmd_run(settings: Settings) -> int:
|
|
190
|
+
_mode_banner(settings)
|
|
191
|
+
ledger = Ledger(settings.ledger_path)
|
|
192
|
+
client = OxinsiderClient(
|
|
193
|
+
settings.oxinsider_api_base, settings.oxinsider_api_key.get_secret_value()
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
_handle_result(client.pick_of_the_day(), settings, ledger)
|
|
197
|
+
finally:
|
|
198
|
+
client.close()
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _next_wake(
|
|
203
|
+
result: PickResult, plans: list[Plan], settings: Settings, now: datetime
|
|
204
|
+
) -> datetime:
|
|
205
|
+
idle = now + timedelta(minutes=settings.watch_idle_minutes)
|
|
206
|
+
candidates: list[datetime] = []
|
|
207
|
+
if isinstance(result, NotReleased | TryLater) and result.retry_at:
|
|
208
|
+
return max(result.retry_at, now + MIN_SLEEP)
|
|
209
|
+
if isinstance(result, TryLater):
|
|
210
|
+
return now + timedelta(minutes=5)
|
|
211
|
+
if isinstance(result, Slate) and result.next_release_at:
|
|
212
|
+
candidates.append(min(result.next_release_at, idle))
|
|
213
|
+
if any(plan.transient for plan in plans):
|
|
214
|
+
candidates.append(now + timedelta(minutes=5))
|
|
215
|
+
wake = min(candidates) if candidates else idle
|
|
216
|
+
return max(wake, now + MIN_SLEEP)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def cmd_watch(settings: Settings) -> int:
|
|
220
|
+
settings = settings.model_copy(
|
|
221
|
+
update={
|
|
222
|
+
"live": "yes"
|
|
223
|
+
if LiveControl(settings.control_env_path).enabled(settings.is_live)
|
|
224
|
+
else "no"
|
|
225
|
+
}
|
|
226
|
+
)
|
|
227
|
+
_mode_banner(settings)
|
|
228
|
+
log.info("Watching. Ctrl-C stops it. Nothing is polled faster than the server asks for.")
|
|
229
|
+
client = OxinsiderClient(
|
|
230
|
+
settings.oxinsider_api_base, settings.oxinsider_api_key.get_secret_value()
|
|
231
|
+
)
|
|
232
|
+
etag: str | None = None
|
|
233
|
+
last_slate: Slate | None = None
|
|
234
|
+
read_failures = 0
|
|
235
|
+
try:
|
|
236
|
+
while True:
|
|
237
|
+
ledger = Ledger(settings.ledger_path)
|
|
238
|
+
try:
|
|
239
|
+
result = client.pick_of_the_day(etag=etag)
|
|
240
|
+
read_failures = 0
|
|
241
|
+
except httpx.TransportError as exc:
|
|
242
|
+
read_failures += 1
|
|
243
|
+
delay = min(300, 30 * 2 ** min(read_failures - 1, 4))
|
|
244
|
+
log.warning(
|
|
245
|
+
"Pick read failed (%s); no orders attempted, retry in %ss",
|
|
246
|
+
type(exc).__name__,
|
|
247
|
+
delay,
|
|
248
|
+
)
|
|
249
|
+
time.sleep(delay)
|
|
250
|
+
continue
|
|
251
|
+
if isinstance(result, NotModified) and last_slate is not None:
|
|
252
|
+
# Same picks as before: re-plan them, because the book or the ledger may have moved.
|
|
253
|
+
result = last_slate
|
|
254
|
+
plans, slate = _handle_result(result, settings, ledger)
|
|
255
|
+
if slate is not None:
|
|
256
|
+
etag, last_slate = slate.etag, slate
|
|
257
|
+
elif isinstance(result, NotReleased):
|
|
258
|
+
etag, last_slate = None, None
|
|
259
|
+
now = datetime.now(UTC)
|
|
260
|
+
wake = _next_wake(result, plans, settings, now)
|
|
261
|
+
log.info("Sleeping until %s", _fmt_time(wake))
|
|
262
|
+
time.sleep((wake - now).total_seconds())
|
|
263
|
+
except KeyboardInterrupt:
|
|
264
|
+
log.info("Stopped.")
|
|
265
|
+
return 0
|
|
266
|
+
finally:
|
|
267
|
+
client.close()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def cmd_status(settings: Settings) -> int:
|
|
271
|
+
"""Region, account, approvals, balance, ledger. Non-zero when the account cannot be opened."""
|
|
272
|
+
_mode_banner(settings)
|
|
273
|
+
geo = geoblock()
|
|
274
|
+
log.info(
|
|
275
|
+
"Polymarket geoblock: %s (%s %s)",
|
|
276
|
+
"BLOCKED" if geo.blocked else "ok",
|
|
277
|
+
geo.country,
|
|
278
|
+
geo.region,
|
|
279
|
+
)
|
|
280
|
+
account_ok = True
|
|
281
|
+
if settings.has_polymarket_credentials:
|
|
282
|
+
account = _open_account(settings)
|
|
283
|
+
if account is None:
|
|
284
|
+
account_ok = False
|
|
285
|
+
else:
|
|
286
|
+
try:
|
|
287
|
+
log.info("Polymarket account %s (%s)", account.wallet, account.wallet_type)
|
|
288
|
+
ready, missing = account.approvals_ready()
|
|
289
|
+
log.info(
|
|
290
|
+
"Trading approvals: %s%s",
|
|
291
|
+
"SET" if ready else "MISSING",
|
|
292
|
+
"" if ready else f" ({missing})",
|
|
293
|
+
)
|
|
294
|
+
log.info("pUSD balance %s", account.collateral_balance_usd())
|
|
295
|
+
finally:
|
|
296
|
+
account.close()
|
|
297
|
+
else:
|
|
298
|
+
log.info("Polymarket credentials not configured; only dry runs are possible.")
|
|
299
|
+
ledger = Ledger(settings.ledger_path)
|
|
300
|
+
entries = ledger.entries()
|
|
301
|
+
log.info("Ledger %s: %d order(s)", ledger.path, len(entries))
|
|
302
|
+
log.info(
|
|
303
|
+
"UTC budget reserved: %s / %s pUSD (includes unresolved older orders)",
|
|
304
|
+
ledger.spent_today_usd(),
|
|
305
|
+
settings.daily_cap_usd,
|
|
306
|
+
)
|
|
307
|
+
unresolved = sum(entry["state"] in {"submitting", "unknown"} for _, entry in entries)
|
|
308
|
+
if unresolved:
|
|
309
|
+
log.warning(
|
|
310
|
+
"%d unresolved order(s): inspect Polymarket Activity before any ledger edit", unresolved
|
|
311
|
+
)
|
|
312
|
+
return 0 if account_ok else 1
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def cmd_setup(settings: Settings) -> int:
|
|
316
|
+
account = _open_account(settings)
|
|
317
|
+
if account is None:
|
|
318
|
+
return 1
|
|
319
|
+
try:
|
|
320
|
+
log.info("Polymarket account %s (%s)", account.wallet, account.wallet_type)
|
|
321
|
+
ready, missing = account.approvals_ready()
|
|
322
|
+
if ready:
|
|
323
|
+
log.info("Trading approvals already SET. Nothing to do.")
|
|
324
|
+
return 0
|
|
325
|
+
if not (
|
|
326
|
+
settings.polymarket_relayer_api_key and settings.polymarket_relayer_api_key_address
|
|
327
|
+
):
|
|
328
|
+
log.error(
|
|
329
|
+
"Trading approvals MISSING (%s). Create a Relayer API key at polymarket.com > "
|
|
330
|
+
"Settings > API Keys, put it in .env, and run setup again. This submits the "
|
|
331
|
+
"approval transactions gaslessly; it never places an order.",
|
|
332
|
+
missing,
|
|
333
|
+
)
|
|
334
|
+
return 1
|
|
335
|
+
log.info("Submitting trading approvals through the relayer...")
|
|
336
|
+
account.setup_approvals()
|
|
337
|
+
ready, missing = account.approvals_ready()
|
|
338
|
+
log.info(
|
|
339
|
+
"Trading approvals: %s%s",
|
|
340
|
+
"SET" if ready else "MISSING",
|
|
341
|
+
"" if ready else f" ({missing})",
|
|
342
|
+
)
|
|
343
|
+
return 0 if ready else 1
|
|
344
|
+
finally:
|
|
345
|
+
account.close()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def cmd_init(directory: Path) -> int:
|
|
349
|
+
"""From nothing to a checked dry run in one terminal session, in a new folder."""
|
|
350
|
+
folder = collect(directory)
|
|
351
|
+
os.chdir(folder)
|
|
352
|
+
settings = _settings().model_copy(update={"live": "no"})
|
|
353
|
+
account_ok = cmd_status(settings) == 0
|
|
354
|
+
log.info("")
|
|
355
|
+
cmd_run(settings)
|
|
356
|
+
log.info("")
|
|
357
|
+
if not account_ok:
|
|
358
|
+
log.info(
|
|
359
|
+
"Fix the Polymarket values in %s, then run `potd-trader status` there.", folder / ".env"
|
|
360
|
+
)
|
|
361
|
+
return 1
|
|
362
|
+
if confirm_live():
|
|
363
|
+
set_live(folder / ".env", True)
|
|
364
|
+
log.warning("LIVE=yes written. The next run or watch spends real pUSD.")
|
|
365
|
+
else:
|
|
366
|
+
log.info("Still a dry run. `potd-trader live on` flips it later.")
|
|
367
|
+
log.info("Next, from inside the folder:")
|
|
368
|
+
log.info(" cd %s", folder)
|
|
369
|
+
log.info(" potd-trader watch")
|
|
370
|
+
return 0
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def cmd_live(state: str) -> int:
|
|
374
|
+
path = active_env_file()
|
|
375
|
+
if not path.exists():
|
|
376
|
+
raise SystemExit(
|
|
377
|
+
"no .env in this directory. cd into the folder `potd-trader init` created, then rerun."
|
|
378
|
+
)
|
|
379
|
+
control = LiveControl(path)
|
|
380
|
+
if state == "status":
|
|
381
|
+
log.info(
|
|
382
|
+
"Live control %s: %s; stop flag %s",
|
|
383
|
+
path.resolve(),
|
|
384
|
+
"enabled" if control.enabled(True) else "disabled",
|
|
385
|
+
control.halt_path,
|
|
386
|
+
)
|
|
387
|
+
return 0
|
|
388
|
+
if state == "off":
|
|
389
|
+
set_live(path, False)
|
|
390
|
+
log.info(
|
|
391
|
+
"Trading stopped for %s, including running watchers using this folder. "
|
|
392
|
+
"Earlier submitted orders are not cancelled.",
|
|
393
|
+
path.resolve(),
|
|
394
|
+
)
|
|
395
|
+
return 0
|
|
396
|
+
require_tty()
|
|
397
|
+
if not confirm_live():
|
|
398
|
+
log.info("Live control unchanged: %s.", path)
|
|
399
|
+
return 1
|
|
400
|
+
set_live(path, True)
|
|
401
|
+
log.warning("LIVE=yes written to %s. The next run or watch spends real pUSD.", path)
|
|
402
|
+
return 0
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def cmd_ledger(settings: Settings) -> int:
|
|
406
|
+
ledger = Ledger(settings.ledger_path)
|
|
407
|
+
entries = ledger.entries()
|
|
408
|
+
if not entries:
|
|
409
|
+
log.info("Ledger %s is empty.", settings.ledger_path)
|
|
410
|
+
return 0
|
|
411
|
+
for key, entry in entries:
|
|
412
|
+
log.info(
|
|
413
|
+
"%-10s %s | %s pUSD | order %s | %s",
|
|
414
|
+
entry.get("state"),
|
|
415
|
+
entry.get("label", key),
|
|
416
|
+
entry.get("stake_usd"),
|
|
417
|
+
entry.get("order_id", "-"),
|
|
418
|
+
entry.get("message") or entry.get("error") or entry.get("status") or "",
|
|
419
|
+
)
|
|
420
|
+
return 0
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def main(argv: list[str] | None = None) -> int:
|
|
424
|
+
parser = argparse.ArgumentParser(
|
|
425
|
+
prog="potd-trader",
|
|
426
|
+
description="Buy the 0xinsider Pick of the Day on your own Polymarket account.",
|
|
427
|
+
)
|
|
428
|
+
parser.add_argument("--version", action="version", version=f"potd-trader {__version__}")
|
|
429
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="debug logging")
|
|
430
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
431
|
+
init = sub.add_parser(
|
|
432
|
+
"init", help="create a folder, ask 4 questions, then run status and a dry run"
|
|
433
|
+
)
|
|
434
|
+
init.add_argument(
|
|
435
|
+
"directory",
|
|
436
|
+
nargs="?",
|
|
437
|
+
default="potd-trader",
|
|
438
|
+
type=Path,
|
|
439
|
+
help="the new folder to create (default: ./potd-trader); it must not exist yet",
|
|
440
|
+
)
|
|
441
|
+
sub.add_parser("status", help="check region, account, approvals, balance and the ledger")
|
|
442
|
+
sub.add_parser("setup", help="set trading approvals once (needs a Relayer API key)")
|
|
443
|
+
run = sub.add_parser("run", help="read today's picks and buy each one at most once, then exit")
|
|
444
|
+
watch = sub.add_parser("watch", help="wake for each release and buy as picks appear")
|
|
445
|
+
for command in (run, watch):
|
|
446
|
+
command.add_argument(
|
|
447
|
+
"--dry-run", action="store_true", help="force no orders, even if LIVE=yes"
|
|
448
|
+
)
|
|
449
|
+
live = sub.add_parser("live", help=f'turn real orders on (type "{LIVE_PHRASE}") or off')
|
|
450
|
+
live.add_argument("state", choices=["on", "off", "status"])
|
|
451
|
+
sub.add_parser("ledger", help="print every order this tool has placed")
|
|
452
|
+
args = parser.parse_args(argv)
|
|
453
|
+
signal.signal(signal.SIGTERM, _terminate)
|
|
454
|
+
|
|
455
|
+
logging.basicConfig(
|
|
456
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
457
|
+
format="%(asctime)s %(levelname)-7s %(message)s",
|
|
458
|
+
datefmt="%H:%M:%S",
|
|
459
|
+
stream=sys.stdout,
|
|
460
|
+
)
|
|
461
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
462
|
+
|
|
463
|
+
commands = {
|
|
464
|
+
"status": cmd_status,
|
|
465
|
+
"setup": cmd_setup,
|
|
466
|
+
"run": cmd_run,
|
|
467
|
+
"watch": cmd_watch,
|
|
468
|
+
"ledger": cmd_ledger,
|
|
469
|
+
}
|
|
470
|
+
try:
|
|
471
|
+
if args.command == "init":
|
|
472
|
+
return cmd_init(args.directory)
|
|
473
|
+
if args.command == "live":
|
|
474
|
+
return cmd_live(args.state)
|
|
475
|
+
settings = _settings()
|
|
476
|
+
if getattr(args, "dry_run", False):
|
|
477
|
+
settings = settings.model_copy(update={"live": "no"})
|
|
478
|
+
return commands[args.command](settings)
|
|
479
|
+
except KeyboardInterrupt:
|
|
480
|
+
log.info("Stopped; any interrupted submission remains reserved in the ledger.")
|
|
481
|
+
return 130
|
|
482
|
+
except (OxinsiderError, LedgerError) as exc:
|
|
483
|
+
log.error("%s", exc)
|
|
484
|
+
return 1
|
|
485
|
+
except (httpx.HTTPError, PolymarketError, OSError, ValueError) as exc:
|
|
486
|
+
log.error(
|
|
487
|
+
"Command stopped (%s). Check configuration/network and ledger before retrying.",
|
|
488
|
+
type(exc).__name__,
|
|
489
|
+
)
|
|
490
|
+
return 1
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _terminate(signum: int, frame: object) -> None:
|
|
494
|
+
raise KeyboardInterrupt
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
if __name__ == "__main__":
|
|
498
|
+
sys.exit(main())
|
potd_trader/config.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Settings, read from the environment and an `.env` file.
|
|
2
|
+
|
|
3
|
+
The API key and the wallet signing key have separate destinations:
|
|
4
|
+
|
|
5
|
+
- `OXINSIDER_API_KEY` is sent to api.0xinsider.com, and nowhere else, to read the pick.
|
|
6
|
+
- `POLYMARKET_PRIVATE_KEY` signs Polymarket orders inside this process. It is never sent
|
|
7
|
+
anywhere. 0xinsider has no endpoint that accepts a wallet key.
|
|
8
|
+
|
|
9
|
+
`potd-trader init` creates a folder (default `./potd-trader`) holding `.env` and, once a live
|
|
10
|
+
order exists, `ledger.json`. Every command reads the `.env` in the current directory, so you run
|
|
11
|
+
them from inside that folder. `~/.potd-trader/.env` (override the directory with
|
|
12
|
+
`POTD_TRADER_HOME`) is a fallback for a machine-wide setup and is never written by `init`.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from decimal import Decimal
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from pydantic import Field, SecretStr, field_validator, model_validator
|
|
22
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
23
|
+
|
|
24
|
+
# The most picks one product day carries; ranks run 1 to this. 0xinsider raised it from 6
|
|
25
|
+
# to 10 on 2026-09-24, and a slate with rank 7 or higher failed validation whole, so no
|
|
26
|
+
# pick traded on those days.
|
|
27
|
+
MAX_DAILY_PICKS = 10
|
|
28
|
+
|
|
29
|
+
API_ORIGIN = "https://api.0xinsider.com"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_api_origin(value: str) -> str:
|
|
33
|
+
if value not in (API_ORIGIN, API_ORIGIN + "/"):
|
|
34
|
+
raise ValueError("OXINSIDER_API_BASE must be https://api.0xinsider.com")
|
|
35
|
+
return API_ORIGIN
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def home_dir() -> Path:
|
|
39
|
+
return Path(os.environ.get("POTD_TRADER_HOME", "~/.potd-trader")).expanduser()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def env_files() -> tuple[Path, Path]:
|
|
43
|
+
"""Candidate files: the home fallback and a `.env` beside the caller."""
|
|
44
|
+
return home_dir() / ".env", Path(".env")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def active_env_file() -> Path:
|
|
48
|
+
"""The file in use: the one in the current directory when it exists, else the home one."""
|
|
49
|
+
home, local = env_files()
|
|
50
|
+
return local if local.exists() else home
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_ledger_path() -> Path:
|
|
54
|
+
"""The ledger sits beside the `.env` in use."""
|
|
55
|
+
return active_env_file().parent / "ledger.json"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Settings(BaseSettings):
|
|
59
|
+
model_config = SettingsConfigDict(
|
|
60
|
+
env_file_encoding="utf-8", extra="ignore", allow_inf_nan=False
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# 0xinsider (read-only: the pick).
|
|
64
|
+
oxinsider_api_key: SecretStr = Field(description="Pro API key, oxi_sk_live_...")
|
|
65
|
+
oxinsider_api_base: str = "https://api.0xinsider.com"
|
|
66
|
+
|
|
67
|
+
# Polymarket (the account that buys).
|
|
68
|
+
polymarket_private_key: SecretStr | None = None
|
|
69
|
+
polymarket_wallet_address: str | None = None
|
|
70
|
+
polymarket_relayer_api_key: SecretStr | None = None
|
|
71
|
+
polymarket_relayer_api_key_address: str | None = None
|
|
72
|
+
polymarket_builder_code: str | None = None
|
|
73
|
+
|
|
74
|
+
# The one switch that spends money. Anything but the exact string "yes" is a dry run.
|
|
75
|
+
live: str = "no"
|
|
76
|
+
|
|
77
|
+
# Sizing and guards.
|
|
78
|
+
stake_usd: Decimal = Decimal("5")
|
|
79
|
+
max_price: Decimal = Decimal("0.925")
|
|
80
|
+
max_slippage_pct: Decimal = Field(default=Decimal("3"), ge=0)
|
|
81
|
+
daily_cap_usd: Decimal = Field(default=Decimal("25"), gt=0)
|
|
82
|
+
kickoff_buffer_minutes: int = Field(default=5, ge=0)
|
|
83
|
+
min_ranks: int = Field(default=1, ge=1, le=MAX_DAILY_PICKS)
|
|
84
|
+
# Defaults to the top 6 of up to 10, as before the day grew; MAX_RANKS=10 buys every pick.
|
|
85
|
+
max_ranks: int = Field(default=6, ge=1, le=MAX_DAILY_PICKS)
|
|
86
|
+
|
|
87
|
+
# Files and cadence.
|
|
88
|
+
ledger_path: Path = Field(default_factory=default_ledger_path)
|
|
89
|
+
watch_idle_minutes: int = Field(default=30, ge=1, le=1440)
|
|
90
|
+
control_env_path: Path = Field(default_factory=lambda: active_env_file().resolve())
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def load(cls) -> Settings:
|
|
94
|
+
path = active_env_file().resolve()
|
|
95
|
+
return cls(_env_file=str(path), control_env_path=path)
|
|
96
|
+
|
|
97
|
+
@field_validator("oxinsider_api_base")
|
|
98
|
+
@classmethod
|
|
99
|
+
def _api_origin(cls, value: str) -> str:
|
|
100
|
+
return validate_api_origin(value)
|
|
101
|
+
|
|
102
|
+
@model_validator(mode="after")
|
|
103
|
+
def _rank_order(self) -> Settings:
|
|
104
|
+
if self.min_ranks > self.max_ranks:
|
|
105
|
+
raise ValueError("MIN_RANKS must not exceed MAX_RANKS")
|
|
106
|
+
return self
|
|
107
|
+
|
|
108
|
+
@field_validator("stake_usd")
|
|
109
|
+
@classmethod
|
|
110
|
+
def _non_negative(cls, value: Decimal) -> Decimal:
|
|
111
|
+
if value < 0:
|
|
112
|
+
raise ValueError("must be zero or more")
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
@field_validator("max_price")
|
|
116
|
+
@classmethod
|
|
117
|
+
def _price_in_range(cls, value: Decimal) -> Decimal:
|
|
118
|
+
if not Decimal("0") < value < Decimal("1"):
|
|
119
|
+
raise ValueError("MAX_PRICE must be between 0 and 1 (a probability)")
|
|
120
|
+
return value
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def is_live(self) -> bool:
|
|
124
|
+
return self.live == "yes"
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def has_polymarket_credentials(self) -> bool:
|
|
128
|
+
return self.polymarket_private_key is not None and bool(self.polymarket_wallet_address)
|
|
129
|
+
|
|
130
|
+
def require_polymarket_credentials(self) -> None:
|
|
131
|
+
if not self.has_polymarket_credentials:
|
|
132
|
+
raise SystemExit(
|
|
133
|
+
"POLYMARKET_PRIVATE_KEY and POLYMARKET_WALLET_ADDRESS are both required for "
|
|
134
|
+
"this command. Run `potd-trader init`, or put them in the .env file, and run "
|
|
135
|
+
"commands from inside the folder that holds it."
|
|
136
|
+
)
|
potd_trader/control.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Authoritative stop and submission barrier, shared by CLI and running watchers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from dotenv import dotenv_values
|
|
10
|
+
|
|
11
|
+
from .storage import atomic_write, file_lock
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LiveControl:
|
|
15
|
+
def __init__(self, env_path: Path) -> None:
|
|
16
|
+
self.env_path = env_path.resolve()
|
|
17
|
+
self.halt_path = self.env_path.parent / "HALT"
|
|
18
|
+
self.lock_path = self.env_path.parent / ".live.lock"
|
|
19
|
+
|
|
20
|
+
def enabled(self, started_live: bool) -> bool:
|
|
21
|
+
if not started_live or self.halt_path.exists():
|
|
22
|
+
return False
|
|
23
|
+
# A file's explicit no beats inherited LIVE=yes. Deletion or a missing LIVE field
|
|
24
|
+
# also fails closed; environment-only deployments must create this small control file.
|
|
25
|
+
if not self.env_path.is_file():
|
|
26
|
+
return False
|
|
27
|
+
return dotenv_values(self.env_path, interpolate=False).get("LIVE") == "yes"
|
|
28
|
+
|
|
29
|
+
@contextmanager
|
|
30
|
+
def submission(self, started_live: bool) -> Iterator[bool]:
|
|
31
|
+
# Held through the exchange call: live off cannot report success while a prior
|
|
32
|
+
# submission is still in flight. A blocked network call can delay that acknowledgment.
|
|
33
|
+
with file_lock(self.lock_path):
|
|
34
|
+
yield self.enabled(started_live)
|
|
35
|
+
|
|
36
|
+
def set_live(self, live: bool) -> None:
|
|
37
|
+
if not live:
|
|
38
|
+
# Signal before waiting so another watcher cannot begin while off awaits the lock.
|
|
39
|
+
atomic_write(self.halt_path, "Stopped by potd-trader live off\n")
|
|
40
|
+
with file_lock(self.lock_path):
|
|
41
|
+
lines = self.env_path.read_text(encoding="utf-8").splitlines()
|
|
42
|
+
kept = [line for line in lines if not _live_assignment(line)]
|
|
43
|
+
kept.append(f"LIVE={'yes' if live else 'no'}")
|
|
44
|
+
atomic_write(self.env_path, "\n".join(kept) + "\n")
|
|
45
|
+
if live:
|
|
46
|
+
self.halt_path.unlink(missing_ok=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _live_assignment(line: str) -> bool:
|
|
50
|
+
left = line.split("=", 1)[0].strip().removeprefix("export ").strip()
|
|
51
|
+
return left == "LIVE"
|