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.
@@ -0,0 +1,3 @@
1
+ """Buy the 0xinsider Pick of the Day on your own Polymarket account."""
2
+
3
+ __version__ = "0.3.1"
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"