delphi-kit 0.1.0__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.
delphi_kit/cli.py ADDED
@@ -0,0 +1,191 @@
1
+ """Command line: `delphi markets`, `delphi search`, `delphi market`, `delphi quote`, `delphi buy`, `delphi sell`,
2
+ `delphi positions`, `delphi collect`, `delphi balance`, `delphi watch`."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+
10
+ from .client import Delphi, DelphiError
11
+
12
+
13
+ def _out(obj, as_json: bool) -> None:
14
+ if as_json:
15
+ print(json.dumps(obj, indent=2, default=str))
16
+
17
+
18
+ def _market_line(m) -> str:
19
+ odds = " ".join(f"{o} {p:.0%}" for o, p in zip(m.outcomes, m.probabilities))
20
+ close = m.trading_closes_at.strftime("%b %d %H:%M") if m.trading_closes_at else "?"
21
+ return f"{m.address} {close:>12} ${m.pool_usd or 0:>7.2f} {odds}\n {m.question}"
22
+
23
+
24
+ def main(argv: list[str] | None = None) -> int:
25
+ p = argparse.ArgumentParser(prog="delphi", description="Read and trade Delphi prediction markets. No API key needed.")
26
+ p.add_argument("--network", default=None, help="mainnet (default), testnet or competition-testnet")
27
+ p.add_argument("--json", action="store_true", help="print JSON")
28
+ sub = p.add_subparsers(dest="cmd", required=True)
29
+
30
+ s = sub.add_parser("markets", help="list markets with live odds")
31
+ s.add_argument("--status", default="open", help="open | awaiting_settlement | settled | expired | failed | all")
32
+ s.add_argument("--limit", type=int, default=25)
33
+
34
+ s = sub.add_parser("search", help="find markets by keywords")
35
+ s.add_argument("query", nargs="+")
36
+ s.add_argument("--status", default="open")
37
+ s.add_argument("--limit", type=int, default=10)
38
+
39
+ s = sub.add_parser("market", help="one market in full, with its rules and sources")
40
+ s.add_argument("address")
41
+
42
+ s = sub.add_parser("quote", help="price a buy or sell without sending anything")
43
+ s.add_argument("address")
44
+ s.add_argument("outcome", help='label ("Yes") or index')
45
+ g = s.add_mutually_exclusive_group(required=True)
46
+ g.add_argument("--usd", type=float, help="buy with this budget")
47
+ g.add_argument("--shares", type=float, help="buy this many shares")
48
+ g.add_argument("--sell", type=float, metavar="SHARES", help="sell this many shares")
49
+
50
+ s = sub.add_parser("buy", help="buy shares (needs DELPHI_PRIVATE_KEY)")
51
+ s.add_argument("address")
52
+ s.add_argument("outcome")
53
+ s.add_argument("--usd", type=float, required=True)
54
+ s.add_argument("--max-slippage", type=float, default=0.02)
55
+ s.add_argument("--yes", action="store_true", help="skip the confirmation prompt")
56
+
57
+ s = sub.add_parser("sell", help="sell shares (needs DELPHI_PRIVATE_KEY)")
58
+ s.add_argument("address")
59
+ s.add_argument("outcome")
60
+ s.add_argument("--shares", type=float, default=None, help="default: all you hold")
61
+ s.add_argument("--max-slippage", type=float, default=0.02)
62
+ s.add_argument("--yes", action="store_true")
63
+
64
+ s = sub.add_parser("positions", help="what a wallet holds and can collect")
65
+ s.add_argument("wallet", nargs="?")
66
+
67
+ s = sub.add_parser("collect", help="redeem a settled market or liquidate an expired/failed one")
68
+ s.add_argument("address")
69
+
70
+ sub.add_parser("balance", help="your wallet's USDC and gas")
71
+
72
+ s = sub.add_parser("watch", help="alerts: new markets, big moves, closing soon, settlements, money to collect")
73
+ s.add_argument("--every", type=float, default=60, help="seconds between checks")
74
+ s.add_argument("--move", type=float, default=0.10, help="alert on probability moves of this size (0.10 = 10 points)")
75
+ s.add_argument("--closing-hours", type=float, default=6)
76
+ s.add_argument("--query", default="", help="only markets matching these words")
77
+ s.add_argument("--market", action="append", default=[], help="only this market (repeatable)")
78
+ s.add_argument("--wallet", default=None, help="also alert when this wallet can collect money")
79
+ s.add_argument("--only", default="", help="comma list of alert kinds")
80
+ s.add_argument("--webhook", default=None, help="POST each alert as JSON to this URL")
81
+ s.add_argument("--telegram", action="store_true", help="send to TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID")
82
+ s.add_argument("--once", action="store_true", help="check once and exit")
83
+
84
+ a = p.parse_args(argv)
85
+ import os
86
+ d = Delphi(a.network or os.environ.get("DELPHI_NETWORK", "mainnet"))
87
+ try:
88
+ return _run(d, a)
89
+ except (DelphiError, ValueError) as e:
90
+ print(f"error: {e}", file=sys.stderr)
91
+ return 1
92
+
93
+
94
+ def _confirm(text: str, yes: bool) -> bool:
95
+ if yes:
96
+ return True
97
+ return input(f"{text} Type yes to send: ").strip().lower() == "yes"
98
+
99
+
100
+ def _run(d: Delphi, a) -> int:
101
+ if a.cmd in ("markets", "search"):
102
+ st = None if a.status == "all" else a.status
103
+ ms = d.search(" ".join(a.query), status=st, limit=a.limit) if a.cmd == "search" else d.markets(status=st, limit=a.limit)
104
+ if a.json:
105
+ _out([{k: v for k, v in m.to_dict().items() if k != "raw_metadata"} for m in ms], True)
106
+ else:
107
+ for m in ms:
108
+ print(_market_line(m))
109
+ print(f"\n{len(ms)} market(s)")
110
+ return 0
111
+ if a.cmd == "market":
112
+ m = d.market(a.address)
113
+ if a.json:
114
+ _out({k: v for k, v in m.to_dict().items() if k != "raw_metadata"}, True)
115
+ return 0
116
+ print(m.question, f"\n status: {m.status} type: {m.deployment} pool: ${m.pool_usd:.2f} fee: {m.trading_fee:.1%}")
117
+ for o, pr in zip(m.outcomes, m.probabilities):
118
+ print(f" {o:<20} {pr:6.1%}")
119
+ print(f" trading closes: {m.trading_closes_at} settle by: {m.settlement_deadline}")
120
+ if m.rules:
121
+ print(f"\nRules the judge applies:\n {m.rules}")
122
+ if m.sources:
123
+ print("Sources:\n " + "\n ".join(m.sources))
124
+ print(f"\nRules file matches on-chain hash: {m.metadata_verified} judge: {m.judge_model}")
125
+ print(m.url)
126
+ return 0
127
+ if a.cmd == "quote":
128
+ q = d.quote_sell(a.address, a.outcome, a.sell) if a.sell is not None else d.quote_buy(a.address, a.outcome, usd=a.usd, shares=a.shares)
129
+ if a.json:
130
+ _out(q.to_dict(), True)
131
+ else:
132
+ after = f" → {q.probability_after:.1%}" if q.probability_after is not None else ""
133
+ verb = "costs" if q.side == "buy" else "returns"
134
+ print(f"{q.side} {q.shares:.4f} {q.outcome_label} shares {verb} ${q.usd:.4f} "
135
+ f"(avg {q.average_price:.4f}); probability {q.probability_before:.1%}{after}")
136
+ return 0
137
+ if a.cmd == "buy":
138
+ q = d.quote_buy(a.address, a.outcome, usd=a.usd)
139
+ if not _confirm(f"Buy {q.shares:.4f} {q.outcome_label} for ${q.usd:.2f} (max +{a.max_slippage:.0%})?", a.yes):
140
+ return 1
141
+ t = d.buy(a.address, a.outcome, usd=a.usd, max_slippage=a.max_slippage)
142
+ _out(t.to_dict(), a.json) if a.json else print(f"bought {t.shares:.4f} {t.outcome_label} for ${t.usd:.4f}\n{t.explorer_url}")
143
+ return 0
144
+ if a.cmd == "sell":
145
+ if not _confirm(f"Sell {'all' if a.shares is None else a.shares} {a.outcome} shares?", a.yes):
146
+ return 1
147
+ t = d.sell(a.address, a.outcome, a.shares, max_slippage=a.max_slippage)
148
+ _out(t.to_dict(), a.json) if a.json else print(f"sold {t.shares:.4f} {t.outcome_label} for ${t.usd:.4f}\n{t.explorer_url}")
149
+ return 0
150
+ if a.cmd == "positions":
151
+ ps = d.positions(a.wallet)
152
+ if a.json:
153
+ _out([x.to_dict() for x in ps], True)
154
+ else:
155
+ for x in ps:
156
+ v = f"${x.value_usd:.2f}" if x.value_usd is not None else "–"
157
+ act = f" → {x.action}" if x.action else ""
158
+ print(f"{x.shares:10.4f} {x.outcome_label or x.outcome!s:<12} {v:>9} {x.status:<19}{act}\n {x.question} {x.market}")
159
+ print(f"\n{len(ps)} position(s)")
160
+ return 0
161
+ if a.cmd == "collect":
162
+ m = d.market(a.address, with_rules=False)
163
+ r = d.redeem(a.address) if m.status == "settled" else d.liquidate(a.address) if m.status in ("expired", "failed") else None
164
+ if r is None:
165
+ raise DelphiError(f"market is {m.status}; nothing to collect yet")
166
+ _out(r, True) if a.json else print(f"collected ${r['usd']:.4f}\n{r['explorer_url']}")
167
+ return 0
168
+ if a.cmd == "balance":
169
+ b = d.balance()
170
+ _out(b, True) if a.json else print(f"{b['wallet']}: ${b['usd']:.2f} USDC, {b['eth']:.6f} ETH for gas")
171
+ return 0
172
+ if a.cmd == "watch":
173
+ from .watch import WatchConfig, Watcher, print_sink, telegram_sink, webhook_sink
174
+ cfg = WatchConfig(move=a.move, closing_hours=a.closing_hours, query=a.query,
175
+ markets=frozenset(x.lower() for x in a.market), wallet=a.wallet,
176
+ kinds=frozenset(k.strip() for k in a.only.split(",") if k.strip()))
177
+ sinks = [print_sink(a.json)]
178
+ if a.webhook:
179
+ sinks.append(webhook_sink(a.webhook))
180
+ if a.telegram:
181
+ sinks.append(telegram_sink())
182
+ w = Watcher(d, cfg)
183
+ if not w.state:
184
+ print("first run: recording a baseline; alerts start from the next check", file=sys.stderr)
185
+ w.run(a.every, sinks, once=a.once)
186
+ return 0
187
+ return 1
188
+
189
+
190
+ if __name__ == "__main__":
191
+ sys.exit(main())
delphi_kit/client.py ADDED
@@ -0,0 +1,483 @@
1
+ """The Delphi client: read every market straight from the chain, quote, trade, and track positions.
2
+
3
+ No API key is needed. Every number comes from the Gensyn chain through a public RPC, and each market's rules
4
+ file is checked against the hash stored on chain.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import math
12
+ import os
13
+ import time
14
+ from concurrent.futures import ThreadPoolExecutor
15
+ from pathlib import Path
16
+ from typing import Iterable, Optional
17
+
18
+ import httpx
19
+ from eth_account import Account
20
+ from web3 import Web3
21
+
22
+ from .chain import ERC20_ABI, Call, Reader, abi, checksum, connect
23
+ from .models import STATUSES, Market, Position, Quote, Trade, ts
24
+ from .networks import Deployment, Network, get_network
25
+
26
+ SHARE = 10**18
27
+ MAX_UINT = 2**256 - 1
28
+
29
+
30
+ class DelphiError(Exception):
31
+ pass
32
+
33
+
34
+ class InsufficientFunds(DelphiError):
35
+ pass
36
+
37
+
38
+ def _cache_dir() -> Path:
39
+ base = os.environ.get("DELPHI_KIT_CACHE") or os.path.join(os.path.expanduser("~"), ".cache", "delphi-kit")
40
+ p = Path(base)
41
+ p.mkdir(parents=True, exist_ok=True)
42
+ return p
43
+
44
+
45
+ class Delphi:
46
+ """Read and trade Delphi prediction markets.
47
+
48
+ >>> d = Delphi() # mainnet, read-only
49
+ >>> for m in d.markets(status="open")[:5]: print(m.summary())
50
+ >>> d.quote_buy(m.address, "Yes", usd=5)
51
+
52
+ Pass ``private_key`` (or set ``DELPHI_PRIVATE_KEY``) to trade.
53
+ """
54
+
55
+ def __init__(self, network: str | Network = "mainnet", *, private_key: str | None = None,
56
+ rpc_url: str | None = None, cache: bool = True):
57
+ self.network = network if isinstance(network, Network) else get_network(network)
58
+ self.w3 = connect(self.network, rpc_url or os.environ.get("DELPHI_RPC_URL"))
59
+ self.reader = Reader(self.w3, self.network)
60
+ key = private_key or os.environ.get("DELPHI_PRIVATE_KEY")
61
+ self.account = Account.from_key(key) if key else None
62
+ self.cache = cache
63
+ self.token = self.w3.eth.contract(address=checksum(self.network.token), abi=ERC20_ABI)
64
+ self._dep = {d.name: d for d in self.network.deployments}
65
+ self._gw = {d.name: self.w3.eth.contract(address=checksum(d.gateway), abi=abi(d.kind)) for d in self.network.deployments}
66
+ self._fac = {d.name: self.w3.eth.contract(address=checksum(d.factory), abi=abi("factory")) for d in self.network.deployments}
67
+ self._where: dict[str, str] = {} # market (lowercase) -> deployment name
68
+ self._addresses: dict[str, list[str]] = {} # deployment name -> market addresses, oldest first
69
+ self._meta: dict[str, tuple[Optional[dict], Optional[bool]]] = {}
70
+ self._http = httpx.Client(timeout=20, follow_redirects=True, headers={"User-Agent": "delphi-kit"})
71
+
72
+ # ------------------------------------------------------------------ wallet
73
+
74
+ @property
75
+ def address(self) -> Optional[str]:
76
+ return self.account.address if self.account else None
77
+
78
+ def balance(self, wallet: str | None = None) -> dict:
79
+ """USDC and gas (ETH) held by a wallet."""
80
+ w = checksum(wallet or self._need_account().address)
81
+ return {
82
+ "wallet": w,
83
+ "usd": self.token.functions.balanceOf(w).call() / 10**self.network.token_decimals,
84
+ "eth": self.w3.eth.get_balance(w) / 1e18,
85
+ }
86
+
87
+ # ---------------------------------------------------------------- routing
88
+
89
+ def _deployment(self, market: str) -> Deployment:
90
+ key = market.lower()
91
+ if key not in self._where:
92
+ calls = [Call(self._fac[n], "marketProxyExists", [checksum(market)]) for n in self._dep]
93
+ for name, found in zip(self._dep, self.reader.run(calls)):
94
+ if found is True:
95
+ self._where[key] = name
96
+ break
97
+ else:
98
+ raise DelphiError(f"{market} is not a Delphi market on {self.network.name}")
99
+ return self._dep[self._where[key]]
100
+
101
+ def gateway_for(self, market: str) -> str:
102
+ """The gateway contract every call about this market must go through."""
103
+ return self._deployment(market).gateway
104
+
105
+ # ---------------------------------------------------------------- reading
106
+
107
+ def market_addresses(self, deployment: str | None = None) -> list[str]:
108
+ """Every market ever created, newest first, straight from the factories."""
109
+ names = [deployment] if deployment else list(self._dep)
110
+ totals = self.reader.run([Call(self._fac[n], "getTotalMarketProxiesCount") for n in names])
111
+ out: list[str] = []
112
+ for name, total in zip(names, totals):
113
+ if isinstance(total, Exception):
114
+ continue
115
+ have = self._addresses.setdefault(name, [])
116
+ if len(have) < total:
117
+ calls = [Call(self._fac[name], "getMarketProxies", [i, min(i + 499, total - 1)])
118
+ for i in range(len(have), total, 500)]
119
+ for chunk in self.reader.run(calls):
120
+ if not isinstance(chunk, Exception):
121
+ have.extend(checksum(a) for a in chunk)
122
+ for a in have:
123
+ self._where[a.lower()] = name
124
+ out.extend(have)
125
+ return list(reversed(out))
126
+
127
+ def markets(self, status: str | Iterable[str] | None = "open", *, deployment: str | None = None,
128
+ limit: int | None = None, with_rules: bool = True) -> list[Market]:
129
+ """Markets with live odds. ``status`` is one of open, awaiting_settlement, settled, expired, failed,
130
+ a list of them, or None for all. Open markets are sorted by closing time, soonest first."""
131
+ wanted = {status} if isinstance(status, str) else (set(status) if status else None)
132
+ if wanted and not wanted <= set(STATUSES):
133
+ raise ValueError(f"status must be among {STATUSES}")
134
+ addrs = self.market_addresses(deployment)
135
+ status_calls = [Call(self._gw[self._where[a.lower()]], "marketStatus", [a]) for a in addrs]
136
+ statuses = self.reader.run(status_calls)
137
+ chosen = [a for a, s in zip(addrs, statuses)
138
+ if not isinstance(s, Exception) and (wanted is None or STATUSES[s] in wanted)]
139
+ if limit and not (wanted == {"open"}):
140
+ chosen = chosen[:limit]
141
+ found = self._load(chosen, with_rules=with_rules)
142
+ if wanted == {"open"}:
143
+ found.sort(key=lambda m: m.trading_closes_at.timestamp() if m.trading_closes_at else math.inf)
144
+ return found[:limit] if limit else found
145
+
146
+ def market(self, address: str, *, with_rules: bool = True) -> Market:
147
+ self._deployment(address)
148
+ return self._load([checksum(address)], with_rules=with_rules)[0]
149
+
150
+ def _load(self, addrs: list[str], with_rules: bool) -> list[Market]:
151
+ if not addrs:
152
+ return []
153
+ calls: list[Call] = []
154
+ for a in addrs:
155
+ g = self._gw[self._deployment(a).name]
156
+ calls += [Call(g, "getMarket", [a]), Call(g, "marketStatus", [a]), Call(g, "marketMetadata", [a])]
157
+ base = self.reader.run(calls)
158
+ rows = [base[i:i + 3] for i in range(0, len(base), 3)]
159
+ for r in rows: # marketMetadata decodes to {"uri", "uriContentHash"}; keep it as a pair
160
+ if isinstance(r[2], dict):
161
+ r[2] = (r[2]["uri"], r[2]["uriContentHash"])
162
+ n_out = [int(r[0]["config"]["outcomeCount"]) if isinstance(r[0], dict) else 0 for r in rows]
163
+ price_calls: list[Call] = []
164
+ for a, n in zip(addrs, n_out):
165
+ g = self._gw[self._deployment(a).name]
166
+ price_calls += [Call(g, "spotImpliedProbabilities", [a, list(range(n))]), Call(g, "spotPrices", [a, list(range(n))])]
167
+ prices = self.reader.run(price_calls)
168
+ metas = self._metadata([r[2] if not isinstance(r[2], Exception) else None for r in rows]) if with_rules else [(None, None)] * len(rows)
169
+ out: list[Market] = []
170
+ for i, (a, r) in enumerate(zip(addrs, rows)):
171
+ dep = self._deployment(a)
172
+ info, st, md = r
173
+ if isinstance(info, Exception):
174
+ continue
175
+ cfg = info["config"]
176
+ probs = prices[2 * i]
177
+ spot = prices[2 * i + 1]
178
+ meta, verified = metas[i]
179
+ win = info.get("winningOutcomeIdx")
180
+ n = int(cfg["outcomeCount"])
181
+ outcomes = list(meta.get("outcomes") or []) if meta else []
182
+ model = (meta or {}).get("model") or {}
183
+ out.append(Market(
184
+ address=checksum(a),
185
+ deployment=dep.name,
186
+ mechanism="lmsr" if dep.kind == "lmsr" else "dpm",
187
+ status=STATUSES[st] if isinstance(st, int) else "unknown",
188
+ question=(meta or {}).get("question"),
189
+ outcomes=outcomes if len(outcomes) == n else (outcomes or [f"Outcome {j}" for j in range(n)]),
190
+ probabilities=[p / 1e18 for p in probs] if not isinstance(probs, Exception) else [],
191
+ prices=[p / 10**self.network.token_decimals for p in spot] if not isinstance(spot, Exception) else [],
192
+ trading_closes_at=ts(cfg.get("tradingDeadline")),
193
+ settlement_deadline=ts(cfg.get("settlementDeadline")),
194
+ earliest_resolve_at=ts(cfg.get("earliestResolveTime")),
195
+ winning_outcome=None if win is None or win >= n else int(win),
196
+ pool_usd=info["pool"] / 10**self.network.token_decimals,
197
+ trading_fee=cfg["tradingFee"] / 1e18,
198
+ rules=model.get("prompt_context") or (meta or {}).get("resolutionPrompt"),
199
+ sources=list((meta or {}).get("data_sources") or (meta or {}).get("dataSources") or []),
200
+ judge_model=model.get("model_identifier"),
201
+ metadata_uri=md[0] if isinstance(md, tuple) else None,
202
+ metadata_verified=verified,
203
+ url=f"{self.network.explorer_url}/address/{checksum(a)}",
204
+ gateway=dep.gateway,
205
+ raw_metadata=meta,
206
+ ))
207
+ return out
208
+
209
+ def _metadata(self, entries: list[Optional[tuple]]) -> list[tuple[Optional[dict], Optional[bool]]]:
210
+ """Fetch each market's rules file, check it against the on-chain hash, cache it on disk."""
211
+ cache = _cache_dir() / "metadata" if self.cache else None
212
+ if cache:
213
+ cache.mkdir(exist_ok=True)
214
+
215
+ def get(entry: Optional[tuple]) -> tuple[Optional[dict], Optional[bool]]:
216
+ if not entry or not entry[0]:
217
+ return None, None
218
+ uri, h = entry[0], bytes(entry[1]).hex()
219
+ if uri in self._meta:
220
+ return self._meta[uri]
221
+ f = cache / f"{h}.json" if cache else None
222
+ body: Optional[bytes] = f.read_bytes() if f and f.exists() else None
223
+ if body is None and uri.startswith("http"):
224
+ for attempt in range(3):
225
+ try:
226
+ r = self._http.get(uri)
227
+ if r.status_code == 200:
228
+ body = r.content
229
+ break
230
+ except httpx.HTTPError:
231
+ time.sleep(0.5 * (attempt + 1))
232
+ if body is None:
233
+ return None, None
234
+ verified = hashlib.sha256(body).hexdigest() == h or Web3.keccak(body).hex().removeprefix("0x") == h
235
+ try:
236
+ data = json.loads(body)
237
+ except ValueError:
238
+ return None, False
239
+ if f and verified and not f.exists():
240
+ f.write_bytes(body)
241
+ self._meta[uri] = (data, verified)
242
+ return data, verified
243
+
244
+ with ThreadPoolExecutor(max_workers=16) as pool:
245
+ return list(pool.map(get, entries))
246
+
247
+ def search(self, query: str, *, status: str | None = "open", limit: int = 10) -> list[Market]:
248
+ """Find markets by words in the question, outcomes or rules. Delphi's own app has no search."""
249
+ from .search import rank
250
+ return rank(self.markets(status=status), query)[:limit]
251
+
252
+ # ---------------------------------------------------------------- quotes
253
+
254
+ def _resolve(self, market: str | Market, outcome: int | str) -> tuple[Market, int]:
255
+ m = market if isinstance(market, Market) else self.market(market)
256
+ return m, m.outcome_index(outcome)
257
+
258
+ def _quote_raw(self, m: Market, idx: int, shares_raw: int, side: str) -> int:
259
+ g = self._gw[m.deployment]
260
+ fn = "quoteBuyExactOut" if side == "buy" else "quoteSellExactIn"
261
+ r = getattr(g.functions, fn)(checksum(m.address), idx, shares_raw).call()
262
+ return int(r[0] if isinstance(r, (list, tuple)) else r)
263
+
264
+ def quote_buy(self, market: str | Market, outcome: int | str, *, shares: float | None = None,
265
+ usd: float | None = None) -> Quote:
266
+ """What a buy costs. Give ``shares`` for an exact share count, or ``usd`` for the most shares a budget
267
+ buys (fees included, never over budget)."""
268
+ if (shares is None) == (usd is None):
269
+ raise ValueError("give exactly one of shares= or usd=")
270
+ m, idx = self._resolve(market, outcome)
271
+ if m.status != "open":
272
+ raise DelphiError(f"market is {m.status}, not open for trading")
273
+ unit = 10**self.network.token_decimals
274
+ if shares is not None:
275
+ raw = int(round(shares * SHARE))
276
+ cost = self._quote_raw(m, idx, raw, "buy")
277
+ else:
278
+ raw, cost = self._fit_budget(m, idx, int(usd * unit))
279
+ return self._quote(m, idx, "buy", raw, cost)
280
+
281
+ def _fit_budget(self, m: Market, idx: int, budget: int) -> tuple[int, int]:
282
+ """Largest share count whose cost is within budget. Cost is convex in shares, so walk in from both sides."""
283
+ p = m.prices[idx] if m.prices and m.prices[idx] > 0 else 0.5
284
+ guess = budget / (10**self.network.token_decimals) / p
285
+ lo: tuple[float, int] | None = None
286
+ hi: tuple[float, int] | None = None
287
+ for _ in range(10):
288
+ raw = int(guess * SHARE)
289
+ try:
290
+ cost = self._quote_raw(m, idx, raw, "buy")
291
+ except Exception: # noqa: BLE001 (too many shares for the pool)
292
+ cost = None
293
+ if cost is not None and cost <= budget:
294
+ if not lo or guess > lo[0]:
295
+ lo = (guess, cost)
296
+ elif not hi or guess < hi[0]:
297
+ hi = (guess, cost if cost is not None else budget * 4)
298
+ if cost and abs(cost - budget) / budget < 0.002 and cost <= budget:
299
+ break
300
+ if lo and hi:
301
+ if (hi[0] - lo[0]) / hi[0] < 0.001:
302
+ break
303
+ guess = lo[0] + (hi[0] - lo[0]) * (budget - lo[1]) / max(1, hi[1] - lo[1])
304
+ guess = min(max(guess, lo[0] * 1.0001), hi[0] * 0.9999)
305
+ else:
306
+ guess *= max(0.25, min(4, budget / cost)) if cost else 0.5
307
+ if not lo:
308
+ raise DelphiError("the budget is too small to buy any shares")
309
+ return int(lo[0] * SHARE), lo[1]
310
+
311
+ def quote_sell(self, market: str | Market, outcome: int | str, shares: float) -> Quote:
312
+ m, idx = self._resolve(market, outcome)
313
+ if m.status != "open":
314
+ raise DelphiError(f"market is {m.status}, not open for trading")
315
+ raw = int(round(shares * SHARE))
316
+ return self._quote(m, idx, "sell", raw, self._quote_raw(m, idx, raw, "sell"))
317
+
318
+ def _quote(self, m: Market, idx: int, side: str, shares_raw: int, usd_raw: int) -> Quote:
319
+ unit = 10**self.network.token_decimals
320
+ after = None
321
+ if m.mechanism == "lmsr":
322
+ fn = "quoteBuyExactOut" if side == "buy" else "quoteSellExactIn"
323
+ try:
324
+ _, new_exp, new_sum = getattr(self._gw[m.deployment].functions, fn)(checksum(m.address), idx, shares_raw).call()
325
+ after = new_exp / new_sum if new_sum else None
326
+ except Exception: # noqa: BLE001
327
+ after = None
328
+ shares = shares_raw / SHARE
329
+ return Quote(market=m.address, outcome=idx, outcome_label=m.outcomes[idx] if idx < len(m.outcomes) else None,
330
+ side=side, shares=shares, usd=usd_raw / unit,
331
+ average_price=(usd_raw / unit / shares) if shares else None,
332
+ probability_before=m.probabilities[idx] if m.probabilities else None,
333
+ probability_after=after, shares_raw=shares_raw, usd_raw=usd_raw)
334
+
335
+ # ---------------------------------------------------------------- trading
336
+
337
+ def _need_account(self):
338
+ if not self.account:
339
+ raise DelphiError("no wallet: pass private_key= or set DELPHI_PRIVATE_KEY")
340
+ return self.account
341
+
342
+ def _send(self, fn) -> dict:
343
+ acct = self._need_account()
344
+ tx = fn.build_transaction({
345
+ "from": acct.address,
346
+ "nonce": self.w3.eth.get_transaction_count(acct.address, "pending"),
347
+ "chainId": self.network.chain_id,
348
+ })
349
+ signed = acct.sign_transaction(tx)
350
+ h = self.w3.eth.send_raw_transaction(signed.raw_transaction)
351
+ receipt = self.w3.eth.wait_for_transaction_receipt(h, timeout=180)
352
+ if receipt["status"] != 1:
353
+ raise DelphiError(f"transaction reverted: {self.tx_url(h.hex())}")
354
+ return receipt
355
+
356
+ def tx_url(self, tx_hash: str) -> str:
357
+ h = tx_hash if tx_hash.startswith("0x") else "0x" + tx_hash
358
+ return f"{self.network.explorer_url}/tx/{h}"
359
+
360
+ def _ensure_allowance(self, spender: str, amount: int, unlimited: bool) -> Optional[str]:
361
+ acct = self._need_account()
362
+ have = self.token.functions.allowance(acct.address, checksum(spender)).call()
363
+ if have >= amount:
364
+ return None
365
+ r = self._send(self.token.functions.approve(checksum(spender), MAX_UINT if unlimited else amount))
366
+ return "0x" + r["transactionHash"].hex().removeprefix("0x")
367
+
368
+ def buy(self, market: str | Market, outcome: int | str, *, usd: float | None = None, shares: float | None = None,
369
+ max_slippage: float = 0.02, approve_unlimited: bool = False) -> Trade:
370
+ """Buy shares of one outcome. ``usd`` spends up to that budget; ``shares`` buys an exact count.
371
+ Approves only the amount needed unless ``approve_unlimited``."""
372
+ acct = self._need_account()
373
+ m, idx = self._resolve(market, outcome)
374
+ q = self.quote_buy(m, idx, usd=usd, shares=shares)
375
+ max_in = math.ceil(q.usd_raw * (1 + max_slippage))
376
+ have = self.token.functions.balanceOf(acct.address).call()
377
+ if have < max_in:
378
+ raise InsufficientFunds(f"need up to ${max_in / 10**self.network.token_decimals:.2f}, wallet has ${have / 10**self.network.token_decimals:.2f}")
379
+ # trades go through the gateway, but the market contract itself pulls the USDC, so it is the spender
380
+ approval = self._ensure_allowance(m.address, max_in, approve_unlimited)
381
+ fn = self._gw[m.deployment].functions.buyExactOut(checksum(m.address), idx, q.shares_raw, max_in)
382
+ sim = fn.call({"from": acct.address})
383
+ r = self._send(fn)
384
+ paid = int(sim[0] if isinstance(sim, (list, tuple)) else sim)
385
+ h = "0x" + r["transactionHash"].hex().removeprefix("0x")
386
+ return Trade(market=m.address, outcome=idx, outcome_label=q.outcome_label, side="buy", shares=q.shares,
387
+ usd=paid / 10**self.network.token_decimals, tx_hash=h, explorer_url=self.tx_url(h), approval_tx=approval)
388
+
389
+ def sell(self, market: str | Market, outcome: int | str, shares: float | None = None, *,
390
+ max_slippage: float = 0.02) -> Trade:
391
+ """Sell shares back to the market. ``shares=None`` sells everything held in that outcome."""
392
+ acct = self._need_account()
393
+ m, idx = self._resolve(market, outcome)
394
+ g = self._gw[m.deployment]
395
+ raw = int(round(shares * SHARE)) if shares is not None else g.functions.balanceOf(checksum(m.address), acct.address, idx).call()
396
+ if raw <= 0:
397
+ raise DelphiError("no shares to sell")
398
+ out = self._quote_raw(m, idx, raw, "sell")
399
+ min_out = math.floor(out * (1 - max_slippage))
400
+ fn = g.functions.sellExactIn(checksum(m.address), idx, raw, min_out)
401
+ sim = fn.call({"from": acct.address})
402
+ r = self._send(fn)
403
+ got = int(sim[0] if isinstance(sim, (list, tuple)) else sim)
404
+ h = "0x" + r["transactionHash"].hex().removeprefix("0x")
405
+ return Trade(market=m.address, outcome=idx, outcome_label=m.outcomes[idx] if idx < len(m.outcomes) else None,
406
+ side="sell", shares=raw / SHARE, usd=got / 10**self.network.token_decimals, tx_hash=h, explorer_url=self.tx_url(h))
407
+
408
+ def redeem(self, market: str) -> dict:
409
+ """Collect winnings from a settled market."""
410
+ acct = self._need_account()
411
+ g = self._gw[self._deployment(market).name]
412
+ fn = g.functions.redeem(checksum(market))
413
+ shares_in, tokens_out = fn.call({"from": acct.address})
414
+ r = self._send(fn)
415
+ h = "0x" + r["transactionHash"].hex().removeprefix("0x")
416
+ return {"market": checksum(market), "shares": shares_in / SHARE, "usd": tokens_out / 10**self.network.token_decimals,
417
+ "tx_hash": h, "explorer_url": self.tx_url(h)}
418
+
419
+ def liquidate(self, market: str, outcomes: list[int] | None = None) -> dict:
420
+ """Recover funds from an expired or failed market (paid at final prices, not refunded at cost)."""
421
+ acct = self._need_account()
422
+ m = self.market(market, with_rules=False)
423
+ g = self._gw[m.deployment]
424
+ idxs = outcomes if outcomes is not None else [
425
+ i for i in range(len(m.probabilities)) if g.functions.balanceOf(checksum(market), acct.address, i).call() > 0]
426
+ fn = g.functions.liquidate(checksum(market), idxs)
427
+ shares_in, tokens_out = fn.call({"from": acct.address})
428
+ r = self._send(fn)
429
+ h = "0x" + r["transactionHash"].hex().removeprefix("0x")
430
+ return {"market": checksum(market), "shares": [s / SHARE for s in shares_in],
431
+ "usd": tokens_out / 10**self.network.token_decimals, "tx_hash": h, "explorer_url": self.tx_url(h)}
432
+
433
+ # ---------------------------------------------------------------- positions
434
+
435
+ def positions(self, wallet: str | None = None, *, markets: list[str | Market] | None = None,
436
+ include_empty_closed: bool = False) -> list[Position]:
437
+ """Every share a wallet holds, in every market of every deployment (or just ``markets``), with what to
438
+ do next. No API key: balances are read from the chain in a few batched calls."""
439
+ w = checksum(wallet or self._need_account().address)
440
+ if markets is not None:
441
+ addrs = [checksum(m.address if isinstance(m, Market) else m) for m in markets]
442
+ for a in addrs:
443
+ self._deployment(a)
444
+ else:
445
+ addrs = self.market_addresses()
446
+ info = self.reader.run([Call(self._gw[self._where[a.lower()]], "getMarket", [a]) for a in addrs])
447
+ counts = [int(i["config"]["outcomeCount"]) if isinstance(i, dict) else 0 for i in info]
448
+ bal = self.reader.run([Call(self._gw[self._where[a.lower()]], "batchBalanceOf", [a, [w] * n, list(range(n))])
449
+ for a, n in zip(addrs, counts)])
450
+ held = [(a, b) for a, b in zip(addrs, bal) if not isinstance(b, Exception) and any(b)]
451
+ if not held:
452
+ return []
453
+ markets = {m.address.lower(): m for m in self._load([a for a, _ in held], with_rules=True)}
454
+ out: list[Position] = []
455
+ for a, b in held:
456
+ m = markets.get(a.lower())
457
+ if not m:
458
+ continue
459
+ for idx, raw in enumerate(b):
460
+ if raw <= 0:
461
+ continue
462
+ sh = raw / SHARE
463
+ p = m.probabilities[idx] if idx < len(m.probabilities) else None
464
+ action, claim, value = None, None, (sh * p if p is not None else None)
465
+ if m.status == "settled":
466
+ won = m.winning_outcome == idx
467
+ value = None
468
+ if won:
469
+ action = "redeem"
470
+ try:
471
+ claim = self._gw[m.deployment].functions.redeem(checksum(a)).call({"from": w})[1] / 10**self.network.token_decimals
472
+ except Exception: # noqa: BLE001 (already redeemed, or nothing to claim)
473
+ claim = None
474
+ value = claim
475
+ elif not include_empty_closed:
476
+ continue
477
+ elif m.status in ("expired", "failed"):
478
+ action = "liquidate"
479
+ out.append(Position(market=m.address, question=m.question, outcome=idx,
480
+ outcome_label=m.outcomes[idx] if idx < len(m.outcomes) else None,
481
+ shares=sh, probability=p, value_usd=value, status=m.status,
482
+ action=action, claimable_usd=claim))
483
+ return out