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/__init__.py +9 -0
- delphi_kit/abi/DelphiFactory.json +1 -0
- delphi_kit/abi/DpmGateway.json +1 -0
- delphi_kit/abi/DpmGatewayLegacy.json +1 -0
- delphi_kit/abi/LmsrGateway.json +1 -0
- delphi_kit/abi/LmsrMarket.json +1 -0
- delphi_kit/abi/__init__.py +0 -0
- delphi_kit/chain.py +135 -0
- delphi_kit/cli.py +191 -0
- delphi_kit/client.py +483 -0
- delphi_kit/limits.py +93 -0
- delphi_kit/mcp_server.py +180 -0
- delphi_kit/models.py +118 -0
- delphi_kit/networks.py +98 -0
- delphi_kit/py.typed +0 -0
- delphi_kit/search.py +40 -0
- delphi_kit/watch.py +209 -0
- delphi_kit-0.1.0.dist-info/METADATA +190 -0
- delphi_kit-0.1.0.dist-info/RECORD +21 -0
- delphi_kit-0.1.0.dist-info/WHEEL +4 -0
- delphi_kit-0.1.0.dist-info/entry_points.txt +4 -0
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
|