synpath 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- synpath/__init__.py +183 -0
- synpath/__main__.py +66 -0
- synpath/base.py +723 -0
- synpath/bucket.py +154 -0
- synpath/client.py +356 -0
- synpath/engine/__init__.py +37 -0
- synpath/engine/__main__.py +354 -0
- synpath/engine/alerts.py +170 -0
- synpath/engine/engine.py +888 -0
- synpath/engine/eod.py +154 -0
- synpath/engine/events.py +140 -0
- synpath/engine/fair_values.py +117 -0
- synpath/engine/feeds.py +220 -0
- synpath/engine/journal.py +907 -0
- synpath/engine/ledger.py +353 -0
- synpath/engine/orders/__init__.py +42 -0
- synpath/engine/orders/base.py +441 -0
- synpath/engine/orders/day.py +72 -0
- synpath/engine/orders/iceberg.py +121 -0
- synpath/engine/orders/manager.py +223 -0
- synpath/engine/orders/oco.py +255 -0
- synpath/engine/orders/peg.py +168 -0
- synpath/engine/orders/routed.py +496 -0
- synpath/engine/orders/stop.py +240 -0
- synpath/engine/orders/taker.py +187 -0
- synpath/engine/orders/twap.py +190 -0
- synpath/engine/paper.py +532 -0
- synpath/engine/reconcile.py +279 -0
- synpath/engine/risk.py +403 -0
- synpath/engine/router.py +261 -0
- synpath/errors.py +98 -0
- synpath/history.py +71 -0
- synpath/hosted.py +86 -0
- synpath/hosted_auth.py +201 -0
- synpath/ids.py +61 -0
- synpath/kalshi.py +1378 -0
- synpath/matching.py +86 -0
- synpath/polymarket.py +1004 -0
- synpath/polymarket_us.py +989 -0
- synpath/remote.py +195 -0
- synpath/server/__init__.py +98 -0
- synpath/server/__main__.py +118 -0
- synpath/server/api.py +439 -0
- synpath/server/errors.py +87 -0
- synpath/server/local.py +96 -0
- synpath/server/models.py +75 -0
- synpath/server/serve.py +236 -0
- synpath/server/store.py +363 -0
- synpath/server/trading.py +764 -0
- synpath/trading/__init__.py +79 -0
- synpath/trading/__main__.py +69 -0
- synpath/trading/base.py +126 -0
- synpath/trading/credentials.py +400 -0
- synpath/trading/errors.py +94 -0
- synpath/trading/init.py +233 -0
- synpath/trading/instruments.py +162 -0
- synpath/trading/kalshi.py +957 -0
- synpath/trading/limiter.py +177 -0
- synpath/trading/money.py +172 -0
- synpath/trading/polymarket.py +1362 -0
- synpath/trading/polymarket_signing.py +478 -0
- synpath/trading/polymarket_us.py +705 -0
- synpath/trading/polymarket_us_exchange.py +825 -0
- synpath/trading/types.py +414 -0
- synpath/types.py +608 -0
- synpath/ws/__init__.py +55 -0
- synpath/ws/base.py +544 -0
- synpath/ws/grpc.py +578 -0
- synpath/ws/kalshi.py +418 -0
- synpath/ws/polymarket.py +430 -0
- synpath/ws/polymarket_us.py +299 -0
- synpath/ws/polymarket_us_exchange.py +754 -0
- synpath-0.1.0.dist-info/METADATA +224 -0
- synpath-0.1.0.dist-info/RECORD +77 -0
- synpath-0.1.0.dist-info/WHEEL +4 -0
- synpath-0.1.0.dist-info/entry_points.txt +2 -0
- synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
synpath/server/api.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""The HTTP surface over the library. No authentication, on purpose.
|
|
2
|
+
|
|
3
|
+
This app is the *inner* app. It knows how to turn a request into a library
|
|
4
|
+
call and a library exception into a status code, and nothing else — no API
|
|
5
|
+
keys, no quotas, no metering, no cross-venue logic. A hosted deployment mounts
|
|
6
|
+
it under its own middleware and owns all of that:
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from fastapi import FastAPI, Depends
|
|
10
|
+
from synpath.server import create_app, VenueRegistry
|
|
11
|
+
|
|
12
|
+
outer = FastAPI()
|
|
13
|
+
outer.state.registry = VenueRegistry() # optional; a default is used otherwise
|
|
14
|
+
outer.include_router(create_app(docs=False).router, dependencies=[Depends(my_auth)])
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Note the registry on the host app. A mounted route is served by the host, so
|
|
18
|
+
`request.app` is the host's app and that is where the registry is looked for.
|
|
19
|
+
|
|
20
|
+
Routes are written out one by one rather than dispatched dynamically from a
|
|
21
|
+
method name. Dynamic dispatch is less code and produces an OpenAPI document
|
|
22
|
+
that says every response is `object`, which makes generated clients untyped —
|
|
23
|
+
and a typed TypeScript client generated from this schema is the whole reason
|
|
24
|
+
the server layer exists.
|
|
25
|
+
|
|
26
|
+
Handlers are `def`, not `async def`. The library is synchronous, so Starlette
|
|
27
|
+
runs them in a worker thread; writing them `async` would block the event loop
|
|
28
|
+
on every venue call.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import logging
|
|
33
|
+
import threading
|
|
34
|
+
from contextlib import asynccontextmanager
|
|
35
|
+
from typing import Literal, Annotated, Any, Iterator
|
|
36
|
+
|
|
37
|
+
from fastapi import Depends, FastAPI, HTTPException, Path, Query, Request
|
|
38
|
+
from fastapi.responses import JSONResponse
|
|
39
|
+
|
|
40
|
+
import synpath
|
|
41
|
+
from synpath import (
|
|
42
|
+
BadRequest,
|
|
43
|
+
Candle,
|
|
44
|
+
Event,
|
|
45
|
+
Exchange,
|
|
46
|
+
ExchangeError,
|
|
47
|
+
ExchangeNotAvailable,
|
|
48
|
+
FeeSchedule,
|
|
49
|
+
Market,
|
|
50
|
+
MarketNotFound,
|
|
51
|
+
NetworkError,
|
|
52
|
+
NotSupported,
|
|
53
|
+
OrderBook,
|
|
54
|
+
RateLimitExceeded,
|
|
55
|
+
RequestTimeout,
|
|
56
|
+
Series,
|
|
57
|
+
SynpathError,
|
|
58
|
+
Trade,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
from ..base import timeframe_seconds
|
|
62
|
+
from .errors import http_error, install_error_handlers
|
|
63
|
+
from .models import ErrorBody, PageResponse, VenueInfo
|
|
64
|
+
|
|
65
|
+
log = logging.getLogger("synpath.server")
|
|
66
|
+
|
|
67
|
+
STATUS_FOR: dict[type[Exception], int] = {
|
|
68
|
+
MarketNotFound: 404,
|
|
69
|
+
BadRequest: 400,
|
|
70
|
+
NotSupported: 501,
|
|
71
|
+
RateLimitExceeded: 429,
|
|
72
|
+
RequestTimeout: 504,
|
|
73
|
+
ExchangeNotAvailable: 502,
|
|
74
|
+
NetworkError: 502,
|
|
75
|
+
ExchangeError: 502,
|
|
76
|
+
}
|
|
77
|
+
"""Library exception to status code.
|
|
78
|
+
|
|
79
|
+
The two that are worth explaining: `NotSupported` is 501 rather than 404,
|
|
80
|
+
because the venue has no such capability at all and no id would have worked;
|
|
81
|
+
and a venue failing us is 502, not 500, because this service is fine and is
|
|
82
|
+
reporting on an upstream that is not.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _status_for(exc: Exception) -> int:
|
|
87
|
+
for kind in type(exc).__mro__:
|
|
88
|
+
if kind in STATUS_FOR:
|
|
89
|
+
return STATUS_FOR[kind]
|
|
90
|
+
return 500
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _venue_of(exc: Exception) -> str | None:
|
|
94
|
+
text = str(exc)
|
|
95
|
+
head = text.split(":", 1)[0].strip().lower()
|
|
96
|
+
return head if head in synpath.exchanges else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class VenueRegistry:
|
|
100
|
+
"""One adapter instance per venue, reused across requests.
|
|
101
|
+
|
|
102
|
+
Reused rather than constructed per request because the rate limiter and
|
|
103
|
+
the HTTP connection pool both have to be shared: a venue counts requests
|
|
104
|
+
per account and IP, so a fresh client per request would hand every request
|
|
105
|
+
a full token bucket and sail straight past the limit.
|
|
106
|
+
|
|
107
|
+
Nothing request-scoped is kept on an adapter — paging cursors ride on the
|
|
108
|
+
returned `Page` — so sharing one across threads is safe.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, exchanges: dict[str, Exchange] | None = None):
|
|
112
|
+
self._exchanges: dict[str, Exchange] = exchanges or {}
|
|
113
|
+
# Handlers are sync `def`, so Starlette runs them in a thread pool and
|
|
114
|
+
# two requests really do race here. Without this, both would build an
|
|
115
|
+
# adapter, one would be dropped still holding an open connection pool,
|
|
116
|
+
# and for that moment two rate limiters would each think they owned the
|
|
117
|
+
# venue's whole budget.
|
|
118
|
+
self._lock = threading.Lock()
|
|
119
|
+
|
|
120
|
+
def get(self, venue: str) -> Exchange:
|
|
121
|
+
if venue not in synpath.exchanges:
|
|
122
|
+
raise http_error(
|
|
123
|
+
404, "unknown_venue",
|
|
124
|
+
f"unknown venue {venue!r}; available: {', '.join(sorted(synpath.exchanges))}",
|
|
125
|
+
)
|
|
126
|
+
existing = self._exchanges.get(venue)
|
|
127
|
+
if existing is not None:
|
|
128
|
+
return existing
|
|
129
|
+
with self._lock:
|
|
130
|
+
# Checked again under the lock: another thread may have built it
|
|
131
|
+
# while this one waited.
|
|
132
|
+
if venue not in self._exchanges:
|
|
133
|
+
self._exchanges[venue] = synpath.exchange(venue)
|
|
134
|
+
return self._exchanges[venue]
|
|
135
|
+
|
|
136
|
+
def close(self) -> None:
|
|
137
|
+
with self._lock:
|
|
138
|
+
for exchange in self._exchanges.values():
|
|
139
|
+
exchange.close()
|
|
140
|
+
self._exchanges.clear()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
DEFAULT_REGISTRY = VenueRegistry()
|
|
144
|
+
"""Used when a request arrives on an app that carries no registry of its own —
|
|
145
|
+
which is what happens when the router is mounted on somebody else's app."""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_venue(
|
|
149
|
+
request: Request,
|
|
150
|
+
venue: Annotated[str, Path(description="Venue id, e.g. `kalshi`.")],
|
|
151
|
+
) -> Exchange:
|
|
152
|
+
"""Resolve the adapter for this request.
|
|
153
|
+
|
|
154
|
+
The registry is looked up on the app handling the request, so a host that
|
|
155
|
+
mounts this router and wants its own registry sets it on its own app:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
outer.state.registry = VenueRegistry()
|
|
159
|
+
outer.include_router(create_app().router)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Without that, requests fall back to the process-wide default, which is the
|
|
163
|
+
right answer for the standalone case and harmless for the mounted one.
|
|
164
|
+
"""
|
|
165
|
+
registry = getattr(request.app.state, "registry", None) or DEFAULT_REGISTRY
|
|
166
|
+
return registry.get(venue)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
Venue = Annotated[Exchange, Depends(get_venue)]
|
|
170
|
+
"""Module level on purpose: `from __future__ import annotations` turns every
|
|
171
|
+
annotation into a string, and FastAPI resolves those against module globals.
|
|
172
|
+
Defined inside the factory it would be invisible and the app would fail to
|
|
173
|
+
build its schema."""
|
|
174
|
+
|
|
175
|
+
RESPONSES: dict[int | str, dict[str, Any]] = {
|
|
176
|
+
400: {"model": ErrorBody, "description": "Invalid parameters."},
|
|
177
|
+
404: {"model": ErrorBody, "description": "No such venue or market."},
|
|
178
|
+
429: {"model": ErrorBody, "description": "Venue rate limit; retry after backing off."},
|
|
179
|
+
501: {"model": ErrorBody, "description": "This venue does not offer the capability."},
|
|
180
|
+
502: {"model": ErrorBody, "description": "The venue failed or rejected the request."},
|
|
181
|
+
504: {"model": ErrorBody, "description": "The venue did not answer in time."},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def create_app(
|
|
186
|
+
*,
|
|
187
|
+
registry: VenueRegistry | None = None,
|
|
188
|
+
title: str = "synpath",
|
|
189
|
+
docs: bool = True,
|
|
190
|
+
) -> FastAPI:
|
|
191
|
+
"""Build the app. Pass `registry` to inject stub adapters in tests."""
|
|
192
|
+
registry = registry or DEFAULT_REGISTRY
|
|
193
|
+
|
|
194
|
+
@asynccontextmanager
|
|
195
|
+
async def lifespan(_: FastAPI):
|
|
196
|
+
yield
|
|
197
|
+
# Closes the registry this app was built with, not the one belonging to
|
|
198
|
+
# whichever app runs the lifespan: `include_router` merges lifespan
|
|
199
|
+
# contexts, so a host mounting this router runs this function with its
|
|
200
|
+
# own app object, which has no registry of ours on it.
|
|
201
|
+
registry.close()
|
|
202
|
+
|
|
203
|
+
app = FastAPI(
|
|
204
|
+
lifespan=lifespan,
|
|
205
|
+
title=title,
|
|
206
|
+
version=synpath.__version__,
|
|
207
|
+
summary="One API for prediction markets.",
|
|
208
|
+
description=(
|
|
209
|
+
"Read-only market data for Kalshi and Polymarket behind one "
|
|
210
|
+
"interface.\n\n"
|
|
211
|
+
"Prices are labelled by source: `bid`, `ask`, `mid` and `last` are "
|
|
212
|
+
"separate fields, `mid` is null unless both sides are quoted, and a "
|
|
213
|
+
"null price means the venue published none rather than zero. "
|
|
214
|
+
"Candles carry `price_source`; volume figures carry `volume_unit`, "
|
|
215
|
+
"because Kalshi counts contracts and Polymarket counts collateral.\n\n"
|
|
216
|
+
"Call `GET /venues` first: `has` says which capabilities a venue "
|
|
217
|
+
"offers, and an unsupported one answers 501."
|
|
218
|
+
),
|
|
219
|
+
docs_url="/docs" if docs else None,
|
|
220
|
+
redoc_url=None,
|
|
221
|
+
openapi_url="/openapi.json" if docs else None,
|
|
222
|
+
)
|
|
223
|
+
app.state.registry = registry
|
|
224
|
+
|
|
225
|
+
# -- errors -------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
install_error_handlers(app, _status_for)
|
|
228
|
+
|
|
229
|
+
# -- discovery ----------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
@app.get("/", tags=["meta"], summary="What this server is")
|
|
232
|
+
def index(request: Request) -> dict[str, Any]:
|
|
233
|
+
"""A landing answer for someone who opens the address in a browser."""
|
|
234
|
+
links = {"docs": "/docs", "openapi": "/openapi.json", "health": "/health", "venues": "/venues"}
|
|
235
|
+
trading = getattr(request.app.state, "trading_path", None)
|
|
236
|
+
if trading:
|
|
237
|
+
links["trading"] = trading
|
|
238
|
+
return {"name": "synpath", "version": synpath.__version__, "links": links}
|
|
239
|
+
|
|
240
|
+
@app.get("/health", tags=["meta"], summary="Liveness")
|
|
241
|
+
def health() -> dict[str, str]:
|
|
242
|
+
"""Answers without touching a venue, so it stays useful when one is down."""
|
|
243
|
+
return {"status": "ok", "version": synpath.__version__}
|
|
244
|
+
|
|
245
|
+
@app.get("/venues", tags=["meta"], summary="List venues and capabilities")
|
|
246
|
+
def list_venues() -> list[VenueInfo]:
|
|
247
|
+
"""Read this before anything else. `has` tells you which calls a venue
|
|
248
|
+
answers and which it will refuse with a 501."""
|
|
249
|
+
return [
|
|
250
|
+
VenueInfo(
|
|
251
|
+
id=cls.id, name=cls.name, book_model=cls.book_model, has=dict(cls.has),
|
|
252
|
+
)
|
|
253
|
+
for cls in synpath.exchanges.values()
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
@app.get("/venues/{venue}", tags=["meta"], summary="One venue", responses=RESPONSES)
|
|
257
|
+
def get_venue_info(exchange: Venue) -> VenueInfo:
|
|
258
|
+
return VenueInfo(
|
|
259
|
+
id=exchange.id, name=exchange.name,
|
|
260
|
+
book_model=exchange.book_model, has=dict(exchange.has),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# -- catalog ------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
@app.get(
|
|
266
|
+
"/venues/{venue}/markets", tags=["catalog"],
|
|
267
|
+
summary="List markets", responses=RESPONSES,
|
|
268
|
+
)
|
|
269
|
+
def list_markets(
|
|
270
|
+
exchange: Venue,
|
|
271
|
+
query: Annotated[str | None, Query(description=(
|
|
272
|
+
"Text filter. Server-side on Polymarket; on Kalshi the page is "
|
|
273
|
+
"filtered locally, so a page may come back short while "
|
|
274
|
+
"`next_cursor` still points at more."
|
|
275
|
+
))] = None,
|
|
276
|
+
limit: Annotated[int, Query(ge=1, le=100)] = 100,
|
|
277
|
+
cursor: Annotated[str | None, Query(description=(
|
|
278
|
+
"Opaque cursor from a previous `next_cursor`. There is no `offset`: "
|
|
279
|
+
"the catalog changes between calls and offset paging silently drops "
|
|
280
|
+
"or repeats rows when it does."
|
|
281
|
+
))] = None,
|
|
282
|
+
status: Annotated[str, Query(pattern="^(open|closed|settled|all)$")] = "open",
|
|
283
|
+
sort: Annotated[str | None, Query(description=(
|
|
284
|
+
"`volume`, `liquidity` or `newest`. Any other value is refused with 400, and a venue that cannot "
|
|
285
|
+
"sort answers 501 rather than returning unsorted rows."
|
|
286
|
+
))] = None,
|
|
287
|
+
) -> PageResponse[Market]:
|
|
288
|
+
page = exchange.fetch_markets(
|
|
289
|
+
query=query, limit=limit, cursor=cursor, status=status, sort=sort,
|
|
290
|
+
)
|
|
291
|
+
return PageResponse[Market](
|
|
292
|
+
data=list(page), next_cursor=page.next_cursor, count=len(page),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
@app.get(
|
|
296
|
+
"/venues/{venue}/markets/{market_id}", tags=["catalog"],
|
|
297
|
+
summary="One market", responses=RESPONSES,
|
|
298
|
+
)
|
|
299
|
+
def get_market(
|
|
300
|
+
exchange: Venue,
|
|
301
|
+
market_id: Annotated[str, Path(description=(
|
|
302
|
+
"Venue market id: a Kalshi ticker, or a Polymarket numeric id. "
|
|
303
|
+
"No path converter here on purpose — a greedy one would swallow "
|
|
304
|
+
"the `/trades` and `/fee` suffixes below."
|
|
305
|
+
))],
|
|
306
|
+
) -> Market:
|
|
307
|
+
return exchange.fetch_market(market_id)
|
|
308
|
+
|
|
309
|
+
@app.get(
|
|
310
|
+
"/venues/{venue}/events", tags=["catalog"],
|
|
311
|
+
summary="List events with markets nested", responses=RESPONSES,
|
|
312
|
+
)
|
|
313
|
+
def list_events(
|
|
314
|
+
exchange: Venue,
|
|
315
|
+
query: Annotated[str | None, Query()] = None,
|
|
316
|
+
limit: Annotated[int, Query(ge=1, le=100)] = 100,
|
|
317
|
+
cursor: Annotated[str | None, Query()] = None,
|
|
318
|
+
status: Annotated[str, Query(pattern="^(open|closed|settled|all)$")] = "open",
|
|
319
|
+
) -> PageResponse[Event]:
|
|
320
|
+
page = exchange.fetch_events(
|
|
321
|
+
query=query, limit=limit, cursor=cursor, status=status,
|
|
322
|
+
)
|
|
323
|
+
return PageResponse[Event](
|
|
324
|
+
data=list(page), next_cursor=page.next_cursor, count=len(page),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# -- market data --------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
@app.get(
|
|
330
|
+
"/venues/{venue}/markets/{market_id}/book", tags=["market data"],
|
|
331
|
+
summary="Order book for one side of a market", responses=RESPONSES,
|
|
332
|
+
)
|
|
333
|
+
def get_order_book(
|
|
334
|
+
exchange: Venue,
|
|
335
|
+
market_id: Annotated[str, Path(description=(
|
|
336
|
+
"A Synpath id (`kalshi:KXFOO-25`, `polymarket:2252244`) or the "
|
|
337
|
+
"venue's own id for this venue."
|
|
338
|
+
))],
|
|
339
|
+
side: Annotated[Literal["yes", "no"], Query(description=(
|
|
340
|
+
"Which side the book is priced for. `no` is what NO costs."
|
|
341
|
+
))] = "yes",
|
|
342
|
+
depth: Annotated[int | None, Query(ge=1, le=1000)] = None,
|
|
343
|
+
) -> OrderBook:
|
|
344
|
+
"""Bids descend, asks ascend, best first on both sides.
|
|
345
|
+
|
|
346
|
+
On Kalshi and Polymarket US one book serves both sides, so the NO
|
|
347
|
+
view is the YES book reflected through the market's face value and
|
|
348
|
+
the response says so with `derived: true`. On Polymarket the NO book
|
|
349
|
+
is a real book of its own.
|
|
350
|
+
"""
|
|
351
|
+
return exchange.fetch_order_book(market_id, side=side, depth=depth)
|
|
352
|
+
|
|
353
|
+
@app.get(
|
|
354
|
+
"/venues/{venue}/markets/{market_id}/trades", tags=["market data"],
|
|
355
|
+
summary="Recent trades", responses=RESPONSES,
|
|
356
|
+
)
|
|
357
|
+
def list_trades(
|
|
358
|
+
exchange: Venue,
|
|
359
|
+
market_id: str,
|
|
360
|
+
since: Annotated[int | None, Query(description="Milliseconds since epoch, inclusive.")] = None,
|
|
361
|
+
limit: Annotated[int, Query(ge=1, le=100)] = 100,
|
|
362
|
+
cursor: Annotated[str | None, Query()] = None,
|
|
363
|
+
) -> PageResponse[Trade]:
|
|
364
|
+
"""Oldest first, in the YES price. `side` is what the taker did on the
|
|
365
|
+
YES leg: `buy` took YES, `sell` took NO."""
|
|
366
|
+
page = exchange.fetch_trades(market_id, since=since, limit=limit, cursor=cursor)
|
|
367
|
+
return PageResponse[Trade](
|
|
368
|
+
data=list(page), next_cursor=page.next_cursor, count=len(page),
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
@app.get(
|
|
372
|
+
"/venues/{venue}/markets/{market_id}/candles", tags=["market data"],
|
|
373
|
+
summary="OHLCV candles, in the YES price", responses=RESPONSES,
|
|
374
|
+
)
|
|
375
|
+
def list_candles(
|
|
376
|
+
exchange: Venue,
|
|
377
|
+
market_id: str,
|
|
378
|
+
timeframe: Annotated[str, Query(description=(
|
|
379
|
+
"Kalshi accepts `1m`, `1h`, `1d` only and refuses anything else "
|
|
380
|
+
"rather than rounding to a period you did not ask for."
|
|
381
|
+
))] = "1h",
|
|
382
|
+
since: Annotated[int | None, Query(description=(
|
|
383
|
+
"Milliseconds since epoch. With it, bars are read forward from here and the "
|
|
384
|
+
"first `limit` returned; without it, the newest `limit` bars."
|
|
385
|
+
))] = None,
|
|
386
|
+
until: Annotated[int | None, Query(description="Milliseconds since epoch.")] = None,
|
|
387
|
+
limit: Annotated[int, Query(ge=1, le=1000)] = 100,
|
|
388
|
+
cursor: Annotated[str | None, Query(description=(
|
|
389
|
+
"`next_cursor` from the previous page of a read with `since`; continues after its last bar."
|
|
390
|
+
))] = None,
|
|
391
|
+
) -> PageResponse[Candle]:
|
|
392
|
+
"""Read `price_source` on every bar before using it.
|
|
393
|
+
|
|
394
|
+
`trade` means executions. `bid_ask_mid` means nothing traded in that
|
|
395
|
+
period and the bar is the book's midpoint. `sampled_mid` means the
|
|
396
|
+
venue publishes no candles at all and these were bucketed from price
|
|
397
|
+
samples — those carry `volume: null`, because null is not zero.
|
|
398
|
+
|
|
399
|
+
A long history is paged forward: pass `since`, then each
|
|
400
|
+
`next_cursor` until it is null. Every page costs the venue only the
|
|
401
|
+
requests that page needs.
|
|
402
|
+
"""
|
|
403
|
+
if cursor is not None:
|
|
404
|
+
if not cursor.isdigit():
|
|
405
|
+
raise http_error(400, "validation_error", "cursor: not a cursor from this endpoint", field="cursor")
|
|
406
|
+
since = int(cursor)
|
|
407
|
+
seconds = timeframe_seconds(timeframe)
|
|
408
|
+
candles = exchange.fetch_ohlcv(
|
|
409
|
+
market_id, timeframe=timeframe, since=since, until=until, limit=limit,
|
|
410
|
+
)
|
|
411
|
+
more = since is not None and len(candles) == limit
|
|
412
|
+
next_cursor = str(candles[-1].timestamp + seconds * 1000) if more else None
|
|
413
|
+
return PageResponse[Candle](data=list(candles), next_cursor=next_cursor, count=len(candles))
|
|
414
|
+
|
|
415
|
+
# -- reference ----------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
@app.get(
|
|
418
|
+
"/venues/{venue}/markets/{market_id}/fee", tags=["reference"],
|
|
419
|
+
summary="Fee schedule, before trading", responses=RESPONSES,
|
|
420
|
+
)
|
|
421
|
+
def get_fee_schedule(exchange: Venue, market_id: str) -> FeeSchedule:
|
|
422
|
+
"""What trading this market costs. Kalshi keys fees on the series above
|
|
423
|
+
the market, so this resolves that for you."""
|
|
424
|
+
return exchange.fetch_fee_schedule(market_id)
|
|
425
|
+
|
|
426
|
+
@app.get(
|
|
427
|
+
"/venues/{venue}/series/{series_id}", tags=["reference"],
|
|
428
|
+
summary="One series", responses=RESPONSES,
|
|
429
|
+
)
|
|
430
|
+
def get_series(exchange: Venue, series_id: str) -> Series:
|
|
431
|
+
"""Kalshi only. Polymarket has no series tier and answers 501."""
|
|
432
|
+
return exchange.fetch_series(series_id)
|
|
433
|
+
|
|
434
|
+
return app
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def venues_dependency(app: FastAPI) -> Iterator[VenueRegistry]:
|
|
438
|
+
"""The registry an app is using, for a host that wants to reach into it."""
|
|
439
|
+
yield app.state.registry
|
synpath/server/errors.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""One error shape for every route: `{"error": {"code", "message", "details"}}`.
|
|
2
|
+
|
|
3
|
+
Both apps install the same handlers, so a client writes one error path. The
|
|
4
|
+
`code` is derived from the library's exception (`InsufficientFunds` becomes
|
|
5
|
+
`insufficient_funds`), so an HTTP caller branches on the same vocabulary an
|
|
6
|
+
in-process caller catches; errors the HTTP layer raises itself (no token, no
|
|
7
|
+
permission, a malformed body) get codes from their status.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any, Callable
|
|
14
|
+
|
|
15
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
16
|
+
from fastapi.exceptions import RequestValidationError
|
|
17
|
+
from fastapi.responses import JSONResponse
|
|
18
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
19
|
+
|
|
20
|
+
from ..errors import NetworkError, SynpathError
|
|
21
|
+
from .models import ErrorBody, ErrorDetail
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger("synpath.server")
|
|
24
|
+
|
|
25
|
+
STATUS_CODES: dict[int, str] = {
|
|
26
|
+
400: "bad_request", 401: "unauthorized", 403: "forbidden", 404: "not_found", 405: "method_not_allowed",
|
|
27
|
+
409: "conflict", 422: "validation_error", 429: "rate_limited", 500: "internal_error", 501: "not_supported",
|
|
28
|
+
502: "venue_error", 503: "unavailable", 504: "venue_timeout",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def code_of(exc: BaseException) -> str:
|
|
33
|
+
"""`InsufficientFunds` -> `insufficient_funds`."""
|
|
34
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", type(exc).__name__).lower()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def error_body(code: str, message: str, **details: Any) -> dict[str, Any]:
|
|
38
|
+
details.setdefault("venue", None)
|
|
39
|
+
details.setdefault("retryable", False)
|
|
40
|
+
return ErrorBody(error=ErrorDetail(code=code, message=message, details=details)).model_dump()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def http_error(status: int, code: str, message: str, **details: Any) -> HTTPException:
|
|
44
|
+
"""An HTTPException that carries its own code, for routes that know better
|
|
45
|
+
than the status alone (`unknown_venue` rather than `not_found`)."""
|
|
46
|
+
return HTTPException(status_code=status, detail={"code": code, "message": message, "details": details})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def install_error_handlers(app: FastAPI, status_for: Callable[[Exception], int]) -> None:
|
|
50
|
+
@app.exception_handler(SynpathError)
|
|
51
|
+
async def _on_synpath_error(request: Request, exc: SynpathError) -> JSONResponse:
|
|
52
|
+
status = status_for(exc)
|
|
53
|
+
if status >= 500 and status != 501:
|
|
54
|
+
log.warning("%s %s -> %s: %s", request.method, request.url.path, status, exc)
|
|
55
|
+
from .api import _venue_of
|
|
56
|
+
|
|
57
|
+
details: dict[str, Any] = {
|
|
58
|
+
"venue": getattr(exc, "synpath_venue", None) or _venue_of(exc),
|
|
59
|
+
"retryable": isinstance(exc, NetworkError),
|
|
60
|
+
}
|
|
61
|
+
for name in ("rule", "reason"):
|
|
62
|
+
value = getattr(exc, name, None)
|
|
63
|
+
if value:
|
|
64
|
+
details[name] = value
|
|
65
|
+
headers = {}
|
|
66
|
+
retry_after = getattr(exc, "retry_after", None)
|
|
67
|
+
if retry_after:
|
|
68
|
+
details["retry_after"] = retry_after
|
|
69
|
+
headers["Retry-After"] = str(int(retry_after))
|
|
70
|
+
return JSONResponse(status_code=status, content=error_body(code_of(exc), str(exc), **details),
|
|
71
|
+
headers=headers)
|
|
72
|
+
|
|
73
|
+
@app.exception_handler(StarletteHTTPException)
|
|
74
|
+
async def _on_http_error(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
|
75
|
+
detail = exc.detail
|
|
76
|
+
if isinstance(detail, dict) and "code" in detail:
|
|
77
|
+
body = error_body(detail["code"], str(detail.get("message", "")), **(detail.get("details") or {}))
|
|
78
|
+
else:
|
|
79
|
+
body = error_body(STATUS_CODES.get(exc.status_code, "error"), str(detail))
|
|
80
|
+
return JSONResponse(status_code=exc.status_code, content=body, headers=getattr(exc, "headers", None))
|
|
81
|
+
|
|
82
|
+
@app.exception_handler(RequestValidationError)
|
|
83
|
+
async def _on_invalid(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
84
|
+
errors = [{"field": ".".join(str(part) for part in e.get("loc", ()) if part != "body"),
|
|
85
|
+
"message": e.get("msg", "")} for e in exc.errors()]
|
|
86
|
+
summary = "; ".join(f"{e['field']}: {e['message']}" for e in errors) or "the request is not valid"
|
|
87
|
+
return JSONResponse(status_code=422, content=error_body("validation_error", summary, errors=errors))
|
synpath/server/local.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Where a self-hosted server leaves its access token for clients on the same machine.
|
|
2
|
+
|
|
3
|
+
A `synpath serve` on your own machine should need no token from you: the
|
|
4
|
+
server makes one on first start and the library finds it. So the server
|
|
5
|
+
records `{host:port -> access token, control database, pid}` in a file only your user
|
|
6
|
+
can read (`~/.synpath/servers.json`, mode 0600), and `synpath.Client(server=
|
|
7
|
+
"http://127.0.0.1:8000")` reads the token from there when the address is
|
|
8
|
+
loopback. A server reached over the network is a different matter: nothing
|
|
9
|
+
is looked up, and the caller passes the token (`access_token=`, or
|
|
10
|
+
`SYNPATH_ACCESS_TOKEN`), which the server printed once when it made it.
|
|
11
|
+
|
|
12
|
+
The secret is written only when it is created, because the control database
|
|
13
|
+
keeps a hash and cannot give it back. Deleting the file loses the local
|
|
14
|
+
copy; `synpath bootstrap` makes a new owner access token.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import stat
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import urlsplit
|
|
25
|
+
|
|
26
|
+
LOOPBACK = {"127.0.0.1", "localhost", "::1", "[::1]"}
|
|
27
|
+
ENV_KEY = "SYNPATH_ACCESS_TOKEN"
|
|
28
|
+
ENV_HOME = "SYNPATH_HOME"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def home(override: str | None = None) -> Path:
|
|
32
|
+
return Path(override or os.environ.get(ENV_HOME) or (Path.home() / ".synpath"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def registry_path(override: str | None = None) -> Path:
|
|
36
|
+
return home(override) / "servers.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_loopback(url: str) -> bool:
|
|
40
|
+
host = urlsplit(url if "://" in url else f"http://{url}").hostname
|
|
41
|
+
return host in LOOPBACK
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def address(host: str, port: int) -> str:
|
|
45
|
+
"""One name for every way of saying this machine, so a server started on
|
|
46
|
+
`localhost` is found by a client that says `127.0.0.1`."""
|
|
47
|
+
host = host.strip("[]")
|
|
48
|
+
if host in ("0.0.0.0", "", "::") or host in LOOPBACK:
|
|
49
|
+
host = "127.0.0.1"
|
|
50
|
+
return f"{host}:{port}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _read(path: Path) -> dict[str, Any]:
|
|
54
|
+
try:
|
|
55
|
+
return json.loads(path.read_text())
|
|
56
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
57
|
+
return {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _write(path: Path, data: dict[str, Any]) -> None:
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
os.chmod(path.parent, stat.S_IRWXU)
|
|
63
|
+
tmp = path.with_suffix(".tmp")
|
|
64
|
+
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
|
|
65
|
+
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
|
|
66
|
+
tmp.replace(path)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def remember(host: str, port: int, *, control: str, journal: str, key: str | None, home_dir: str | None = None) -> str | None:
|
|
70
|
+
"""Record this server. `key` is the secret just made, or `None` on a
|
|
71
|
+
later start, when the earlier record's token is kept if it was for the
|
|
72
|
+
same control database. Returns the token on record, if any."""
|
|
73
|
+
path = registry_path(home_dir)
|
|
74
|
+
data = _read(path)
|
|
75
|
+
name = address(host, port)
|
|
76
|
+
previous = data.get(name) or {}
|
|
77
|
+
kept = key or (previous.get("access_token") if previous.get("control") == str(control) else None)
|
|
78
|
+
data[name] = {"access_token": kept, "control": str(control), "journal": str(journal), "pid": os.getpid(),
|
|
79
|
+
"started_ms": int(time.time() * 1000)}
|
|
80
|
+
_write(path, data)
|
|
81
|
+
return kept
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def lookup(server: str, *, home_dir: str | None = None) -> str | None:
|
|
85
|
+
"""The stored access token for a loopback server address, or `None`."""
|
|
86
|
+
if not is_loopback(server):
|
|
87
|
+
return None
|
|
88
|
+
parts = urlsplit(server if "://" in server else f"http://{server}")
|
|
89
|
+
name = address(parts.hostname or "127.0.0.1", parts.port or (443 if parts.scheme == "https" else 80))
|
|
90
|
+
entry = _read(registry_path(home_dir)).get(name) or {}
|
|
91
|
+
return entry.get("access_token") or None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resolve_key(server: str, explicit: str | None = None, *, home_dir: str | None = None) -> str | None:
|
|
95
|
+
"""Explicit, then the environment, then the local registry for loopback."""
|
|
96
|
+
return explicit or os.environ.get(ENV_KEY) or lookup(server, home_dir=home_dir)
|