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.
Files changed (77) hide show
  1. synpath/__init__.py +183 -0
  2. synpath/__main__.py +66 -0
  3. synpath/base.py +723 -0
  4. synpath/bucket.py +154 -0
  5. synpath/client.py +356 -0
  6. synpath/engine/__init__.py +37 -0
  7. synpath/engine/__main__.py +354 -0
  8. synpath/engine/alerts.py +170 -0
  9. synpath/engine/engine.py +888 -0
  10. synpath/engine/eod.py +154 -0
  11. synpath/engine/events.py +140 -0
  12. synpath/engine/fair_values.py +117 -0
  13. synpath/engine/feeds.py +220 -0
  14. synpath/engine/journal.py +907 -0
  15. synpath/engine/ledger.py +353 -0
  16. synpath/engine/orders/__init__.py +42 -0
  17. synpath/engine/orders/base.py +441 -0
  18. synpath/engine/orders/day.py +72 -0
  19. synpath/engine/orders/iceberg.py +121 -0
  20. synpath/engine/orders/manager.py +223 -0
  21. synpath/engine/orders/oco.py +255 -0
  22. synpath/engine/orders/peg.py +168 -0
  23. synpath/engine/orders/routed.py +496 -0
  24. synpath/engine/orders/stop.py +240 -0
  25. synpath/engine/orders/taker.py +187 -0
  26. synpath/engine/orders/twap.py +190 -0
  27. synpath/engine/paper.py +532 -0
  28. synpath/engine/reconcile.py +279 -0
  29. synpath/engine/risk.py +403 -0
  30. synpath/engine/router.py +261 -0
  31. synpath/errors.py +98 -0
  32. synpath/history.py +71 -0
  33. synpath/hosted.py +86 -0
  34. synpath/hosted_auth.py +201 -0
  35. synpath/ids.py +61 -0
  36. synpath/kalshi.py +1378 -0
  37. synpath/matching.py +86 -0
  38. synpath/polymarket.py +1004 -0
  39. synpath/polymarket_us.py +989 -0
  40. synpath/remote.py +195 -0
  41. synpath/server/__init__.py +98 -0
  42. synpath/server/__main__.py +118 -0
  43. synpath/server/api.py +439 -0
  44. synpath/server/errors.py +87 -0
  45. synpath/server/local.py +96 -0
  46. synpath/server/models.py +75 -0
  47. synpath/server/serve.py +236 -0
  48. synpath/server/store.py +363 -0
  49. synpath/server/trading.py +764 -0
  50. synpath/trading/__init__.py +79 -0
  51. synpath/trading/__main__.py +69 -0
  52. synpath/trading/base.py +126 -0
  53. synpath/trading/credentials.py +400 -0
  54. synpath/trading/errors.py +94 -0
  55. synpath/trading/init.py +233 -0
  56. synpath/trading/instruments.py +162 -0
  57. synpath/trading/kalshi.py +957 -0
  58. synpath/trading/limiter.py +177 -0
  59. synpath/trading/money.py +172 -0
  60. synpath/trading/polymarket.py +1362 -0
  61. synpath/trading/polymarket_signing.py +478 -0
  62. synpath/trading/polymarket_us.py +705 -0
  63. synpath/trading/polymarket_us_exchange.py +825 -0
  64. synpath/trading/types.py +414 -0
  65. synpath/types.py +608 -0
  66. synpath/ws/__init__.py +55 -0
  67. synpath/ws/base.py +544 -0
  68. synpath/ws/grpc.py +578 -0
  69. synpath/ws/kalshi.py +418 -0
  70. synpath/ws/polymarket.py +430 -0
  71. synpath/ws/polymarket_us.py +299 -0
  72. synpath/ws/polymarket_us_exchange.py +754 -0
  73. synpath-0.1.0.dist-info/METADATA +224 -0
  74. synpath-0.1.0.dist-info/RECORD +77 -0
  75. synpath-0.1.0.dist-info/WHEEL +4 -0
  76. synpath-0.1.0.dist-info/entry_points.txt +2 -0
  77. synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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()