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/limits.py ADDED
@@ -0,0 +1,93 @@
1
+ """Spending limits for agents. Every trade made through the MCP server (or any code that uses ``Guard``) is
2
+ checked against a per-trade cap, a rolling daily cap and an optional market allow-list, and recorded in a small
3
+ ledger file so the daily cap survives restarts."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import threading
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+
15
+ class LimitExceeded(Exception):
16
+ pass
17
+
18
+
19
+ def _env_float(name: str, default: float) -> float:
20
+ v = os.environ.get(name)
21
+ return float(v) if v not in (None, "") else default
22
+
23
+
24
+ @dataclass
25
+ class Limits:
26
+ trading_enabled: bool = False
27
+ max_trade_usd: float = 5.0
28
+ max_daily_usd: float = 20.0
29
+ max_slippage: float = 0.03
30
+ allowed_markets: frozenset[str] | None = None
31
+
32
+ @classmethod
33
+ def from_env(cls) -> "Limits":
34
+ allow = os.environ.get("DELPHI_ALLOWED_MARKETS")
35
+ return cls(
36
+ trading_enabled=os.environ.get("DELPHI_ALLOW_TRADING", "").lower() in ("1", "true", "yes"),
37
+ max_trade_usd=_env_float("DELPHI_MAX_TRADE_USD", 5.0),
38
+ max_daily_usd=_env_float("DELPHI_MAX_DAILY_USD", 20.0),
39
+ max_slippage=_env_float("DELPHI_MAX_SLIPPAGE", 0.03),
40
+ allowed_markets=frozenset(a.strip().lower() for a in allow.split(",") if a.strip()) if allow else None,
41
+ )
42
+
43
+
44
+ class Guard:
45
+ def __init__(self, limits: Limits, ledger: Path | None = None):
46
+ self.limits = limits
47
+ base = Path(os.environ.get("DELPHI_KIT_CACHE") or Path.home() / ".cache" / "delphi-kit")
48
+ base.mkdir(parents=True, exist_ok=True)
49
+ self.ledger = ledger or base / "spend.json"
50
+ self._lock = threading.Lock()
51
+
52
+ def _today(self) -> str:
53
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")
54
+
55
+ def _read(self) -> dict:
56
+ try:
57
+ return json.loads(self.ledger.read_text())
58
+ except (FileNotFoundError, ValueError):
59
+ return {}
60
+
61
+ def spent_today(self) -> float:
62
+ return float(self._read().get(self._today(), 0.0))
63
+
64
+ def check(self, market: str, usd: float) -> None:
65
+ lim = self.limits
66
+ if not lim.trading_enabled:
67
+ raise LimitExceeded("trading is off. Set DELPHI_ALLOW_TRADING=true to let this agent trade.")
68
+ if lim.allowed_markets is not None and market.lower() not in lim.allowed_markets:
69
+ raise LimitExceeded("this market is not in DELPHI_ALLOWED_MARKETS")
70
+ if usd > lim.max_trade_usd + 1e-9:
71
+ raise LimitExceeded(f"${usd:.2f} is over the per-trade limit of ${lim.max_trade_usd:.2f} (DELPHI_MAX_TRADE_USD)")
72
+ left = lim.max_daily_usd - self.spent_today()
73
+ if usd > left + 1e-9:
74
+ raise LimitExceeded(f"${usd:.2f} is over today's remaining budget of ${max(0.0, left):.2f} (DELPHI_MAX_DAILY_USD)")
75
+
76
+ def record(self, usd: float) -> None:
77
+ with self._lock:
78
+ data = self._read()
79
+ day = self._today()
80
+ data[day] = round(float(data.get(day, 0.0)) + usd, 6)
81
+ data = {k: v for k, v in data.items() if k >= sorted(data)[-30:][0]} # keep 30 days
82
+ self.ledger.write_text(json.dumps(data, indent=1))
83
+
84
+ def status(self) -> dict:
85
+ lim = self.limits
86
+ return {
87
+ "trading_enabled": lim.trading_enabled,
88
+ "max_trade_usd": lim.max_trade_usd,
89
+ "max_daily_usd": lim.max_daily_usd,
90
+ "spent_today_usd": round(self.spent_today(), 4),
91
+ "max_slippage": lim.max_slippage,
92
+ "allowed_markets": sorted(lim.allowed_markets) if lim.allowed_markets is not None else "all",
93
+ }
@@ -0,0 +1,180 @@
1
+ """An MCP server that lets any AI agent (Claude, Cursor, Hermes, your own) read and trade Delphi markets.
2
+
3
+ Reading needs nothing. Trading is off until you set ``DELPHI_ALLOW_TRADING=true`` and a wallet key, and every
4
+ trade is held to a per-trade cap, a daily cap and a slippage cap (see ``limits.py``).
5
+
6
+ uvx --from "delphi-kit[mcp]" delphi-mcp # stdio, for Claude Desktop / Claude Code / Cursor
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from typing import Any, Optional
13
+
14
+ from mcp.server.mcpserver import MCPServer
15
+ from mcp.types import ToolAnnotations
16
+
17
+ from .client import Delphi, DelphiError
18
+ from .limits import Guard, LimitExceeded, Limits
19
+
20
+ INSTRUCTIONS = """Delphi is a prediction market on the Gensyn chain, settled by an AI judge that reads each
21
+ market's rules and named sources. Prices are probabilities (0-1). Before buying: read the market's rules and
22
+ sources with get_market, quote with quote_buy, and check probability_after: thin markets move a lot on small
23
+ trades. Trades are held to spending limits shown by wallet_status; never try to work around them."""
24
+
25
+ READ = ToolAnnotations(read_only_hint=True, open_world_hint=True)
26
+ WRITE = ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False, open_world_hint=True)
27
+
28
+ server = MCPServer(name="delphi", instructions=INSTRUCTIONS)
29
+ _client: Optional[Delphi] = None
30
+ _guard: Optional[Guard] = None
31
+
32
+
33
+ def client() -> Delphi:
34
+ global _client
35
+ if _client is None:
36
+ _client = Delphi(os.environ.get("DELPHI_NETWORK", "mainnet"))
37
+ return _client
38
+
39
+
40
+ def guard() -> Guard:
41
+ global _guard
42
+ if _guard is None:
43
+ _guard = Guard(Limits.from_env())
44
+ return _guard
45
+
46
+
47
+ def _brief(m) -> dict:
48
+ return {
49
+ "address": m.address, "question": m.question, "status": m.status,
50
+ "outcomes": [{"label": o, "probability": round(p, 4)} for o, p in zip(m.outcomes, m.probabilities)],
51
+ "trading_closes_at": m.trading_closes_at.isoformat() if m.trading_closes_at else None,
52
+ "pool_usd": round(m.pool_usd or 0, 2), "type": m.deployment,
53
+ }
54
+
55
+
56
+ def _fail(e: Exception) -> dict:
57
+ return {"error": str(e)}
58
+
59
+
60
+ @server.tool(annotations=READ)
61
+ def list_markets(status: str = "open", query: str = "", limit: int = 20) -> Any:
62
+ """List Delphi markets with live probabilities. status: open | awaiting_settlement | settled | expired | failed | all.
63
+ query: optional keywords to search questions, outcomes and rules. Open markets come soonest-closing first."""
64
+ try:
65
+ st = None if status == "all" else status
66
+ ms = client().search(query, status=st, limit=limit) if query.strip() else client().markets(status=st, limit=limit)
67
+ return [_brief(m) for m in ms]
68
+ except (DelphiError, ValueError) as e:
69
+ return _fail(e)
70
+
71
+
72
+ @server.tool(annotations=READ)
73
+ def get_market(market: str) -> Any:
74
+ """Everything about one market: question, outcomes and probabilities, the rules the AI judge will apply,
75
+ its sources, deadlines, pool size, fee, and whether the rules file matches its on-chain hash."""
76
+ try:
77
+ m = client().market(market)
78
+ d = m.to_dict()
79
+ d.pop("raw_metadata", None)
80
+ return d
81
+ except (DelphiError, ValueError) as e:
82
+ return _fail(e)
83
+
84
+
85
+ @server.tool(annotations=READ)
86
+ def quote_buy(market: str, outcome: str, usd: float) -> Any:
87
+ """What spending `usd` on `outcome` (label like "Yes" or an index) buys right now: shares, average price,
88
+ and the probability after the trade. Fees included; nothing is sent."""
89
+ try:
90
+ return client().quote_buy(market, outcome, usd=usd).to_dict()
91
+ except (DelphiError, ValueError) as e:
92
+ return _fail(e)
93
+
94
+
95
+ @server.tool(annotations=READ)
96
+ def quote_sell(market: str, outcome: str, shares: float) -> Any:
97
+ """What selling `shares` of `outcome` back to the market returns right now. Nothing is sent."""
98
+ try:
99
+ return client().quote_sell(market, outcome, shares).to_dict()
100
+ except (DelphiError, ValueError) as e:
101
+ return _fail(e)
102
+
103
+
104
+ @server.tool(annotations=READ)
105
+ def get_positions(wallet: str = "") -> Any:
106
+ """Shares a wallet holds across every Delphi market, their current value, and what can be collected
107
+ (redeem after settlement, liquidate after a failed or expired market). Defaults to the agent's own wallet."""
108
+ try:
109
+ return [p.to_dict() for p in client().positions(wallet or None)]
110
+ except (DelphiError, ValueError) as e:
111
+ return _fail(e)
112
+
113
+
114
+ @server.tool(annotations=READ)
115
+ def wallet_status() -> Any:
116
+ """The agent's wallet address, USDC and gas balance, and its spending limits and spend so far today."""
117
+ c = client()
118
+ out: dict[str, Any] = {"network": c.network.name, "limits": guard().status()}
119
+ if c.address:
120
+ try:
121
+ out.update(c.balance())
122
+ except Exception as e: # noqa: BLE001
123
+ out["balance_error"] = str(e)
124
+ else:
125
+ out["wallet"] = None
126
+ out["note"] = "Read-only: no DELPHI_PRIVATE_KEY set."
127
+ return out
128
+
129
+
130
+ @server.tool(annotations=WRITE)
131
+ def buy(market: str, outcome: str, usd: float) -> Any:
132
+ """Buy shares of `outcome` with up to `usd` of USDC. Held to the per-trade, daily and slippage limits.
133
+ Quote first and read the rules; this sends a real transaction."""
134
+ g = guard()
135
+ try:
136
+ g.check(market, usd)
137
+ t = client().buy(market, outcome, usd=usd, max_slippage=g.limits.max_slippage)
138
+ g.record(t.usd)
139
+ return t.to_dict()
140
+ except (LimitExceeded, DelphiError, ValueError) as e:
141
+ return _fail(e)
142
+
143
+
144
+ @server.tool(annotations=WRITE)
145
+ def sell(market: str, outcome: str, shares: float = 0) -> Any:
146
+ """Sell `shares` of `outcome` back to the market (0 = everything held). Sends a real transaction."""
147
+ g = guard()
148
+ try:
149
+ if not g.limits.trading_enabled:
150
+ raise LimitExceeded("trading is off. Set DELPHI_ALLOW_TRADING=true to let this agent trade.")
151
+ return client().sell(market, outcome, shares or None, max_slippage=g.limits.max_slippage).to_dict()
152
+ except (LimitExceeded, DelphiError, ValueError) as e:
153
+ return _fail(e)
154
+
155
+
156
+ @server.tool(annotations=WRITE)
157
+ def collect(market: str) -> Any:
158
+ """Collect what a closed market owes the agent: redeems winnings from a settled market, or liquidates
159
+ shares in an expired or failed market at their final prices."""
160
+ g = guard()
161
+ try:
162
+ if not g.limits.trading_enabled:
163
+ raise LimitExceeded("trading is off. Set DELPHI_ALLOW_TRADING=true to let this agent trade.")
164
+ c = client()
165
+ m = c.market(market, with_rules=False)
166
+ if m.status == "settled":
167
+ return c.redeem(market)
168
+ if m.status in ("expired", "failed"):
169
+ return c.liquidate(market)
170
+ return _fail(DelphiError(f"market is {m.status}; nothing to collect yet"))
171
+ except (LimitExceeded, DelphiError, ValueError) as e:
172
+ return _fail(e)
173
+
174
+
175
+ def main() -> None:
176
+ server.run("stdio")
177
+
178
+
179
+ if __name__ == "__main__":
180
+ main()
delphi_kit/models.py ADDED
@@ -0,0 +1,118 @@
1
+ """Plain data objects returned by the client. Money is in USD (the collateral token), shares are decimal numbers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Optional
8
+
9
+ STATUSES = ("open", "awaiting_settlement", "settled", "expired", "failed")
10
+
11
+
12
+ def ts(seconds: int | None) -> Optional[datetime]:
13
+ return datetime.fromtimestamp(seconds, tz=timezone.utc) if seconds else None
14
+
15
+
16
+ def _jsonable(v: Any) -> Any:
17
+ if isinstance(v, datetime):
18
+ return v.isoformat()
19
+ if isinstance(v, dict):
20
+ return {k: _jsonable(x) for k, x in v.items()}
21
+ if isinstance(v, (list, tuple)):
22
+ return [_jsonable(x) for x in v]
23
+ return v
24
+
25
+
26
+ class _Dict:
27
+ def to_dict(self) -> dict:
28
+ return _jsonable(asdict(self)) # type: ignore[arg-type]
29
+
30
+
31
+ @dataclass
32
+ class Market(_Dict):
33
+ address: str
34
+ deployment: str # "lmsr" | "dpm" | "dpm-legacy"
35
+ mechanism: str # "lmsr" | "dpm"
36
+ status: str # one of STATUSES
37
+ question: Optional[str]
38
+ outcomes: list[str]
39
+ probabilities: list[float] # implied probability per outcome, sums to ~1
40
+ prices: list[float] # USD cost of one more share, per outcome (spot)
41
+ trading_closes_at: Optional[datetime]
42
+ settlement_deadline: Optional[datetime]
43
+ earliest_resolve_at: Optional[datetime] = None
44
+ winning_outcome: Optional[int] = None
45
+ pool_usd: Optional[float] = None
46
+ trading_fee: Optional[float] = None
47
+ rules: Optional[str] = None # what the AI judge is told to check
48
+ sources: list[str] = field(default_factory=list)
49
+ judge_model: Optional[str] = None
50
+ metadata_uri: Optional[str] = None
51
+ metadata_verified: Optional[bool] = None # True when the rules file matches its on-chain hash
52
+ url: Optional[str] = None
53
+ gateway: Optional[str] = None
54
+ raw_metadata: Optional[dict] = field(default=None, repr=False)
55
+
56
+ @property
57
+ def is_open(self) -> bool:
58
+ return self.status == "open"
59
+
60
+ def outcome_index(self, outcome: int | str) -> int:
61
+ """Accept an index or a label ("Yes", "no", ...)."""
62
+ if isinstance(outcome, int) or (isinstance(outcome, str) and outcome.isdigit()):
63
+ i = int(outcome)
64
+ if not 0 <= i < len(self.outcomes or self.probabilities):
65
+ raise ValueError(f"outcome {i} out of range for {self.address}")
66
+ return i
67
+ labels = [o.lower().strip() for o in self.outcomes]
68
+ try:
69
+ return labels.index(str(outcome).lower().strip())
70
+ except ValueError:
71
+ raise ValueError(f"{outcome!r} is not an outcome of this market; outcomes are {self.outcomes}") from None
72
+
73
+ def summary(self) -> str:
74
+ odds = ", ".join(f"{o} {p:.0%}" for o, p in zip(self.outcomes, self.probabilities))
75
+ close = self.trading_closes_at.strftime("%Y-%m-%d %H:%M UTC") if self.trading_closes_at else "?"
76
+ return f"{self.question} [{odds}] {self.status}, trading closes {close}"
77
+
78
+
79
+ @dataclass
80
+ class Quote(_Dict):
81
+ market: str
82
+ outcome: int
83
+ outcome_label: Optional[str]
84
+ side: str # "buy" | "sell"
85
+ shares: float
86
+ usd: float # cost for a buy, proceeds for a sell (fees included)
87
+ average_price: Optional[float]
88
+ probability_before: Optional[float]
89
+ probability_after: Optional[float] = None
90
+ shares_raw: int = 0
91
+ usd_raw: int = 0
92
+
93
+
94
+ @dataclass
95
+ class Trade(_Dict):
96
+ market: str
97
+ outcome: int
98
+ outcome_label: Optional[str]
99
+ side: str
100
+ shares: float
101
+ usd: float
102
+ tx_hash: str
103
+ explorer_url: str
104
+ approval_tx: Optional[str] = None
105
+
106
+
107
+ @dataclass
108
+ class Position(_Dict):
109
+ market: str
110
+ question: Optional[str]
111
+ outcome: int
112
+ outcome_label: Optional[str]
113
+ shares: float
114
+ probability: Optional[float]
115
+ value_usd: Optional[float] # shares x current probability (open markets) or payout (settled)
116
+ status: str
117
+ action: Optional[str] = None # "redeem" | "liquidate" | None
118
+ claimable_usd: Optional[float] = None
delphi_kit/networks.py ADDED
@@ -0,0 +1,98 @@
1
+ """Where Delphi lives on each network.
2
+
3
+ Delphi has shipped three generations of contracts. Each generation has its own factory (which creates markets)
4
+ and gateway (which every trade, quote and settlement goes through). A market belongs to exactly one generation
5
+ for life, so every call has to be sent to the right gateway. The official TypeScript SDK 2.1.0 knows the two
6
+ pari-mutuel generations; the LMSR generation, which carries live trading on mainnet since September 2026, is
7
+ listed here too.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Deployment:
17
+ name: str # short label, e.g. "lmsr"
18
+ kind: str # "lmsr" | "dpm" | "dpm_legacy" (selects the ABI)
19
+ factory: str
20
+ gateway: str
21
+ note: str = ""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Network:
26
+ name: str
27
+ chain_id: int
28
+ rpc_url: str
29
+ token: str # USDC (or the competition token)
30
+ token_decimals: int
31
+ multicall3: str
32
+ explorer_url: str
33
+ app_url: str
34
+ deployments: tuple[Deployment, ...] = field(default_factory=tuple)
35
+
36
+
37
+ MULTICALL3 = "0xcA11bde05977b3631167028862bE2a173976CA11"
38
+
39
+ NETWORKS: dict[str, Network] = {
40
+ "mainnet": Network(
41
+ name="mainnet",
42
+ chain_id=685689,
43
+ rpc_url="https://gensyn-mainnet.g.alchemy.com/public",
44
+ token="0x5b32c997211621d55a89Cc5abAF1cC21F3A6ddF5",
45
+ token_decimals=6,
46
+ multicall3=MULTICALL3,
47
+ explorer_url="https://gensyn-mainnet.explorer.alchemy.com",
48
+ app_url="https://delphi.fyi",
49
+ deployments=(
50
+ Deployment("lmsr", "lmsr", "0x1819a2aeEb96DA9BA1C5262A5c82B6F9c347f99c",
51
+ "0x7fd914D05D1FE4Fa3EC84E2Cd1D81be6CBe03747",
52
+ "LMSR, AI-settled. Live trading since Sep 2026. Not in SDK 2.1.0 defaults."),
53
+ Deployment("dpm", "dpm", "0x9C73417f79a1361c6aF9Bd828343badEE1b84936",
54
+ "0x982a67aE92D8de361957249fB2BB4a62BCc6A8d5",
55
+ "Pari-mutuel, AI-settled."),
56
+ Deployment("dpm-legacy", "dpm_legacy", "0x4596d847eA56DCf9A37944c13793Af802Fc5D1eC",
57
+ "0x4e4e85c52E0F414cc67eE88d0C649Ec81698d700",
58
+ "Pari-mutuel, creator-settled. First generation."),
59
+ ),
60
+ ),
61
+ "testnet": Network(
62
+ name="testnet",
63
+ chain_id=685685,
64
+ rpc_url="https://gensyn-testnet.g.alchemy.com/public",
65
+ token="0x0724D6079b986F8e44bDafB8a09B60C0bd6A45a1",
66
+ token_decimals=6,
67
+ multicall3=MULTICALL3,
68
+ explorer_url="https://gensyn-testnet.explorer.alchemy.com",
69
+ app_url="https://testnet.delphi.fyi",
70
+ deployments=(
71
+ Deployment("dpm", "dpm", "0x97d2b3F0614C8189343A38094629FCE2910b727A",
72
+ "0x22ea355D7218Dc86b4c83732cBbd01f7Ff2332b3"),
73
+ Deployment("dpm-legacy", "dpm_legacy", "0xd03CEC55802f0D44D844384E1144B25717315E5A",
74
+ "0x7b8FDBD187B0Be5e30e48B1995df574A62667147"),
75
+ ),
76
+ ),
77
+ "competition-testnet": Network(
78
+ name="competition-testnet",
79
+ chain_id=685685,
80
+ rpc_url="https://gensyn-testnet.g.alchemy.com/public",
81
+ token="0x8A2d75753362Eb5D5669a2c22cbf394b26a0571F",
82
+ token_decimals=6,
83
+ multicall3=MULTICALL3,
84
+ explorer_url="https://gensyn-testnet.explorer.alchemy.com",
85
+ app_url="https://agent-competition.gensyn.ai",
86
+ deployments=(
87
+ Deployment("lmsr", "lmsr", "0xEa9D0a78d0209916e88e363B8FDa3e23206Ff49b",
88
+ "0x097599c9D966fF496284b892A8F13BF885b258ef", "Agent competition."),
89
+ ),
90
+ ),
91
+ }
92
+
93
+
94
+ def get_network(name: str) -> Network:
95
+ try:
96
+ return NETWORKS[name]
97
+ except KeyError:
98
+ raise ValueError(f"unknown network {name!r}; choose one of {', '.join(NETWORKS)}") from None
delphi_kit/py.typed ADDED
File without changes
delphi_kit/search.py ADDED
@@ -0,0 +1,40 @@
1
+ """Keyword search over markets. Small and dependency-free: word matches in the question count most, then
2
+ outcomes, then the rules; prefixes count ("elect" finds "election"); ties go to the market that closes first."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import math
7
+ import re
8
+
9
+ from .models import Market
10
+
11
+ _WORD = re.compile(r"[a-z0-9$%.]+")
12
+ _STOP = {"the", "a", "an", "of", "in", "on", "by", "to", "will", "be", "is", "for", "and", "or", "at", "before", "after"}
13
+
14
+
15
+ def _words(text: str | None) -> list[str]:
16
+ return [w.strip(".") for w in _WORD.findall((text or "").lower()) if w.strip(".") and w not in _STOP]
17
+
18
+
19
+ def score(m: Market, terms: list[str]) -> float:
20
+ fields = ((_words(m.question), 3.0), (_words(" ".join(m.outcomes)), 2.0), (_words(m.rules), 1.0))
21
+ total = 0.0
22
+ for t in terms:
23
+ best = 0.0
24
+ for words, weight in fields:
25
+ if t in words:
26
+ best = max(best, weight)
27
+ elif any(w.startswith(t) for w in words) and len(t) >= 3:
28
+ best = max(best, weight * 0.6)
29
+ total += best
30
+ return total
31
+
32
+
33
+ def rank(markets: list[Market], query: str) -> list[Market]:
34
+ terms = _words(query)
35
+ if not terms:
36
+ return markets
37
+ scored = [(score(m, terms), m) for m in markets]
38
+ scored = [(s, m) for s, m in scored if s > 0]
39
+ scored.sort(key=lambda x: (-x[0], x[1].trading_closes_at.timestamp() if x[1].trading_closes_at else math.inf))
40
+ return [m for _, m in scored]