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 +0 -0
- oddsrail/kalshi.py +299 -0
- oddsrail/polymarket.py +170 -0
- oddsrail/server.py +234 -0
- oddsrail/signals.py +202 -0
- oddsrail/trading.py +125 -0
- oddsrail-0.3.0.dist-info/METADATA +261 -0
- oddsrail-0.3.0.dist-info/RECORD +11 -0
- oddsrail-0.3.0.dist-info/WHEEL +4 -0
- oddsrail-0.3.0.dist-info/entry_points.txt +2 -0
- oddsrail-0.3.0.dist-info/licenses/LICENSE +21 -0
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,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.
|