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/engine/router.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""The router: one order on a bucket, as legs on its member venues.
|
|
2
|
+
|
|
3
|
+
Pure functions over data the caller already holds. `merge` lays the members'
|
|
4
|
+
books side by side in bucket terms; `plan` walks the merged book from the
|
|
5
|
+
best net price outwards and says how much to put where. Nothing here talks
|
|
6
|
+
to a venue or makes an order; the parent that owns the bucket order does
|
|
7
|
+
that with the plan, and re-plans as fills and books move.
|
|
8
|
+
|
|
9
|
+
Three rules the plan keeps, because they are the difference between a
|
|
10
|
+
router and a footgun:
|
|
11
|
+
|
|
12
|
+
* **The legs never sum to more than was asked.** Oversubscribing to fill
|
|
13
|
+
faster is how a bucket buys twice.
|
|
14
|
+
* **Prices are net of the taker fee**, per level, because on Kalshi the fee
|
|
15
|
+
is a curve in price and a 0.41 with fee can cost more than a 0.42 without.
|
|
16
|
+
* **A leg below its venue's minimum is dropped and its size goes to the next
|
|
17
|
+
best venue**, rather than rounded up into more than the caller wanted.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
|
|
23
|
+
from typing import Any, Callable, Literal, Mapping
|
|
24
|
+
|
|
25
|
+
from ..bucket import Bucket, BucketMember
|
|
26
|
+
from ..trading.instruments import VENUE_RULES
|
|
27
|
+
from ..trading.types import Precision, Side
|
|
28
|
+
from ..types import OrderBook
|
|
29
|
+
|
|
30
|
+
ZERO = Decimal("0")
|
|
31
|
+
FeeFn = Callable[[str, Decimal, Decimal], Decimal]
|
|
32
|
+
"""`fee(market_id, price, contracts)` -> the taker fee for that many contracts
|
|
33
|
+
at that price, in the venue's currency. The plan asks per level."""
|
|
34
|
+
|
|
35
|
+
Reason = Literal["", "worst_price", "liquidity", "min_amount"]
|
|
36
|
+
|
|
37
|
+
PriceSize = tuple[Decimal, Decimal]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class BookView:
|
|
42
|
+
"""One member's book in bucket terms: `asks` is what one bucket YES costs,
|
|
43
|
+
`bids` what it fetches, both best first, as Decimals."""
|
|
44
|
+
market_id: str
|
|
45
|
+
asks: tuple[PriceSize, ...]
|
|
46
|
+
bids: tuple[PriceSize, ...]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def view(book: Any, member: BucketMember, *, face_value: Decimal = Decimal("1")) -> BookView:
|
|
50
|
+
"""A member's book as the bucket sees it.
|
|
51
|
+
|
|
52
|
+
Takes an `OrderBook` (either side) or anything with `levels()` giving
|
|
53
|
+
`(bids, asks)` of price/size pairs (`synpath.ws.LocalBook`, the paper
|
|
54
|
+
venue's book), which is always the YES side. A flipped member's YES book
|
|
55
|
+
is turned into its NO book here: NO asks are `face - YES bids`, NO bids
|
|
56
|
+
`face - YES asks`. An `OrderBook` already on the NO side is taken as is."""
|
|
57
|
+
def pairs(rows: Any) -> tuple[PriceSize, ...]:
|
|
58
|
+
out = []
|
|
59
|
+
for row in rows:
|
|
60
|
+
if isinstance(row, (tuple, list)):
|
|
61
|
+
price, size = Decimal(str(row[0])), Decimal(str(row[1]))
|
|
62
|
+
else:
|
|
63
|
+
price, size = Decimal(str(row.price)), Decimal(str(row.size))
|
|
64
|
+
if size > 0:
|
|
65
|
+
out.append((price, size))
|
|
66
|
+
return tuple(out)
|
|
67
|
+
|
|
68
|
+
side_in = getattr(book, "side", "yes")
|
|
69
|
+
if callable(getattr(book, "levels", None)):
|
|
70
|
+
bids, asks = book.levels()
|
|
71
|
+
else:
|
|
72
|
+
bids, asks = book.bids, book.asks
|
|
73
|
+
bids, asks = pairs(bids), pairs(asks)
|
|
74
|
+
wanted = member.book_side()
|
|
75
|
+
if side_in == wanted:
|
|
76
|
+
return BookView(member.market_id, asks, bids)
|
|
77
|
+
if side_in == "yes" and wanted == "no":
|
|
78
|
+
return BookView(
|
|
79
|
+
member.market_id,
|
|
80
|
+
asks=tuple((face_value - p, s) for p, s in bids),
|
|
81
|
+
bids=tuple((face_value - p, s) for p, s in asks),
|
|
82
|
+
)
|
|
83
|
+
raise ValueError(f"{member.market_id}: book is the {side_in} side, bucket needs {wanted}")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def default_precision(market_id: str) -> Precision:
|
|
87
|
+
"""The venue's published rules, for a member whose market has not been read."""
|
|
88
|
+
venue = market_id.split(":", 1)[0]
|
|
89
|
+
rules = VENUE_RULES.get(venue, VENUE_RULES["polymarket"])
|
|
90
|
+
return Precision(tick=rules["default_tick"], min_amount=rules["min_amount"],
|
|
91
|
+
amount_step=rules["amount_step"], whole_contracts=rules["whole"])
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class Level:
|
|
96
|
+
market_id: str
|
|
97
|
+
price: Decimal
|
|
98
|
+
"""In bucket terms: what one contract of the bucket's YES costs (asks) or fetches (bids)."""
|
|
99
|
+
net_price: Decimal
|
|
100
|
+
"""`price` plus the taker fee per contract on a buy, minus it on a sell."""
|
|
101
|
+
size: Decimal
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class Leg:
|
|
106
|
+
market_id: str
|
|
107
|
+
flip: bool
|
|
108
|
+
side: Side
|
|
109
|
+
"""The member's own side, after `flip`."""
|
|
110
|
+
price: Decimal
|
|
111
|
+
"""The member's own YES price, after `flip` and tick rounding. What the venue receives."""
|
|
112
|
+
amount: Decimal
|
|
113
|
+
bucket_price: Decimal
|
|
114
|
+
"""The worst bucket-terms price this leg reaches, before fees."""
|
|
115
|
+
net_price: Decimal
|
|
116
|
+
"""Volume-weighted net price of the levels this leg takes, in bucket terms."""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class Plan:
|
|
121
|
+
side: Side
|
|
122
|
+
amount: Decimal
|
|
123
|
+
limit: Decimal
|
|
124
|
+
legs: list[Leg] = field(default_factory=list)
|
|
125
|
+
unfilled: Decimal = ZERO
|
|
126
|
+
reason: Reason = ""
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def allocated(self) -> Decimal:
|
|
130
|
+
return sum((leg.amount for leg in self.legs), ZERO)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def expected_net_price(self) -> Decimal | None:
|
|
134
|
+
if not self.legs:
|
|
135
|
+
return None
|
|
136
|
+
return sum((leg.net_price * leg.amount for leg in self.legs), ZERO) / self.allocated
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def merge(bucket: Bucket, books: Mapping[str, Any], fee: FeeFn) -> tuple[list[Level], list[Level]]:
|
|
140
|
+
"""The members' books as one, in bucket terms: `(asks, bids)`, asks
|
|
141
|
+
cheapest-net first, bids richest-net first. Books go through `view`, so a
|
|
142
|
+
flipped member may hand in either side."""
|
|
143
|
+
asks: list[Level] = []
|
|
144
|
+
bids: list[Level] = []
|
|
145
|
+
for member in bucket.members:
|
|
146
|
+
book = books.get(member.market_id)
|
|
147
|
+
if book is None:
|
|
148
|
+
continue
|
|
149
|
+
seen = book if isinstance(book, BookView) else view(book, member)
|
|
150
|
+
for price, size in seen.asks:
|
|
151
|
+
asks.append(Level(member.market_id, price, price + _per_contract(fee, member.market_id, price), size))
|
|
152
|
+
for price, size in seen.bids:
|
|
153
|
+
bids.append(Level(member.market_id, price, price - _per_contract(fee, member.market_id, price), size))
|
|
154
|
+
asks.sort(key=lambda l: (l.net_price, l.price))
|
|
155
|
+
bids.sort(key=lambda l: (-l.net_price, -l.price))
|
|
156
|
+
return asks, bids
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _per_contract(fee: FeeFn, market_id: str, price: Decimal) -> Decimal:
|
|
160
|
+
charged = fee(market_id, price, Decimal("1"))
|
|
161
|
+
return charged if charged > 0 else ZERO
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def plan(
|
|
165
|
+
bucket: Bucket,
|
|
166
|
+
books: Mapping[str, Any],
|
|
167
|
+
precision: Mapping[str, Precision],
|
|
168
|
+
fee: FeeFn,
|
|
169
|
+
*,
|
|
170
|
+
side: Side,
|
|
171
|
+
amount: Decimal,
|
|
172
|
+
limit: Decimal,
|
|
173
|
+
) -> Plan:
|
|
174
|
+
"""How to take `amount` of the bucket at no worse than `limit` net, given
|
|
175
|
+
what the books show now. Walks the merged book best-net first, stops at
|
|
176
|
+
the limit, then drops any leg under its venue's minimum and re-walks with
|
|
177
|
+
that venue excluded so its size goes to the next best."""
|
|
178
|
+
asks, bids = merge(bucket, books, fee)
|
|
179
|
+
levels = asks if side == Side.BUY else bids
|
|
180
|
+
excluded: set[str] = set()
|
|
181
|
+
out = Plan(side=side, amount=amount, limit=limit)
|
|
182
|
+
for _ in range(len(bucket.members) + 1):
|
|
183
|
+
legs, remaining, reason = _walk(bucket, levels, precision, side, amount, limit, excluded)
|
|
184
|
+
short = [leg for leg in legs if leg.amount < precision[leg.market_id].min_amount]
|
|
185
|
+
if not short:
|
|
186
|
+
out.legs, out.unfilled, out.reason = legs, remaining, reason
|
|
187
|
+
if remaining > 0 and reason == "" :
|
|
188
|
+
out.reason = "min_amount"
|
|
189
|
+
return out
|
|
190
|
+
excluded.update(leg.market_id for leg in short)
|
|
191
|
+
out.legs, out.unfilled, out.reason = [], amount, "min_amount"
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _walk(
|
|
196
|
+
bucket: Bucket,
|
|
197
|
+
levels: list[Level],
|
|
198
|
+
precision: Mapping[str, Precision],
|
|
199
|
+
side: Side,
|
|
200
|
+
amount: Decimal,
|
|
201
|
+
limit: Decimal,
|
|
202
|
+
excluded: set[str],
|
|
203
|
+
) -> tuple[list[Leg], Decimal, Reason]:
|
|
204
|
+
taken: dict[str, dict[str, Decimal]] = {}
|
|
205
|
+
remaining = amount
|
|
206
|
+
reason: Reason = "liquidity"
|
|
207
|
+
for lvl in levels:
|
|
208
|
+
if remaining <= 0:
|
|
209
|
+
reason = ""
|
|
210
|
+
break
|
|
211
|
+
if lvl.market_id in excluded:
|
|
212
|
+
continue
|
|
213
|
+
if (side == Side.BUY and lvl.net_price > limit) or (side == Side.SELL and lvl.net_price < limit):
|
|
214
|
+
reason = "worst_price"
|
|
215
|
+
break
|
|
216
|
+
take = min(lvl.size, remaining)
|
|
217
|
+
slot = taken.setdefault(lvl.market_id, {"amount": ZERO, "cost": ZERO, "worst": lvl.price})
|
|
218
|
+
slot["amount"] += take
|
|
219
|
+
slot["cost"] += lvl.net_price * take
|
|
220
|
+
slot["worst"] = max(slot["worst"], lvl.price) if side == Side.BUY else min(slot["worst"], lvl.price)
|
|
221
|
+
remaining -= take
|
|
222
|
+
else:
|
|
223
|
+
if remaining <= 0:
|
|
224
|
+
reason = ""
|
|
225
|
+
legs: list[Leg] = []
|
|
226
|
+
for market_id, slot in taken.items():
|
|
227
|
+
member = bucket.member(market_id)
|
|
228
|
+
spec = precision[market_id]
|
|
229
|
+
qty = _round_amount(slot["amount"], spec)
|
|
230
|
+
if qty <= 0:
|
|
231
|
+
remaining += slot["amount"]
|
|
232
|
+
continue
|
|
233
|
+
remaining += slot["amount"] - qty
|
|
234
|
+
member_side, member_price = member.to_member(side, slot["worst"])
|
|
235
|
+
member_price = _round_price(member_price, spec.tick, member_side)
|
|
236
|
+
legs.append(Leg(
|
|
237
|
+
market_id=market_id, flip=member.flip, side=member_side, price=member_price, amount=qty,
|
|
238
|
+
bucket_price=slot["worst"], net_price=slot["cost"] / slot["amount"],
|
|
239
|
+
))
|
|
240
|
+
return legs, remaining, reason
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _round_amount(amount: Decimal, spec: Precision) -> Decimal:
|
|
244
|
+
"""Down, never up: a leg may fall short of what the level offered but must
|
|
245
|
+
not exceed it."""
|
|
246
|
+
if spec.whole_contracts:
|
|
247
|
+
return Decimal(int(amount))
|
|
248
|
+
if spec.amount_step:
|
|
249
|
+
return (amount / spec.amount_step).to_integral_value(rounding=ROUND_FLOOR) * spec.amount_step
|
|
250
|
+
return amount
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _round_price(price: Decimal, tick: Decimal, side: Side) -> Decimal:
|
|
254
|
+
"""Onto the venue's tick, toward the side that cannot worsen the limit:
|
|
255
|
+
a buy rounds down, a sell rounds up."""
|
|
256
|
+
rounding = ROUND_FLOOR if side == Side.BUY else ROUND_CEILING
|
|
257
|
+
return (price / tick).to_integral_value(rounding=rounding) * tick
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def member_from_plan(bucket: Bucket, leg: Leg) -> BucketMember:
|
|
261
|
+
return bucket.member(leg.market_id)
|
synpath/errors.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Error hierarchy, named after ccxt's so the reflexes transfer.
|
|
2
|
+
|
|
3
|
+
Two branches under `SynpathError`, and the split is the one a caller actually
|
|
4
|
+
acts on:
|
|
5
|
+
|
|
6
|
+
* `NetworkError` — the request never got a verdict from the venue. Retrying
|
|
7
|
+
the identical call is reasonable.
|
|
8
|
+
* `ExchangeError` — the venue answered and said no. Retrying unchanged will
|
|
9
|
+
fail again; the caller has to change the request.
|
|
10
|
+
|
|
11
|
+
`RateLimitExceeded` sits under `NetworkError` for exactly this reason, which
|
|
12
|
+
surprises people until they notice that waiting and retrying is the correct
|
|
13
|
+
response to it.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SynpathError(Exception):
|
|
21
|
+
"""Base for everything this library raises on purpose."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NetworkError(SynpathError):
|
|
25
|
+
"""No verdict from the venue: timeout, DNS, connection reset, 5xx."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ExchangeNotAvailable(NetworkError):
|
|
29
|
+
"""The venue is up but refusing service — maintenance, 502/503.
|
|
30
|
+
|
|
31
|
+
`body` is the venue's parsed payload when it sent one: some venues say
|
|
32
|
+
*why* they refuse (cancel-only mode, a scheduled pause) in a 503.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str, *, body: Any = None, status: int | None = None):
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.body = body
|
|
38
|
+
self.status = status
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RequestTimeout(NetworkError):
|
|
42
|
+
"""The venue did not answer inside the timeout."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class RateLimitExceeded(NetworkError):
|
|
46
|
+
"""Too many requests. Retry after backing off."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, message: str, *, retry_after: float | None = None):
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.retry_after = retry_after
|
|
51
|
+
"""Seconds to wait, when the venue said. Kalshi does not, so usually None."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ExchangeError(SynpathError):
|
|
55
|
+
"""The venue answered and rejected the request.
|
|
56
|
+
|
|
57
|
+
`body` is the venue's parsed error payload when it sent JSON, and
|
|
58
|
+
`status` the HTTP status, so a caller (or a trading adapter) can branch
|
|
59
|
+
on the venue's own error code rather than on message text.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, message: str, *, body: Any = None, status: int | None = None):
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
self.body = body
|
|
65
|
+
self.status = status
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def code(self) -> str | None:
|
|
69
|
+
"""The venue's error code, where its payload carries one."""
|
|
70
|
+
if isinstance(self.body, dict):
|
|
71
|
+
inner = self.body.get("error") if isinstance(self.body.get("error"), dict) else self.body
|
|
72
|
+
code = inner.get("code") if isinstance(inner, dict) else None
|
|
73
|
+
return str(code) if code is not None else None
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class BadRequest(ExchangeError):
|
|
78
|
+
"""Malformed or invalid parameters (4xx that is not auth or not-found)."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class BadSymbol(BadRequest):
|
|
82
|
+
"""A market, event or instrument id this venue does not recognise."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class MarketNotFound(BadSymbol):
|
|
86
|
+
"""The id is well-formed but the venue has no such market."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class NotSupported(SynpathError):
|
|
90
|
+
"""This venue does not offer the capability.
|
|
91
|
+
|
|
92
|
+
Raised rather than returning empty, so a missing feature is never mistaken
|
|
93
|
+
for an absence of data. Check `exchange.has` to branch before calling.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AuthenticationError(ExchangeError):
|
|
98
|
+
"""Credentials missing, malformed, or rejected."""
|
synpath/history.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Historical order books and trades, from Synpath's hosted history service.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
import synpath
|
|
5
|
+
|
|
6
|
+
at = synpath.fetch_order_book_at("kalshi:KXQUANTUM-30", as_of_ms=1789509599000)
|
|
7
|
+
if at.book is None:
|
|
8
|
+
print("no book:", at.absence_reason) # coverage is not continuous
|
|
9
|
+
else:
|
|
10
|
+
print(at.book.best_bid, at.book.best_ask)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Needs a Synpath API key (`SYNPATH_API_KEY`, or `api_key=`). The service lives at
|
|
14
|
+
`https://api2.synpath.dev` unless `base_url=` or `SYNPATH_HISTORY_URL` says otherwise.
|
|
15
|
+
Times are recorder receive times in Unix milliseconds. Coverage varies by market and
|
|
16
|
+
has gaps, which every answer says explicitly: a missing book comes back with an
|
|
17
|
+
`absence_reason`, never as an empty book. A range too large to return whole raises
|
|
18
|
+
`BadRequest`; narrow it or raise `limit` (`examples/track_historical_book.py`
|
|
19
|
+
splits long walks for you).
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .hosted import call, hosted_client
|
|
26
|
+
from .matching import _validate
|
|
27
|
+
from .types import BookSide, OrderBookAtResponse, OrderBookRangeResponse, TradesRangeResponse
|
|
28
|
+
|
|
29
|
+
BASE_URL_ENV = "SYNPATH_HISTORY_URL"
|
|
30
|
+
DEFAULT_URL = "https://api2.synpath.dev"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _post(path: str, body: dict[str, Any], *, base_url: str | None, client: Any, api_key: str | None) -> Any:
|
|
34
|
+
_validate(body["market_id"])
|
|
35
|
+
http = hosted_client(base_url=base_url, env=BASE_URL_ENV, default=DEFAULT_URL, http_client=client)
|
|
36
|
+
payload = {k: v for k, v in body.items() if v is not None}
|
|
37
|
+
return call(http, "POST", path, json=payload, api_key=api_key, owned=client is None, use_stored=True)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fetch_order_book_at(
|
|
41
|
+
market_id: str, as_of_ms: int, *, side: BookSide | None = None, depth: int | None = None,
|
|
42
|
+
api_key: str | None = None, base_url: str | None = None, client: Any = None,
|
|
43
|
+
) -> OrderBookAtResponse:
|
|
44
|
+
"""The book as it stood at `as_of_ms`. `book` is `None`, with `absence_reason`, where
|
|
45
|
+
there is no coverage at that time."""
|
|
46
|
+
body = _post("/v1/order-book/at", {"market_id": market_id, "as_of_ms": as_of_ms, "side": side, "depth": depth},
|
|
47
|
+
base_url=base_url, client=client, api_key=api_key)
|
|
48
|
+
return OrderBookAtResponse.model_validate(body)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fetch_order_book_range(
|
|
52
|
+
market_id: str, start_ms: int, end_ms: int, *, side: BookSide | None = None, limit: int | None = None,
|
|
53
|
+
api_key: str | None = None, base_url: str | None = None, client: Any = None,
|
|
54
|
+
) -> OrderBookRangeResponse:
|
|
55
|
+
"""Every change to the book between `start_ms` and `end_ms`: each segment starts from a
|
|
56
|
+
full book, and an interval without coverage is its own segment rather than a gap."""
|
|
57
|
+
body = _post("/v1/order-book/range", {"market_id": market_id, "start_ms": start_ms, "end_ms": end_ms,
|
|
58
|
+
"side": side, "limit": limit},
|
|
59
|
+
base_url=base_url, client=client, api_key=api_key)
|
|
60
|
+
return OrderBookRangeResponse.model_validate(body)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def fetch_trades_range(
|
|
64
|
+
market_id: str, start_ms: int, end_ms: int, *, limit: int | None = None,
|
|
65
|
+
api_key: str | None = None, base_url: str | None = None, client: Any = None,
|
|
66
|
+
) -> TradesRangeResponse:
|
|
67
|
+
"""Trades between `start_ms` and `end_ms`, with the coverage they were recorded under."""
|
|
68
|
+
body = _post("/v1/trades/range", {"market_id": market_id, "start_ms": start_ms, "end_ms": end_ms,
|
|
69
|
+
"limit": limit},
|
|
70
|
+
base_url=base_url, client=client, api_key=api_key)
|
|
71
|
+
return TradesRangeResponse.model_validate(body)
|
synpath/hosted.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Synpath's own hosted services, as opposed to the venues.
|
|
2
|
+
|
|
3
|
+
Two of them, each with a default address and an environment variable that
|
|
4
|
+
overrides it:
|
|
5
|
+
|
|
6
|
+
matching https://api.synpath.dev SYNPATH_MATCHING_URL `match_market`, `match_event`
|
|
7
|
+
history https://api2.synpath.dev SYNPATH_HISTORY_URL `fetch_order_book_at`, ...
|
|
8
|
+
|
|
9
|
+
Both take a Synpath API key as `Authorization: Bearer <key>`, from `api_key=`,
|
|
10
|
+
`SYNPATH_API_KEY`, or the key `synpath keys create` saved. The key goes to Synpath's services only, never to a
|
|
11
|
+
venue, and nothing that runs on your own machine needs it.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .base import HttpClient, RateLimiter
|
|
19
|
+
from .errors import AuthenticationError, BadRequest, ExchangeError, ExchangeNotAvailable, MarketNotFound, NetworkError
|
|
20
|
+
|
|
21
|
+
API_KEY_ENV = "SYNPATH_API_KEY"
|
|
22
|
+
LABEL = "synpath"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def api_key_from(explicit: str | None = None, *, use_stored: bool = False) -> str | None:
|
|
26
|
+
if explicit or os.environ.get(API_KEY_ENV):
|
|
27
|
+
return explicit or os.environ.get(API_KEY_ENV)
|
|
28
|
+
if use_stored:
|
|
29
|
+
from .hosted_auth import stored_api_key
|
|
30
|
+
return stored_api_key()
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def auth_headers(api_key: str | None = None, *, use_stored: bool = False) -> dict[str, str]:
|
|
35
|
+
key = api_key_from(api_key, use_stored=use_stored)
|
|
36
|
+
return {"Authorization": f"Bearer {key}"} if key else {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def hosted_client(*, base_url: str | None, env: str, default: str, http_client: Any = None) -> HttpClient:
|
|
40
|
+
url = base_url or os.environ.get(env) or default
|
|
41
|
+
return HttpClient(url, limiter=RateLimiter(rate_per_second=10), client=http_client, venue=LABEL)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_ours(body: Any) -> bool:
|
|
45
|
+
"""Whether an error body came from a Synpath service rather than the host in front of it.
|
|
46
|
+
Ours carry `detail` (FastAPI) or `error` with a `code` (the newer shape)."""
|
|
47
|
+
if not isinstance(body, dict):
|
|
48
|
+
return False
|
|
49
|
+
if "detail" in body:
|
|
50
|
+
return True
|
|
51
|
+
error = body.get("error")
|
|
52
|
+
return isinstance(error, dict) and "code" in error
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def call(http: HttpClient, method: str, path: str, *, api_key: str | None, owned: bool,
|
|
56
|
+
use_stored: bool = False, **kwargs: Any) -> Any:
|
|
57
|
+
"""One request to a hosted service, with the key and plainer errors: a
|
|
58
|
+
refused key says which variable to set, and a range too large to answer
|
|
59
|
+
whole says to narrow it."""
|
|
60
|
+
try:
|
|
61
|
+
return http.request(method, path, headers=auth_headers(api_key, use_stored=use_stored), **kwargs)
|
|
62
|
+
except MarketNotFound as exc:
|
|
63
|
+
if _is_ours(exc.body):
|
|
64
|
+
raise
|
|
65
|
+
# A 404 that is not our service's own answer is the host in front of it saying there is
|
|
66
|
+
# no application there: the service is down, not the market missing.
|
|
67
|
+
raise ExchangeNotAvailable(
|
|
68
|
+
f"{LABEL}: the service at {http.base_url} is not running or not deployed (it answered 404 "
|
|
69
|
+
f"without a Synpath error body); try again later or check {http.base_url}",
|
|
70
|
+
body=exc.body, status=exc.status,
|
|
71
|
+
) from None
|
|
72
|
+
except NetworkError as exc:
|
|
73
|
+
reason = str(exc).removeprefix(f"{LABEL}: ")
|
|
74
|
+
raise NetworkError(f"{LABEL}: could not reach {http.base_url}: {reason}") from None
|
|
75
|
+
except AuthenticationError as exc:
|
|
76
|
+
hint = "" if api_key_from(api_key, use_stored=use_stored) else (
|
|
77
|
+
f"; run `synpath login` then `synpath keys create`, or set {API_KEY_ENV}")
|
|
78
|
+
raise AuthenticationError(f"{exc}{hint}", body=exc.body, status=exc.status) from None
|
|
79
|
+
except ExchangeError as exc:
|
|
80
|
+
if exc.status == 413:
|
|
81
|
+
raise BadRequest(f"{LABEL}: the result is too large to return whole; narrow the range or raise limit",
|
|
82
|
+
body=exc.body, status=exc.status) from None
|
|
83
|
+
raise
|
|
84
|
+
finally:
|
|
85
|
+
if owned:
|
|
86
|
+
http.close()
|