oddsrail 0.3.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.
oddsrail/__init__.py ADDED
File without changes
oddsrail/kalshi.py ADDED
@@ -0,0 +1,299 @@
1
+ """Kalshi venue: signed REST access for trading agents.
2
+
3
+ Auth is RSA-PSS(SHA256, MGF1-SHA256, salt=digest length) over the exact string
4
+ str(unix_ms) + METHOD_UPPER + path
5
+ where `path` INCLUDES the /trade-api/v2 prefix and EXCLUDES the query string,
6
+ sent as KALSHI-ACCESS-KEY / -SIGNATURE / -TIMESTAMP headers.
7
+
8
+ Deliberately built on httpx rather than the official SDK: kalshi-python-sync
9
+ requires Python >=3.13 (this runs on 3.12), it re-releases weekly in lockstep
10
+ with the spec version, and the surface used here is small enough that a
11
+ pinned dependency costs more than it saves. Read endpoints need no key at all.
12
+
13
+ Two shapes on this API bite hard, so both are normalised here:
14
+
15
+ 1. PRICES ARE DOLLAR STRINGS, NOT CENTS. Fields carry a `_dollars` suffix
16
+ ("0.5600") and sizes an `_fp` suffix ("10.00"); the legacy integer-cent
17
+ fields were removed in 2026-03. All arithmetic uses Decimal — float
18
+ rounding on a 1-tick market is a real money bug.
19
+
20
+ 2. THE ORDERBOOK IS BIDS-ONLY ON BOTH SIDES. `orderbook_fp.yes_dollars` and
21
+ `.no_dollars` are both BID ladders, ascending, so the best bid is the LAST
22
+ element. A NO bid at $0.99 IS a YES ask at $0.01. get_orderbook() converts
23
+ this into a conventional best-first bid/ask view of the YES book; the raw
24
+ ladders are returned alongside so nothing is hidden.
25
+
26
+ Compliance note: Kalshi's Developer Agreement limits API use to a member's
27
+ own trading and restricts storing/sharing API data. oddsrail is self-hosted
28
+ and single-tenant — the operator supplies their own key and trades their own
29
+ account — and this module caches nothing.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import base64
35
+ import os
36
+ import time
37
+ from decimal import Decimal
38
+
39
+ import httpx
40
+
41
+ PROD = "https://external-api.kalshi.com"
42
+ DEMO = "https://external-api.demo.kalshi.co"
43
+ PREFIX = "/trade-api/v2"
44
+
45
+
46
+ def base_url() -> str:
47
+ return DEMO if os.environ.get("KALSHI_DEMO", "").lower() in ("1", "true", "yes") else PROD
48
+
49
+
50
+ def dry_run() -> bool:
51
+ return os.environ.get("ODDSRAIL_DRY_RUN", "1") not in ("0", "false", "no")
52
+
53
+
54
+ def has_credentials() -> bool:
55
+ return bool(os.environ.get("KALSHI_KEY_ID")) and bool(
56
+ os.environ.get("KALSHI_PRIVATE_KEY_PATH") or os.environ.get("KALSHI_PRIVATE_KEY"))
57
+
58
+
59
+ def _private_key():
60
+ from cryptography.hazmat.primitives import serialization
61
+ pem = os.environ.get("KALSHI_PRIVATE_KEY")
62
+ if not pem:
63
+ path = os.environ.get("KALSHI_PRIVATE_KEY_PATH")
64
+ if not path:
65
+ raise RuntimeError(
66
+ "no Kalshi key: set KALSHI_PRIVATE_KEY_PATH (PKCS#8 PEM) or "
67
+ "KALSHI_PRIVATE_KEY. Read tools work without one.")
68
+ with open(path, "rb") as fh:
69
+ pem_bytes = fh.read()
70
+ else:
71
+ pem_bytes = pem.encode()
72
+ return serialization.load_pem_private_key(pem_bytes, password=None)
73
+
74
+
75
+ def _signed_headers(method: str, path: str) -> dict:
76
+ """path must start with /trade-api/v2 and carry no query string."""
77
+ from cryptography.hazmat.primitives import hashes
78
+ from cryptography.hazmat.primitives.asymmetric import padding
79
+
80
+ key_id = os.environ.get("KALSHI_KEY_ID")
81
+ if not key_id:
82
+ raise RuntimeError("KALSHI_KEY_ID not set")
83
+ ts = str(int(time.time() * 1000))
84
+ msg = (ts + method.upper() + path.split("?")[0].split("#")[0]).encode()
85
+ sig = _private_key().sign(
86
+ msg,
87
+ padding.PSS(mgf=padding.MGF1(hashes.SHA256()),
88
+ salt_length=padding.PSS.DIGEST_LENGTH),
89
+ hashes.SHA256(),
90
+ )
91
+ return {
92
+ "KALSHI-ACCESS-KEY": key_id,
93
+ "KALSHI-ACCESS-SIGNATURE": base64.b64encode(sig).decode("ascii"),
94
+ "KALSHI-ACCESS-TIMESTAMP": ts,
95
+ "Content-Type": "application/json",
96
+ }
97
+
98
+
99
+ async def _get(path: str, params: dict | None = None, signed: bool = False):
100
+ url = base_url() + PREFIX + path
101
+ headers = _signed_headers("GET", PREFIX + path) if signed else {}
102
+ async with httpx.AsyncClient(timeout=20.0) as c:
103
+ r = await c.get(url, params=params or {}, headers=headers)
104
+ r.raise_for_status()
105
+ return r.json()
106
+
107
+
108
+ async def _post(path: str, body: dict):
109
+ url = base_url() + PREFIX + path
110
+ headers = _signed_headers("POST", PREFIX + path)
111
+ async with httpx.AsyncClient(timeout=20.0) as c:
112
+ r = await c.post(url, json=body, headers=headers)
113
+ r.raise_for_status()
114
+ return r.json()
115
+
116
+
117
+ def _d(v) -> Decimal | None:
118
+ try:
119
+ return Decimal(str(v))
120
+ except Exception:
121
+ return None
122
+
123
+
124
+ def slim_market(m: dict) -> dict:
125
+ """Kalshi market -> the same vocabulary oddsrail uses for Polymarket."""
126
+ return {
127
+ "venue": "kalshi",
128
+ "ticker": m.get("ticker"),
129
+ "event_ticker": m.get("event_ticker"),
130
+ "title": m.get("title"),
131
+ "yes_sub_title": m.get("yes_sub_title"),
132
+ "status": m.get("status"),
133
+ "yes_bid": m.get("yes_bid_dollars"),
134
+ "yes_ask": m.get("yes_ask_dollars"),
135
+ "no_bid": m.get("no_bid_dollars"),
136
+ "no_ask": m.get("no_ask_dollars"),
137
+ "last_price": m.get("last_price_dollars"),
138
+ "volume": m.get("volume_fp"),
139
+ "volume_24h": m.get("volume_24h_fp"),
140
+ "open_interest": m.get("open_interest_fp"),
141
+ "close_time": m.get("close_time"),
142
+ "rules_primary": (m.get("rules_primary") or "")[:400],
143
+ "market_type": m.get("market_type"),
144
+ }
145
+
146
+
147
+ async def search_markets(query: str = "", limit: int = 10,
148
+ status: str = "open", min_volume: float = 0.0):
149
+ """Kalshi has no text-search endpoint, so this pages recent open markets
150
+ and filters client-side on title/ticker. MVE combo shards are excluded —
151
+ they are auto-generated, illiquid, and drown real markets otherwise."""
152
+ out, cursor, pages = [], None, 0
153
+ q = (query or "").lower()
154
+ while len(out) < limit and pages < 5:
155
+ params = {"limit": 200, "status": status, "mve_filter": "exclude"}
156
+ if cursor:
157
+ params["cursor"] = cursor
158
+ data = await _get("/markets", params)
159
+ batch = data.get("markets") or []
160
+ for m in batch:
161
+ if float(_d(m.get("volume_fp")) or 0) < min_volume:
162
+ continue
163
+ hay = f"{m.get('title','')} {m.get('ticker','')} {m.get('yes_sub_title','')}".lower()
164
+ if q and q not in hay:
165
+ continue
166
+ out.append(m)
167
+ cursor = data.get("cursor")
168
+ pages += 1
169
+ if not cursor or not batch:
170
+ break
171
+ out.sort(key=lambda m: -float(_d(m.get("volume_fp")) or 0))
172
+ return [slim_market(m) for m in out[:limit]]
173
+
174
+
175
+ async def get_market(ticker: str):
176
+ d = await _get(f"/markets/{ticker}")
177
+ return slim_market(d.get("market") or {})
178
+
179
+
180
+ async def get_orderbook(ticker: str, depth: int = 10):
181
+ """Normalise Kalshi's bids-only ladders into a YES-book bid/ask view.
182
+
183
+ yes_dollars is the YES bid ladder; no_dollars is the NO bid ladder, and a
184
+ NO bid at q is a YES ask at (1 - q). Both arrive ascending, so the best
185
+ levels are at the END — everything below is returned best-first.
186
+ """
187
+ d = await _get(f"/markets/{ticker}/orderbook", {"depth": depth})
188
+ ob = d.get("orderbook_fp") or {}
189
+ yes_raw = ob.get("yes_dollars") or []
190
+ no_raw = ob.get("no_dollars") or []
191
+
192
+ yes_bids = [{"price": str(p), "size": str(s)} for p, s in reversed(yes_raw)]
193
+ yes_asks = [{"price": str(Decimal("1") - Decimal(p)), "size": str(s)}
194
+ for p, s in reversed(no_raw)]
195
+ return {
196
+ "venue": "kalshi",
197
+ "ticker": ticker,
198
+ "yes_bids": yes_bids[:depth],
199
+ "yes_asks": yes_asks[:depth],
200
+ "best_yes_bid": yes_bids[0]["price"] if yes_bids else None,
201
+ "best_yes_ask": yes_asks[0]["price"] if yes_asks else None,
202
+ "note": ("Kalshi publishes bid ladders only; yes_asks are derived as "
203
+ "1 - (NO bid). Prices are dollar strings, not cents."),
204
+ "raw_ladders": {"yes_bids": yes_raw, "no_bids": no_raw},
205
+ }
206
+
207
+
208
+ async def get_trades(ticker: str, limit: int = 50):
209
+ d = await _get("/markets/trades", {"ticker": ticker, "limit": limit})
210
+ return d.get("trades") or []
211
+
212
+
213
+ async def get_balance():
214
+ if not has_credentials():
215
+ return {"note": "no Kalshi credentials configured"}
216
+ return await _get("/portfolio/balance", signed=True)
217
+
218
+
219
+ async def get_positions(limit: int = 50):
220
+ if not has_credentials():
221
+ return {"note": "no Kalshi credentials configured"}
222
+ return await _get("/portfolio/positions", {"limit": limit}, signed=True)
223
+
224
+
225
+ async def open_orders(limit: int = 50):
226
+ if not has_credentials():
227
+ return {"note": "no Kalshi credentials configured"}
228
+ return await _get("/portfolio/orders", {"limit": limit, "status": "resting"},
229
+ signed=True)
230
+
231
+
232
+ def _to_yes_book(outcome: str, action: str, price: Decimal):
233
+ """Translate (outcome, action, price-of-that-outcome) to the YES book.
234
+
235
+ Kalshi V2 quotes everything from the YES leg as bid/ask — there is no
236
+ yes/no side and no buy/sell action — so this is where an inverted-position
237
+ bug would live if it were done implicitly at the call site.
238
+
239
+ buy YES @ p -> bid @ p sell YES @ p -> ask @ p
240
+ buy NO @ q -> ask @ (1 - q) sell NO @ q -> bid @ (1 - q)
241
+ """
242
+ outcome, action = outcome.lower(), action.lower()
243
+ if outcome not in ("yes", "no"):
244
+ raise ValueError("outcome must be 'yes' or 'no'")
245
+ if action not in ("buy", "sell"):
246
+ raise ValueError("action must be 'buy' or 'sell'")
247
+ if outcome == "yes":
248
+ return ("bid" if action == "buy" else "ask"), price
249
+ return ("ask" if action == "buy" else "bid"), (Decimal("1") - price)
250
+
251
+
252
+ async def place_order(ticker: str, outcome: str, action: str, price: float,
253
+ count: float, time_in_force: str = "good_till_cancelled",
254
+ client_order_id: str | None = None):
255
+ """Limit order. `price` is the probability of `outcome`, in (0,1)."""
256
+ p = Decimal(str(price))
257
+ if not (Decimal("0") < p < Decimal("1")):
258
+ raise ValueError("price is a probability in (0, 1)")
259
+ if count <= 0:
260
+ raise ValueError("count must be positive")
261
+
262
+ side, yes_price = _to_yes_book(outcome, action, p)
263
+ body = {
264
+ "ticker": ticker,
265
+ "side": side,
266
+ "price": f"{yes_price:.4f}",
267
+ "count": f"{Decimal(str(count)):.2f}",
268
+ "time_in_force": time_in_force,
269
+ "self_trade_prevention_type": "cancel_resting",
270
+ }
271
+ if client_order_id:
272
+ body["client_order_id"] = client_order_id
273
+
274
+ intent = {
275
+ "venue": "kalshi",
276
+ "requested": {"ticker": ticker, "outcome": outcome, "action": action,
277
+ "price": str(p), "count": count},
278
+ "translated_to_yes_book": body,
279
+ "attribution": ("none — Kalshi has no builder-code field on REST; "
280
+ "its builder program is a Solana/DFlow integration"),
281
+ }
282
+ if dry_run():
283
+ return {"dry_run": True, "would_post": intent,
284
+ "note": "set ODDSRAIL_DRY_RUN=0 to post real orders"}
285
+ if not has_credentials():
286
+ raise RuntimeError("Kalshi credentials required to place real orders")
287
+ resp = await _post("/portfolio/events/orders", body)
288
+ return {"dry_run": False, "posted": intent, "response": resp}
289
+
290
+
291
+ async def cancel_order(order_id: str):
292
+ if dry_run():
293
+ return {"dry_run": True, "would_cancel": order_id}
294
+ url = base_url() + PREFIX + f"/portfolio/events/orders/{order_id}"
295
+ headers = _signed_headers("DELETE", PREFIX + f"/portfolio/events/orders/{order_id}")
296
+ async with httpx.AsyncClient(timeout=20.0) as c:
297
+ r = await c.delete(url, headers=headers)
298
+ r.raise_for_status()
299
+ return r.json()
oddsrail/polymarket.py ADDED
@@ -0,0 +1,170 @@
1
+ """Polymarket data access via the official unified SDK (polymarket-client).
2
+
3
+ Read paths need no keys (AsyncPublicClient). Trading lives in trading.py.
4
+
5
+ Field mappings below were verified against the live API (2026-08-23), not
6
+ guessed from docs: `outcomes` is a DICT keyed yes/no (not a list), search
7
+ returns {events, tags, profiles} with markets nested inside each event, and
8
+ book/volume/resolution data live in the prices/metrics/state/resolution
9
+ sub-objects rather than at the top level.
10
+ """
11
+
12
+ import httpx
13
+
14
+ CLOB = "https://clob.polymarket.com"
15
+
16
+ _public = None
17
+
18
+
19
+ async def public():
20
+ global _public
21
+ if _public is None:
22
+ from polymarket import AsyncPublicClient
23
+ _public = AsyncPublicClient()
24
+ return _public
25
+
26
+
27
+ def dump(obj):
28
+ """Pydantic model / tuple / list -> plain JSON-able structure."""
29
+ if hasattr(obj, "model_dump"):
30
+ return obj.model_dump(mode="json")
31
+ if isinstance(obj, (list, tuple)):
32
+ return [dump(x) for x in obj]
33
+ if isinstance(obj, dict):
34
+ return {k: dump(v) for k, v in obj.items()}
35
+ return obj
36
+
37
+
38
+ def slim_market(m: dict) -> dict:
39
+ """Reduce a dumped Market model to what an agent needs to decide."""
40
+ outs = m.get("outcomes") or {}
41
+ prices = m.get("prices") or {}
42
+ metrics = m.get("metrics") or {}
43
+ state = m.get("state") or {}
44
+ res = m.get("resolution") or {}
45
+
46
+ def leg(key):
47
+ o = outs.get(key) or {}
48
+ return {"label": o.get("label"), "token_id": o.get("token_id"),
49
+ "price": o.get("price")}
50
+
51
+ return {
52
+ "id": m.get("id"),
53
+ "slug": m.get("slug"),
54
+ "question": m.get("question"),
55
+ "condition_id": m.get("condition_id"),
56
+ "outcomes": {"yes": leg("yes"), "no": leg("no")} if outs else None,
57
+ "best_bid": prices.get("best_bid"),
58
+ "best_ask": prices.get("best_ask"),
59
+ "spread": prices.get("spread"),
60
+ "last_trade_price": prices.get("last_trade_price"),
61
+ "volume": metrics.get("volume"),
62
+ "volume_24hr": metrics.get("volume_24hr"),
63
+ "liquidity": metrics.get("liquidity"),
64
+ "active": state.get("active"),
65
+ "closed": state.get("closed"),
66
+ "accepting_orders": state.get("accepting_orders"),
67
+ "neg_risk": state.get("neg_risk"),
68
+ "end_date": state.get("end_date"),
69
+ "uma_resolution_status": res.get("uma_resolution_status"),
70
+ "resolution_source": res.get("source"),
71
+ }
72
+
73
+
74
+ def _markets_from_search(results) -> list:
75
+ """search() yields {events, tags, profiles}; the markets hang off events."""
76
+ out = []
77
+ for r in results:
78
+ if not isinstance(r, dict):
79
+ continue
80
+ for ev in r.get("events") or []:
81
+ for mk in ev.get("markets") or []:
82
+ if isinstance(mk, dict):
83
+ out.append(mk)
84
+ return out
85
+
86
+
87
+ async def search_markets(query: str = "", limit: int = 10):
88
+ c = await public()
89
+ if query:
90
+ page = await c.search(q=query, sort="volume_24hr",
91
+ page_size=max(limit, 5)).first_page()
92
+ found = _markets_from_search(dump(list(page.items)))
93
+ # open markets first, then by 24h volume
94
+ def vol(m):
95
+ try:
96
+ return float(((m.get("metrics") or {}).get("volume_24hr")) or 0)
97
+ except (TypeError, ValueError):
98
+ return 0.0
99
+ found.sort(key=lambda m: (bool((m.get("state") or {}).get("closed")), -vol(m)))
100
+ return [slim_market(m) for m in found[:limit]]
101
+
102
+ page = await c.list_markets(closed=False, page_size=limit).first_page()
103
+ return [slim_market(m) for m in dump(list(page.items))]
104
+
105
+
106
+ async def get_market(id_or_slug: str, full: bool = False):
107
+ c = await public()
108
+ try:
109
+ m = await c.get_market(slug=id_or_slug)
110
+ except Exception:
111
+ m = await c.get_market(id=id_or_slug)
112
+ d = dump(m)
113
+ return d if full else slim_market(d)
114
+
115
+
116
+ async def get_orderbook(token_id: str):
117
+ c = await public()
118
+ return dump(await c.get_order_book(token_id=token_id))
119
+
120
+
121
+ async def price_history(token_id: str, hours: float = 6.0,
122
+ fidelity_minutes: int = 1):
123
+ """Returns (times, prices) oldest->newest."""
124
+ c = await public()
125
+ interval = "1h" if hours <= 1 else "6h" if hours <= 6 else \
126
+ "1d" if hours <= 24 else "1w"
127
+ pts = dump(await c.get_price_history(token_id=token_id, interval=interval,
128
+ fidelity=fidelity_minutes))
129
+ times, prices = [], []
130
+ for p in pts:
131
+ t = p.get("t", p.get("timestamp"))
132
+ v = p.get("p", p.get("price"))
133
+ if t is not None and v is not None:
134
+ times.append(float(t))
135
+ prices.append(float(v))
136
+ return times, prices
137
+
138
+
139
+ async def get_positions(address: str, limit: int = 25):
140
+ c = await public()
141
+ page = await c.list_positions(user=address, page_size=limit).first_page()
142
+ return dump(list(page.items))
143
+
144
+
145
+ # The SDK accepts only these; agents will say "weekly"/"7d"/"week", so map.
146
+ _PERIODS = {"day": "DAY", "daily": "DAY", "1d": "DAY", "24h": "DAY",
147
+ "week": "WEEK", "weekly": "WEEK", "7d": "WEEK",
148
+ "month": "MONTH", "monthly": "MONTH", "30d": "MONTH",
149
+ "all": "ALL", "alltime": "ALL", "all_time": "ALL"}
150
+
151
+
152
+ def normalize_period(p: str) -> str:
153
+ return _PERIODS.get(str(p or "").strip().lower().replace("-", "_"), "WEEK")
154
+
155
+
156
+ async def builder_leaderboard(time_period: str = "WEEK", limit: int = 25):
157
+ c = await public()
158
+ period = normalize_period(time_period)
159
+ page = await c.list_builder_leaderboard(
160
+ time_period=period, page_size=limit).first_page()
161
+ return dump(list(page.items))
162
+
163
+
164
+ async def builder_trades(builder_code: str):
165
+ """Public attribution check: matched trades carrying this builder code."""
166
+ async with httpx.AsyncClient(timeout=15.0) as h:
167
+ r = await h.get(f"{CLOB}/builder/trades",
168
+ params={"builder_code": builder_code})
169
+ r.raise_for_status()
170
+ return r.json()
oddsrail/server.py ADDED
@@ -0,0 +1,234 @@
1
+ """oddsrail — the rail AI agents use to trade prediction markets.
2
+
3
+ MCP server exposing Polymarket market data, order routing with on-chain
4
+ builder-code attribution (CLOB V2), and premium signal tools (overshoot,
5
+ dispute risk). Self-hosted and non-custodial: keys never leave the machine.
6
+
7
+ Run (stdio, for Claude Code / Desktop / any MCP client):
8
+ .venv/bin/python -m oddsrail.server
9
+ """
10
+
11
+ import json
12
+ import os
13
+
14
+ from mcp.server.mcpserver import MCPServer
15
+
16
+ from . import kalshi as kx
17
+ from . import polymarket as pm
18
+ from . import signals
19
+ from . import trading
20
+
21
+ srv = MCPServer(
22
+ name="oddsrail",
23
+ instructions=(
24
+ "Prediction-market rail for trading agents. Read tools need no keys. "
25
+ "place_order defaults to dry-run; the operator must set "
26
+ "ODDSRAIL_DRY_RUN=0 and POLYMARKET_PRIVATE_KEY to trade. Prices are "
27
+ "implied probabilities in (0,1). Orders carry the operator's builder "
28
+ "code (ODDSRAIL_BUILDER_CODE) signed into the order for on-chain "
29
+ "attribution. Kalshi tools are prefixed kalshi_ and need the "
30
+ "operator's own API key for private endpoints; Kalshi prices are "
31
+ "probabilities in (0,1) here, translated to its YES-book bid/ask "
32
+ "internally."
33
+ ),
34
+ )
35
+
36
+
37
+ def _j(x) -> str:
38
+ return json.dumps(x, default=str)
39
+
40
+
41
+ # ------------------------------ market data -------------------------------- #
42
+
43
+ @srv.tool(description="Search Polymarket markets by text (Gamma public-search "
44
+ "under the hood); empty query lists open markets. "
45
+ "Returns token ids, prices, metrics, resolution info.")
46
+ async def search_markets(query: str = "", limit: int = 10) -> str:
47
+ return _j(await pm.search_markets(query=query, limit=limit))
48
+
49
+
50
+ @srv.tool(description="Get one market's details by slug (or id).")
51
+ async def get_market(id_or_slug: str) -> str:
52
+ return _j(await pm.get_market(id_or_slug))
53
+
54
+
55
+ @srv.tool(description="Get the live orderbook (bids/asks) for a CLOB token id.")
56
+ async def get_orderbook(token_id: str) -> str:
57
+ return _j(await pm.get_orderbook(token_id))
58
+
59
+
60
+ @srv.tool(description="Recent price history for a CLOB token id: hours back, "
61
+ "at fidelity_minutes resolution.")
62
+ async def price_history(token_id: str, hours: float = 6.0,
63
+ fidelity_minutes: int = 1) -> str:
64
+ times, prices = await pm.price_history(token_id, hours, fidelity_minutes)
65
+ return _j({"points": len(times),
66
+ "series": [[t, p] for t, p in zip(times, prices)]})
67
+
68
+
69
+ @srv.tool(description="Current positions for a wallet address.")
70
+ async def get_positions(address: str, limit: int = 25) -> str:
71
+ return _j(await pm.get_positions(address, limit))
72
+
73
+
74
+ # ------------------------------ signals (premium) --------------------------- #
75
+
76
+ @srv.tool(description="PREMIUM SIGNAL — overshoot/fade detector. Analyzes a "
77
+ "token's recent price series for fresh panic jumps and "
78
+ "reports whether a fade setup is active plus this "
79
+ "market's historical reversion tendency.")
80
+ async def overshoot_signal(token_id: str, hours: float = 6.0,
81
+ threshold: float = 0.05,
82
+ lookback_s: float = 60.0) -> str:
83
+ times, prices = await pm.price_history(token_id, hours, fidelity_minutes=1)
84
+ return _j(signals.overshoot_report(times, prices,
85
+ lookback=lookback_s,
86
+ threshold=threshold))
87
+
88
+
89
+ @srv.tool(description="PREMIUM SIGNAL — dispute-risk triage. Scores 0-100 how "
90
+ "likely a market's resolution gets contested (UMA "
91
+ "dispute risk) with transparent reasons.")
92
+ async def dispute_risk(id_or_slug: str) -> str:
93
+ market = await pm.get_market(id_or_slug, full=True)
94
+ flat = dict(market)
95
+ # V2 model nests resolution info; surface it for the heuristic
96
+ res = market.get("resolution") or {}
97
+ if isinstance(res, dict):
98
+ flat.setdefault("umaResolutionStatus", res.get("uma_resolution_status")
99
+ or res.get("status"))
100
+ out = signals.dispute_risk(flat)
101
+ out["market"] = {"question": market.get("question"),
102
+ "slug": market.get("slug")}
103
+ return _j(out)
104
+
105
+
106
+ # ------------------------------ trading ------------------------------------ #
107
+
108
+ @srv.tool(description="Place a limit order. DRY-RUN by default: returns the "
109
+ "order it would post. Real trading needs "
110
+ "ODDSRAIL_DRY_RUN=0 and POLYMARKET_PRIVATE_KEY. The "
111
+ "operator's builder code is signed into the order. "
112
+ "Price = implied probability.")
113
+ async def place_order(token_id: str, side: str, price: float, size: float,
114
+ order_type: str = "GTC") -> str:
115
+ return _j(await trading.place_order(token_id, side, price, size, order_type))
116
+
117
+
118
+ @srv.tool(description="Cancel an open order by id (respects dry-run).")
119
+ async def cancel_order(order_id: str) -> str:
120
+ return _j(await trading.cancel_order(order_id))
121
+
122
+
123
+ @srv.tool(description="List the operator wallet's open orders.")
124
+ async def open_orders() -> str:
125
+ return _j(await trading.open_orders())
126
+
127
+
128
+ # ------------------------------ builder ------------------------------------ #
129
+
130
+ @srv.tool(description="Builder attribution stats: the public builder "
131
+ "leaderboard, and (if ODDSRAIL_BUILDER_CODE is set) "
132
+ "matched trades attributed to this operator's code.")
133
+ async def builder_stats(time_period: str = "WEEK") -> str:
134
+ out = {"leaderboard": await pm.builder_leaderboard(time_period)}
135
+ code = trading.builder_code()
136
+ if code:
137
+ try:
138
+ out["my_trades"] = await pm.builder_trades(code)
139
+ except Exception as e:
140
+ out["my_trades_error"] = str(e)
141
+ else:
142
+ out["note"] = "set ODDSRAIL_BUILDER_CODE to track your attributed flow"
143
+ return _j(out)
144
+
145
+
146
+ # ------------------------------ kalshi (venue #2) --------------------------- #
147
+
148
+ @srv.tool(description="Search Kalshi markets. Kalshi has no text-search "
149
+ "endpoint, so this pages open markets and filters on "
150
+ "title/ticker; auto-generated MVE combo shards are "
151
+ "excluded. Prices are dollar strings, not cents.")
152
+ async def kalshi_search_markets(query: str = "", limit: int = 10,
153
+ min_volume: float = 0.0) -> str:
154
+ return _j(await kx.search_markets(query=query, limit=limit,
155
+ min_volume=min_volume))
156
+
157
+
158
+ @srv.tool(description="Get one Kalshi market by ticker.")
159
+ async def kalshi_get_market(ticker: str) -> str:
160
+ return _j(await kx.get_market(ticker))
161
+
162
+
163
+ @srv.tool(description="Kalshi orderbook for a ticker, normalised to a YES-book "
164
+ "bid/ask view (Kalshi publishes bid ladders only; asks "
165
+ "are derived as 1 - NO bid). Raw ladders included.")
166
+ async def kalshi_get_orderbook(ticker: str, depth: int = 10) -> str:
167
+ return _j(await kx.get_orderbook(ticker, depth))
168
+
169
+
170
+ @srv.tool(description="Recent public trades for a Kalshi ticker.")
171
+ async def kalshi_get_trades(ticker: str, limit: int = 50) -> str:
172
+ return _j(await kx.get_trades(ticker, limit))
173
+
174
+
175
+ @srv.tool(description="Kalshi account balance (needs the operator's API key).")
176
+ async def kalshi_balance() -> str:
177
+ return _j(await kx.get_balance())
178
+
179
+
180
+ @srv.tool(description="Kalshi positions (needs the operator's API key).")
181
+ async def kalshi_positions(limit: int = 50) -> str:
182
+ return _j(await kx.get_positions(limit))
183
+
184
+
185
+ @srv.tool(description="Kalshi resting orders (needs the operator's API key).")
186
+ async def kalshi_open_orders(limit: int = 50) -> str:
187
+ return _j(await kx.open_orders(limit))
188
+
189
+
190
+ @srv.tool(description="Place a Kalshi limit order. State it naturally: "
191
+ "outcome yes|no, action buy|sell, price = probability of "
192
+ "THAT outcome in (0,1). Translated to Kalshi's YES-book "
193
+ "bid/ask internally. DRY-RUN by default.")
194
+ async def kalshi_place_order(ticker: str, outcome: str, action: str,
195
+ price: float, count: float,
196
+ time_in_force: str = "good_till_cancelled") -> str:
197
+ return _j(await kx.place_order(ticker, outcome, action, price, count,
198
+ time_in_force))
199
+
200
+
201
+ @srv.tool(description="Cancel a Kalshi order by id (respects dry-run).")
202
+ async def kalshi_cancel_order(order_id: str) -> str:
203
+ return _j(await kx.cancel_order(order_id))
204
+
205
+
206
+ @srv.tool(description="Server status: dry-run state, attribution config, "
207
+ "and which capabilities are enabled.")
208
+ def server_info() -> str:
209
+ return _j({
210
+ "name": "oddsrail",
211
+ "version": "0.3.0",
212
+ "dry_run": trading.dry_run(),
213
+ "trading_key_configured": bool(os.environ.get("POLYMARKET_PRIVATE_KEY")),
214
+ "builder_code_configured": bool(trading.builder_code()),
215
+ "builder_code_source": trading.builder_code_source(),
216
+ "attribution": "on-chain (CLOB V2 builder field)",
217
+ "custody": "none — self-hosted, keys stay local",
218
+ "venues": {
219
+ "polymarket": {"attribution": "on-chain builder code",
220
+ "trading_key": bool(os.environ.get("POLYMARKET_PRIVATE_KEY"))},
221
+ "kalshi": {"attribution": "none available on REST",
222
+ "credentials": kx.has_credentials(),
223
+ "environment": "demo" if os.environ.get("KALSHI_DEMO") else "production"},
224
+ },
225
+ "premium_tools": ["overshoot_signal", "dispute_risk"],
226
+ })
227
+
228
+
229
+ def main() -> None:
230
+ srv.run(transport="stdio")
231
+
232
+
233
+ if __name__ == "__main__":
234
+ main()
oddsrail/signals.py ADDED
@@ -0,0 +1,202 @@
1
+ """Signal tools: the premium layer of oddsrail.
2
+
3
+ overshoot: adapted from the polymarket-wc in-play overshoot analyzer —
4
+ detects endogenous price jumps in a recent price series and reports whether
5
+ the market is currently inside a fresh jump window (the fade-the-overreaction
6
+ setup) plus how much past jumps in this series reverted.
7
+
8
+ dispute_risk: heuristic scoring of how likely a market's resolution gets
9
+ messy (UMA disputes), from market metadata. Honest v0: a transparent
10
+ rules-based score with reasons, not a trained model.
11
+ """
12
+
13
+ import bisect
14
+ import statistics
15
+
16
+
17
+ # --------------------------------------------------------------------------- #
18
+ # Overshoot #
19
+ # --------------------------------------------------------------------------- #
20
+
21
+ def _price_at(times, prices, t):
22
+ i = bisect.bisect_right(times, t) - 1
23
+ return prices[i] if i >= 0 else None
24
+
25
+
26
+ def detect_jumps(times, prices, *, lookback=60.0, threshold=0.05,
27
+ settle=30.0, debounce=90.0, horizons=(30, 60, 120, 300)):
28
+ """Find jumps >= threshold within lookback seconds; measure reversion.
29
+
30
+ Returns a list of event dicts. Reversion fraction: +1.0 = fully retraced,
31
+ 0.0 = sticky, <0 = kept running (momentum).
32
+ """
33
+ events = []
34
+ last_t = None
35
+ n = len(times)
36
+ for i in range(n):
37
+ t, p = times[i], prices[i]
38
+ ref = _price_at(times, prices, t - lookback)
39
+ if ref is None or abs(p - ref) < threshold:
40
+ continue
41
+ if last_t is not None and (t - last_t) < debounce:
42
+ continue
43
+
44
+ direction = 1 if p > ref else -1
45
+ ext_p, ext_t = p, t
46
+ j = i
47
+ while j < n and times[j] <= t + settle:
48
+ better = prices[j] > ext_p if direction == 1 else prices[j] < ext_p
49
+ if better:
50
+ ext_p, ext_t = prices[j], times[j]
51
+ j += 1
52
+
53
+ jump = (ext_p - ref) * direction
54
+ if jump <= 0:
55
+ continue
56
+
57
+ rev = {}
58
+ for h in horizons:
59
+ ph = _price_at(times, prices, ext_t + h)
60
+ rev[h] = None if ph is None else round(((ext_p - ph) * direction) / jump, 3)
61
+
62
+ events.append({
63
+ "detected_at": t, "direction": "up" if direction == 1 else "down",
64
+ "pre_jump_price": round(ref, 4), "extreme_price": round(ext_p, 4),
65
+ "extreme_at": ext_t, "jump_size": round(jump, 4),
66
+ "reversion_by_horizon_s": rev,
67
+ })
68
+ last_t = t
69
+ return events
70
+
71
+
72
+ def overshoot_report(times, prices, *, lookback=60.0, threshold=0.05,
73
+ settle=30.0, debounce=90.0, fresh_window=300.0):
74
+ """Live overshoot signal for one price series (oldest -> newest)."""
75
+ if len(times) < 5:
76
+ return {"ok": False, "error": "not enough price history points"}
77
+
78
+ events = detect_jumps(times, prices, lookback=lookback, threshold=threshold,
79
+ settle=settle, debounce=debounce)
80
+ now = times[-1]
81
+ current = prices[-1]
82
+ fresh = [e for e in events if now - e["extreme_at"] <= fresh_window]
83
+
84
+ # historical tendency of this series: median reversion at 120s
85
+ hist = [e["reversion_by_horizon_s"].get(120) for e in events
86
+ if e["reversion_by_horizon_s"].get(120) is not None]
87
+ tendency = round(statistics.median(hist), 3) if hist else None
88
+
89
+ out = {
90
+ "ok": True,
91
+ "last_price": round(current, 4),
92
+ "jumps_detected": len(events),
93
+ "median_reversion_120s": tendency,
94
+ "series_span_s": round(now - times[0], 1),
95
+ "events": events[-5:],
96
+ "fade_setup_active": False,
97
+ }
98
+ if fresh:
99
+ e = fresh[-1]
100
+ elapsed = now - e["extreme_at"]
101
+ retraced = ((e["extreme_price"] - current)
102
+ / e["jump_size"] if e["direction"] == "up"
103
+ else (current - e["extreme_price"]) / e["jump_size"])
104
+ out.update({
105
+ "fade_setup_active": True,
106
+ "active_jump": e,
107
+ "seconds_since_extreme": round(elapsed, 1),
108
+ "retraced_so_far": round(retraced, 3),
109
+ "note": ("price jumped {} by {:.1%}; historically this series' jumps "
110
+ "retrace a median of {} of the move within 120s"
111
+ ).format(e["direction"], e["jump_size"],
112
+ f"{tendency:.0%}" if tendency is not None else "n/a"),
113
+ })
114
+ return out
115
+
116
+
117
+ # --------------------------------------------------------------------------- #
118
+ # Dispute risk #
119
+ # --------------------------------------------------------------------------- #
120
+
121
+ # words that historically correlate with contested resolutions: subjective
122
+ # criteria, source ambiguity, or human-judgment resolution language
123
+ _AMBIGUOUS = (
124
+ "consensus of", "credible report", "official announcement", "widely report",
125
+ "public statement", "in the opinion", "substantially", "materially",
126
+ "generally accepted", "confirmed by", "according to sources", "de facto",
127
+ "attempt", "significant", "major", "formal", "informal",
128
+ )
129
+
130
+ _CLEAN = (
131
+ "final score", "closing price", "settlement price", "official result",
132
+ "as reported by the associated press", "coin market cap", "coingecko",
133
+ "chainlink", "binance", "opening weekend", "box office",
134
+ )
135
+
136
+
137
+ def dispute_risk(market: dict) -> dict:
138
+ """Score 0-100 how likely this market's resolution gets contested.
139
+
140
+ Transparent heuristic v0 built from the failure patterns in 2026's UMA
141
+ dispute wave (subjective wording, long-tail topics, deadline-edge risk,
142
+ big open interest attracting oracle manipulation).
143
+ """
144
+ score = 0
145
+ reasons = []
146
+
147
+ desc = " ".join(str(market.get(k, "")) for k in
148
+ ("description", "question", "title")).lower()
149
+
150
+ hits = [w for w in _AMBIGUOUS if w in desc]
151
+ if hits:
152
+ pts = min(35, 12 * len(hits))
153
+ score += pts
154
+ reasons.append(f"+{pts}: ambiguous resolution wording ({', '.join(hits[:4])})")
155
+
156
+ clean_hits = [w for w in _CLEAN if w in desc]
157
+ if clean_hits:
158
+ score -= 15
159
+ reasons.append(f"-15: objective settlement source named ({clean_hits[0]})")
160
+
161
+ uma = str(market.get("umaResolutionStatus", "") or
162
+ market.get("uma_resolution_status", "")).lower()
163
+ if "dispute" in uma:
164
+ score += 40
165
+ reasons.append("+40: UMA status shows an active or past dispute")
166
+ elif "proposed" in uma:
167
+ score += 10
168
+ reasons.append("+10: resolution proposed, challenge window open")
169
+
170
+ if market.get("negRisk") or market.get("neg_risk"):
171
+ score += 5
172
+ reasons.append("+5: neg-risk (multi-outcome) market — more edge cases")
173
+
174
+ try:
175
+ vol = float(market.get("volume", 0) or 0)
176
+ if vol > 5_000_000:
177
+ score += 15
178
+ reasons.append("+15: large open interest (>$5M) — worth manipulating")
179
+ elif vol > 1_000_000:
180
+ score += 8
181
+ reasons.append("+8: meaningful open interest (>$1M)")
182
+ except (TypeError, ValueError):
183
+ pass
184
+
185
+ # deadline-edge risk: events that can happen right at the boundary
186
+ for w in ("by ", "before ", "deadline", "by the end of"):
187
+ if w in desc:
188
+ score += 8
189
+ reasons.append("+8: deadline-conditioned question (boundary-case risk)")
190
+ break
191
+
192
+ score = max(0, min(100, score))
193
+ band = ("low" if score < 25 else
194
+ "moderate" if score < 50 else
195
+ "elevated" if score < 75 else "high")
196
+ return {
197
+ "score": score,
198
+ "band": band,
199
+ "reasons": reasons,
200
+ "disclaimer": ("heuristic v0 — transparent rules, not a model; "
201
+ "treat as a triage flag, not a probability"),
202
+ }
oddsrail/trading.py ADDED
@@ -0,0 +1,125 @@
1
+ """Order routing with on-chain builder-code attribution (CLOB V2).
2
+
3
+ How attribution works (verified against docs.polymarket.com, Aug 2026):
4
+ the operator's bytes32 builder code — from polymarket.com/settings?tab=builder —
5
+ is placed in the `builder` field of the V2 order struct BEFORE signing, so
6
+ attribution is on-chain: every OrderFilled event on CTF Exchange V2 carries it,
7
+ and builder fees (taker <= 100 bps, maker <= 50 bps, additive to platform fees)
8
+ settle to the builder-profile wallet.
9
+
10
+ Attribution defaults (how this project sustains itself):
11
+ oddsrail ships with a project builder code set at 0 bps, so routed orders are
12
+ attributed by default. At 0 bps this costs the operator NOTHING — no fee is
13
+ added to any trade — and the project earns only a share of Polymarket's
14
+ weekly builder reward pool, which Polymarket pays out of its own program.
15
+ Set ODDSRAIL_BUILDER_CODE to your own code to claim that share instead; the
16
+ project code is a default, never a lock-in. server_info reports which is
17
+ in use.
18
+
19
+ Safety model:
20
+ - ODDSRAIL_DRY_RUN=1 (default): place_order returns the exact order it WOULD
21
+ post, never touches the exchange. Set ODDSRAIL_DRY_RUN=0 to trade.
22
+ - Trading requires POLYMARKET_PRIVATE_KEY (+ POLYMARKET_WALLET_ADDRESS for
23
+ proxy/deposit wallets). Keys stay on this machine — oddsrail is self-hosted
24
+ and non-custodial by design.
25
+ """
26
+
27
+ import os
28
+
29
+ _secure = None
30
+
31
+
32
+ def dry_run() -> bool:
33
+ return os.environ.get("ODDSRAIL_DRY_RUN", "1") not in ("0", "false", "no")
34
+
35
+
36
+ # The project's own builder code ("oddsrail" builder profile), applied when the
37
+ # operator has not set one. Registered at 0 bps maker / 0 bps taker, so it
38
+ # attributes routed flow without adding a fee to anyone's trade.
39
+ DEFAULT_BUILDER_CODE = "0xa576c5ce9fabba322d8fa3a8d16738221d1b6b2b0c57b544f757fa9e45a09a90"
40
+
41
+
42
+ def builder_code() -> str | None:
43
+ """Operator's code if set, else the project default (may be empty)."""
44
+ return os.environ.get("ODDSRAIL_BUILDER_CODE") or DEFAULT_BUILDER_CODE or None
45
+
46
+
47
+ def builder_code_source() -> str:
48
+ if os.environ.get("ODDSRAIL_BUILDER_CODE"):
49
+ return "operator (ODDSRAIL_BUILDER_CODE)"
50
+ if DEFAULT_BUILDER_CODE:
51
+ return "oddsrail project default (0 bps — no fee added; override with ODDSRAIL_BUILDER_CODE)"
52
+ return "none configured"
53
+
54
+
55
+ async def _client():
56
+ global _secure
57
+ if _secure is None:
58
+ from polymarket import AsyncSecureClient
59
+ key = os.environ.get("POLYMARKET_PRIVATE_KEY")
60
+ if not key:
61
+ raise RuntimeError(
62
+ "POLYMARKET_PRIVATE_KEY not set — trading tools are disabled. "
63
+ "Read-only tools work without it.")
64
+ _secure = await AsyncSecureClient.create(
65
+ private_key=key,
66
+ wallet=os.environ.get("POLYMARKET_WALLET_ADDRESS"),
67
+ )
68
+ return _secure
69
+
70
+
71
+ def _intent(token_id, side, price, size, order_type):
72
+ code = builder_code()
73
+ return {
74
+ "exchange": "polymarket",
75
+ "token_id": token_id,
76
+ "side": side,
77
+ "price": price,
78
+ "size": size,
79
+ "order_type": order_type,
80
+ "builder_code": code,
81
+ "attribution": ("on-chain: builder code signed into the order "
82
+ f"[{builder_code_source()}]"
83
+ if code else
84
+ "NONE — no builder code configured"),
85
+ }
86
+
87
+
88
+ async def place_order(token_id: str, side: str, price: float, size: float,
89
+ order_type: str = "GTC"):
90
+ side = side.upper()
91
+ if side not in ("BUY", "SELL"):
92
+ raise ValueError("side must be BUY or SELL")
93
+ if not (0 < price < 1):
94
+ raise ValueError("price is an implied probability in (0, 1)")
95
+ if size <= 0:
96
+ raise ValueError("size must be positive (number of shares)")
97
+
98
+ intent = _intent(token_id, side, price, size, order_type)
99
+ if dry_run():
100
+ return {"dry_run": True, "would_post": intent,
101
+ "note": "set ODDSRAIL_DRY_RUN=0 to post real orders"}
102
+
103
+ client = await _client()
104
+ resp = await client.place_limit_order(
105
+ token_id=token_id, price=str(price), size=str(size), side=side,
106
+ builder_code=builder_code())
107
+ from .polymarket import dump
108
+ return {"dry_run": False, "posted": intent, "response": dump(resp)}
109
+
110
+
111
+ async def cancel_order(order_id: str):
112
+ if dry_run():
113
+ return {"dry_run": True, "would_cancel": order_id}
114
+ client = await _client()
115
+ from .polymarket import dump
116
+ return dump(await client.cancel_order(order_id))
117
+
118
+
119
+ async def open_orders():
120
+ if not os.environ.get("POLYMARKET_PRIVATE_KEY"):
121
+ return {"note": "no trading key configured; nothing to list"}
122
+ client = await _client()
123
+ from .polymarket import dump
124
+ page = await client.list_open_orders().first_page()
125
+ return dump(list(page.items))
@@ -0,0 +1,261 @@
1
+ Metadata-Version: 2.5
2
+ Name: oddsrail
3
+ Version: 0.3.0
4
+ Summary: MCP server giving AI trading agents access to prediction markets (Polymarket, Kalshi) with on-chain builder-code attribution and trading signals.
5
+ Project-URL: Homepage, https://github.com/hmesutozsoy/oddsrail
6
+ Project-URL: Repository, https://github.com/hmesutozsoy/oddsrail
7
+ Project-URL: Issues, https://github.com/hmesutozsoy/oddsrail/issues
8
+ Author: oddsrail
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 oddsrail
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: ai-agents,kalshi,llm,mcp,model-context-protocol,polymarket,prediction-markets,trading
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: Intended Audience :: Financial and Insurance Industry
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Topic :: Office/Business :: Financial :: Investment
40
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
41
+ Requires-Python: >=3.11
42
+ Requires-Dist: cryptography>=42.0
43
+ Requires-Dist: httpx>=0.28
44
+ Requires-Dist: mcp[cli]>=2.0.0
45
+ Requires-Dist: polymarket-client>=0.6.0
46
+ Description-Content-Type: text/markdown
47
+
48
+ # oddsrail
49
+
50
+ <!-- mcp-name: io.github.hmesutozsoy/oddsrail -->
51
+
52
+ **The rail AI agents use to trade prediction markets.**
53
+
54
+ An MCP server that gives any agent (Claude Code, Claude Desktop, or anything
55
+ MCP-compatible) prediction-market access across **Polymarket and Kalshi**:
56
+ market search, orderbooks, price history, positions, and order routing — with
57
+ **on-chain builder-code attribution** on Polymarket — plus two premium signal
58
+ tools (in-play overshoot/fade detection, resolution dispute-risk).
59
+
60
+ **Free to use, and free of fees.** oddsrail ships with a project builder code
61
+ registered at **0 bps**, so orders routed through it are attributed without
62
+ adding a single basis point to anyone's trade. The project's income is a share
63
+ of Polymarket's weekly builder reward pool — paid by Polymarket's own program,
64
+ not by you. Running your own builder profile instead is one environment
65
+ variable (`ODDSRAIL_BUILDER_CODE`), and `server_info` always tells you which
66
+ code is in use. No fee tiers, no paywalled tools, no account required.
67
+
68
+ ## Why this and not Parsec
69
+
70
+ The closest competitor (parsecapi.com) is a closed-source hosted service:
71
+ it stores your exchange keys (or holds a managed wallet that signs for you),
72
+ and its Builder Program keeps **55–85% of the fees builders collect**.
73
+ oddsrail is the opposite on every axis: **self-hosted, non-custodial (keys
74
+ never leave your machine), open source, and you keep 100% of your Polymarket
75
+ builder fees** because attribution uses Polymarket's native mechanism, not a
76
+ middleman escrow. Parsec also ships zero signal/analytics tools and nothing
77
+ on resolution risk — that's our paid layer.
78
+
79
+ ## Quickstart
80
+
81
+ Python 3.11+ required.
82
+
83
+ ```bash
84
+ pip install oddsrail
85
+ ```
86
+
87
+ ```bash
88
+ claude mcp add --transport stdio oddsrail -- oddsrail
89
+ ```
90
+
91
+ Or from a clone, without installing:
92
+
93
+ ```bash
94
+ python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
95
+ ```
96
+
97
+ ```bash
98
+ claude mcp add --transport stdio oddsrail -- /abs/path/to/oddsrail/.venv/bin/python -m oddsrail.server
99
+ ```
100
+
101
+ Then ask the agent: *"search markets about the World Cup final and run the
102
+ overshoot signal on the favorite"*.
103
+
104
+ ## How attribution works (CLOB V2, verified Aug 2026)
105
+
106
+ 1. Get your **builder code** (a bytes32) at polymarket.com → **Settings →
107
+ Builders**. Set your fee rates there: taker up to 100 bps, maker up to
108
+ 50 bps — additive on top of platform fees, settled to your builder wallet.
109
+ 2. `export ODDSRAIL_BUILDER_CODE=0x...` where the server runs.
110
+ 3. Every order any agent routes through `place_order` has the code placed in
111
+ the V2 order struct's `builder` field **before signing** — attribution is
112
+ on-chain, visible in every `OrderFilled` event on CTF Exchange V2.
113
+ 4. Verify with the `builder_stats` tool (public builder-trades endpoint +
114
+ leaderboard).
115
+
116
+ If you skip this, orders carry the bundled oddsrail code at 0 bps — costing
117
+ you nothing and funding the project. If you set your own, yours wins; the
118
+ default is a default, not a lock-in.
119
+
120
+ ## Environment variables
121
+
122
+ | Variable | Default | Meaning |
123
+ |---|---|---|
124
+ | `ODDSRAIL_DRY_RUN` | `1` | `1` = orders are simulated and returned, never posted. Set `0` to trade. |
125
+ | `ODDSRAIL_BUILDER_CODE` | project default | Your bytes32 builder code. Overrides the bundled project default so attribution (and any reward-pool share) accrues to you instead. |
126
+ | `POLYMARKET_PRIVATE_KEY` | unset | Operator wallet key; required only for real trading. Never leaves this machine. |
127
+ | `POLYMARKET_WALLET_ADDRESS` | unset | Proxy/deposit wallet address, if the account uses one. |
128
+
129
+ ## Status — live-verified 2026-08-23
130
+
131
+ All 10 callable tools were driven end-to-end through a real MCP client
132
+ session against live Polymarket, from the Finland VPS (`/opt/oddsrail`).
133
+ Verified working: search, market lookup, orderbook (9/65 levels), price
134
+ history (361 pts), overshoot signal, dispute-risk, builder leaderboard,
135
+ dry-run order, open orders, server info.
136
+
137
+ Field mappings were corrected against the real API during that run — the
138
+ docs-guessed shapes were wrong in three places (`outcomes` is a dict keyed
139
+ `yes`/`no`, `search()` nests markets inside events, and book/volume/
140
+ resolution data live in `prices`/`metrics`/`state`/`resolution`
141
+ sub-objects).
142
+
143
+ ## ⚠️ Network note
144
+
145
+ Polymarket API domains are **blocked on Turkish networks (BTK)** — local
146
+ testing fails TLS with a block page. Run the server where Polymarket is
147
+ reachable (the Finland VPS at `/opt/oddsrail`, a VPN, or any unblocked
148
+ network). The signal logic and MCP layer are fully testable offline.
149
+
150
+ ## Kalshi (venue #2)
151
+
152
+ Kalshi is **bring-your-own-key and single-tenant by design**: the operator
153
+ supplies their own API key, trades their own account, and this server caches
154
+ nothing. That is deliberate — Kalshi's Developer Agreement limits API use to a
155
+ member's own trading (§3), bars facilitating other members' trading (§3.2) and
156
+ sublicensing the API (§3.7), and restricts storing/sharing API data (§3.1). A
157
+ hosted multi-tenant Kalshi service would not be compliant; a self-hosted one is.
158
+
159
+ **Attribution does not exist here.** Kalshi Builder Codes are a
160
+ Solana/DFlow/Jupiter integration — there is no builder or affiliate field
161
+ anywhere on the REST API, so Kalshi order flow cannot be attributed or
162
+ monetised the way Polymarket's can. Kalshi is in oddsrail for coverage and
163
+ signal reach, not for routing revenue.
164
+
165
+ Two shapes on this API are easy to get wrong, so oddsrail normalises both:
166
+
167
+ - **Prices are dollar strings, not cents** (`"0.5600"`), sizes are fixed-point
168
+ strings (`"10.00"`); the legacy integer-cent fields were removed in March
169
+ 2026. All arithmetic uses `Decimal`.
170
+ - **The orderbook is bids-only on both sides.** `yes_dollars` and `no_dollars`
171
+ are both bid ladders, ascending — so the best bid is the *last* element, and
172
+ a NO bid at $0.99 *is* a YES ask at $0.01. `kalshi_get_orderbook` returns a
173
+ conventional best-first bid/ask view of the YES book plus the raw ladders.
174
+
175
+ Order placement speaks natural terms — `outcome` (yes/no), `action`
176
+ (buy/sell), `price` = probability of that outcome — and translates to Kalshi's
177
+ YES-book `bid`/`ask` internally (buy NO @ 0.25 becomes ask @ 0.75). That
178
+ translation is unit-tested, since it is the obvious place to ship an
179
+ inverted-position bug.
180
+
181
+ Credentials: `KALSHI_KEY_ID` plus `KALSHI_PRIVATE_KEY_PATH` (PKCS#8 PEM) or
182
+ `KALSHI_PRIVATE_KEY`. Set `KALSHI_DEMO=1` to hit the demo environment. Read
183
+ tools need no key at all.
184
+
185
+ ## Tools (21)
186
+
187
+ - `search_markets`, `get_market`, `get_orderbook`, `price_history`,
188
+ `get_positions` — read-only, no keys
189
+ - `overshoot_signal` — premium: fresh panic-jump detection + this market's
190
+ historical reversion tendency (ported from the polymarket-wc analyzer)
191
+ - `dispute_risk` — premium: transparent 0–100 heuristic for contested
192
+ (UMA-dispute-prone) resolutions
193
+ - `place_order`, `cancel_order`, `open_orders` — trading, dry-run by default
194
+ - `builder_stats` — attribution verification + public builder leaderboard
195
+ - `server_info` — config status, per-venue
196
+
197
+ Kalshi: `kalshi_search_markets`, `kalshi_get_market`, `kalshi_get_orderbook`,
198
+ `kalshi_get_trades`, `kalshi_balance`, `kalshi_positions`,
199
+ `kalshi_open_orders`, `kalshi_place_order`, `kalshi_cancel_order`.
200
+
201
+ ## Stack notes
202
+
203
+ - Official unified SDK `polymarket-client` (0.6.x): `AsyncPublicClient` for
204
+ data, `AsyncSecureClient.place_limit_order(..., builder_code=...)` for
205
+ attributed orders. The legacy `py-clob-client` is archived and cannot
206
+ attach builder codes — do not use it.
207
+ - MCP SDK 2.0: `MCPServer` from `mcp.server.mcpserver` (the old
208
+ `mcp.server.fastmcp.FastMCP` import is gone in 2.x).
209
+ - Kalshi is on plain `httpx` + `cryptography`, not the official SDK:
210
+ `kalshi-python-sync` requires Python >=3.13 and re-releases weekly in
211
+ lockstep with the spec version. Auth is RSA-PSS(SHA256, salt=digest length)
212
+ over `str(unix_ms) + METHOD + path`, where the path includes `/trade-api/v2`
213
+ and excludes the query string. Base URL is now
214
+ `external-api.kalshi.com`.
215
+ - x402 (planned): the official `x402` PyPI package (v2.20+) can wrap MCP
216
+ tools directly (`x402.mcp`, payment rides in tool-call `_meta`), but its
217
+ MCP helpers currently target mcp 1.x — integrating means pinning
218
+ `mcp>=1.28,<2` or waiting for the 2.x-compatible release. Mainnet
219
+ settlement needs a facilitator (Coinbase CDP: 1,000 free settlements/mo,
220
+ then $0.001). Keep free tiers of both signals so registries can index the
221
+ server.
222
+
223
+ ## What the builder economy looks like (live, 2026-08-23)
224
+
225
+ Pulled from the public leaderboard via `builder_stats`:
226
+
227
+ | | weekly | all-time |
228
+ |---|---|---|
229
+ | #1 (betmoar) | $2.31M | $2.10B |
230
+ | median of top 25 | $127K | $88.2M |
231
+ | **entry to top 25** | **$42K** | $36.8M |
232
+
233
+ The instructive rows are the small-user ones: MagicMarkets routes $354K/week
234
+ with **1 active user**, Gate $1.11M/week with 2, PolymarketScan $277K with 3.
235
+ Those are bot operators routing their own flow — oddsrail's exact target
236
+ customer — and they show a single serious agent trader is worth real volume.
237
+ Wallets (MetaMask, 37K users) dominate on user count, not on volume per user.
238
+
239
+ ## Roadmap
240
+
241
+ 1. ~~Live smoke test from an unblocked network~~ — done 2026-08-23, all tools pass
242
+ 2. Register builder code (polymarket.com → Settings → Builders), set fees to
243
+ 0 bps at launch, export `ODDSRAIL_BUILDER_CODE`; first attributed order on
244
+ a tiny size
245
+ 3. ~~Kalshi as venue #2~~ — done 2026-08-23, 9 tools, verified live
246
+ 4. x402 paid wrapping for the two signals once the mcp-2.x conflict clears
247
+ 5. Registry listings: official MCP registry (`mcp-publisher`, PyPI
248
+ `mcp-name:` marker), Smithery (needs public streamable-HTTP + a free
249
+ tool for their scanner), Glama (`glama.json`)
250
+
251
+ ## Listing / distribution
252
+
253
+ - **GitHub**: https://github.com/hmesutozsoy/oddsrail (public, MIT)
254
+ - **Glama**: auto-crawls GitHub; `glama.json` in the repo root claims
255
+ maintainership.
256
+ - **Official MCP registry**: `server.json` is ready. Publishing needs the
257
+ package on PyPI first (the registry verifies ownership via an
258
+ `mcp-name: io.github.hmesutozsoy/oddsrail` marker in the PyPI README),
259
+ then `mcp-publisher login github && mcp-publisher publish`.
260
+ - **Smithery**: requires a public HTTPS streamable-HTTP endpoint — available
261
+ once oddsrail is hosted rather than run locally over stdio.
@@ -0,0 +1,11 @@
1
+ oddsrail/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ oddsrail/kalshi.py,sha256=D3Z0PsBV4fcMbeC5J-LCPZlJBxETQm7weXwHNfzUNEY,11706
3
+ oddsrail/polymarket.py,sha256=6l_fCx7bTb_-ZfOpT1SeL_vZTpbSVPDS38gybSF4eR8,6002
4
+ oddsrail/server.py,sha256=U9XnXSF29ugfbDoZ4vmFkHGIki7pbDW-bMgGWk1htH8,9827
5
+ oddsrail/signals.py,sha256=CbkAfAylvmiNzzD5DUTDXuKYhRggk2rbBOsqeZeA0Bs,7763
6
+ oddsrail/trading.py,sha256=IeotYKrAI3Ox6miW8LSm-k9I8sBKurZY5gnG9ortlHY,4805
7
+ oddsrail-0.3.0.dist-info/METADATA,sha256=UnLorL26WqBdR_yrVfADzaEI486B1rmQHwLRELwLTPs,12774
8
+ oddsrail-0.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
+ oddsrail-0.3.0.dist-info/entry_points.txt,sha256=OUyo11JafqYpJ9xvI7bqRm0SKMlzwifAert3ZSKkKt8,50
10
+ oddsrail-0.3.0.dist-info/licenses/LICENSE,sha256=x5otxHpKt24xA6z0y05NfPHyTdWd3K5SRN69W8v7mJk,1065
11
+ oddsrail-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ oddsrail = oddsrail.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 oddsrail
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.