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,957 @@
1
+ """Kalshi order entry, on the V2 event-contract endpoints.
2
+
3
+ Two facts about the venue shape everything here.
4
+
5
+ **One leg on the wire.** Kalshi V2 quotes the YES leg only: `side=bid` is buy
6
+ YES, `side=ask` is sell YES, and the price is the YES price. A NO order is
7
+ the same order seen from the other side -- buying NO at *q* is selling YES at
8
+ `1 - q` -- so that is what is sent, and that is how the venue reports it
9
+ back. An order placed as "buy NO at 0.30" is therefore read back as "sell
10
+ YES at 0.70": the same order, in the venue's own words. `info` keeps the
11
+ venue's `outcome_side`, and the engine's journal keeps the request as it was
12
+ made.
13
+
14
+ **Limit orders only.** There is no market order type on V2. A market order
15
+ here is an immediate-or-cancel limit at the caller's protection price, and a
16
+ request without one is refused rather than sent at a price nobody chose.
17
+
18
+ Every request is signed: `timestamp + METHOD + path`, RSA-PSS with SHA-256
19
+ over the full request path (`/trade-api/v2/...`, no query string), sent in
20
+ three headers. The private key never leaves `KalshiSigner`.
21
+
22
+ The venue's budget is metered in tokens by account tier (`GET
23
+ /account/limits`); `fetch_limits` reads it and hands it to the budget
24
+ limiter so the adapter paces to what the account actually has.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import base64
29
+ import time
30
+ import uuid
31
+ from decimal import Decimal
32
+ from typing import Any
33
+
34
+ from ..base import AsyncHttpClient, Capability
35
+ from .. import ids
36
+ from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound, NotSupported
37
+ from ..kalshi import parse_ts, to_float
38
+ from ..types import FeeSchedule, Page
39
+ from .base import TradingExchange
40
+ from .credentials import KalshiCredentials
41
+ from .errors import (
42
+ DuplicateClientOrderId, InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected,
43
+ )
44
+ from .limiter import BudgetLimiter, Priority
45
+ from .money import D, kalshi_count, kalshi_dollars, validate_amount, validate_price
46
+ from .types import (
47
+ Account, Balance, EditRequest, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest,
48
+ OrderStatus, OrderType, Position, PositionSide, Precision, Settlement, SettlementState, Side,
49
+ TimeInForce, VENUE_ORDER_TYPES,
50
+ )
51
+
52
+ VENUE = "kalshi"
53
+
54
+ BASE_URLS = {
55
+ "demo": "https://external-api.demo.kalshi.co/trade-api/v2",
56
+ "prod": "https://external-api.kalshi.com/trade-api/v2",
57
+ }
58
+
59
+ TIF_TO_VENUE = {
60
+ TimeInForce.GTC: "good_till_canceled",
61
+ TimeInForce.IOC: "immediate_or_cancel",
62
+ TimeInForce.FOK: "fill_or_kill",
63
+ TimeInForce.GTD: "good_till_canceled",
64
+ }
65
+ """`gtd` is `good_till_canceled` plus `expiration_time`. `day` is not here on
66
+ purpose: the engine rewrites it to `gtd` at the session end before an
67
+ adapter sees it, and an adapter that received one would have to invent a
68
+ session."""
69
+
70
+ DEFAULT_COST = 10
71
+ """Tokens a call costs unless the venue's endpoint-cost table says
72
+ otherwise; one order is one default call, a batch is one per order."""
73
+
74
+ KNOWN_COSTS: dict[tuple[str, str], int] = {
75
+ ("DELETE", "/trade-api/v2/portfolio/events/orders"): 2,
76
+ ("DELETE", "/trade-api/v2/portfolio/events/orders/:order_id"): 2,
77
+ ("DELETE", "/trade-api/v2/portfolio/events/orders/batched"): 2,
78
+ ("GET", "/trade-api/v2/portfolio/orders/:order_id"): 2,
79
+ ("POST", "/trade-api/v2/communications/quotes"): 2,
80
+ ("DELETE", "/trade-api/v2/communications/rfqs/:rfq_id/quotes/:quote_id"): 2,
81
+ ("GET", "/trade-api/v2/communications/rfqs/:rfq_id/quotes/:quote_id"): 2,
82
+ ("PUT", "/trade-api/v2/communications/rfqs/:rfq_id/quotes/:quote_id/confirm"): 1,
83
+ }
84
+ """The venue's published non-default costs as of 2026-09-17, used until
85
+ `fetch_limits` replaces them with the live table. Paths are the venue's
86
+ own patterns: `:name` matches one segment, `*name` the rest."""
87
+
88
+ DEFAULT_PRECISION = Precision(
89
+ tick=Decimal("0.01"), min_amount=Decimal("0.01"), amount_step=Decimal("0.01"), whole_contracts=False,
90
+ )
91
+ """What an order is checked against when the caller gives no instrument
92
+ spec: the coarse tick every market accepts, and the venue's two-decimal
93
+ contract count. A market with a finer ladder wants its own `Precision`."""
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Signing
98
+ # ---------------------------------------------------------------------------
99
+
100
+ class KalshiSigner:
101
+ """Holds the private key and produces the three auth headers.
102
+
103
+ The signing payload is `timestamp_ms + METHOD + path` with `path` the full
104
+ request path including the `/trade-api/v2` prefix and excluding any query
105
+ string, per the venue's own examples. RSA-PSS, SHA-256, salt length equal
106
+ to the digest -- the combination the venue's reference client uses and
107
+ that a `openssl dgst -sigopt rsa_pss_saltlen:digest` reproduces.
108
+ """
109
+
110
+ def __init__(self, key_id: str, private_key_pem: bytes):
111
+ try:
112
+ from cryptography.hazmat.primitives import serialization
113
+ except ImportError as exc: # pragma: no cover - depends on the environment
114
+ raise ImportError("Kalshi signing needs the `cryptography` package: pip install synpath") from exc
115
+ self.key_id = key_id
116
+ self._key = serialization.load_pem_private_key(private_key_pem, password=None)
117
+
118
+ def sign(self, timestamp_ms: int, method: str, path: str) -> str:
119
+ from cryptography.hazmat.primitives import hashes
120
+ from cryptography.hazmat.primitives.asymmetric import padding
121
+
122
+ payload = f"{timestamp_ms}{method.upper()}{path}".encode()
123
+ signature = self._key.sign(
124
+ payload,
125
+ padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.DIGEST_LENGTH),
126
+ hashes.SHA256(),
127
+ )
128
+ return base64.b64encode(signature).decode()
129
+
130
+ def headers(self, method: str, path: str, *, timestamp_ms: int | None = None) -> dict[str, str]:
131
+ stamp = timestamp_ms if timestamp_ms is not None else int(time.time() * 1000)
132
+ return {
133
+ "KALSHI-ACCESS-KEY": self.key_id,
134
+ "KALSHI-ACCESS-SIGNATURE": self.sign(stamp, method, path),
135
+ "KALSHI-ACCESS-TIMESTAMP": str(stamp),
136
+ }
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Pure translation and normalizers
141
+ # ---------------------------------------------------------------------------
142
+
143
+ def ticker_of(market_id: str) -> str:
144
+ """The ticker behind a Synpath market id (`kalshi:KXFOO-25`) or a bare
145
+ ticker. Another venue's id is refused before anything is signed."""
146
+ try:
147
+ return ids.native(VENUE, market_id)
148
+ except BadRequest as exc:
149
+ raise InvalidOrder(str(exc)) from None
150
+
151
+
152
+ def translate_order(request: OrderRequest, *, precision: Precision | None = None) -> dict[str, Any]:
153
+ """An `OrderRequest` as the V2 create body, on the YES leg.
154
+
155
+ Validation happens here, before anything is signed: price on the tick and
156
+ inside (0, 1), amount on the venue's two-decimal grid, an order type the
157
+ venue holds, a time in force it understands, a protection price for a
158
+ market order.
159
+ """
160
+ if request.type not in VENUE_ORDER_TYPES:
161
+ raise InvalidOrder(
162
+ f"kalshi: {request.type.value} is held by the execution engine, not the venue; "
163
+ f"submit it through the engine"
164
+ )
165
+ if request.time_in_force == TimeInForce.DAY:
166
+ raise InvalidOrder("kalshi: 'day' is rewritten to 'gtd' by the engine; an adapter cannot pick a session end")
167
+ if request.price is None:
168
+ raise InvalidOrder(
169
+ "kalshi: a price is required -- the venue has no market orders, so a market order is an "
170
+ "immediate-or-cancel limit at the protection price you give"
171
+ )
172
+ ticker = ticker_of(request.market_id)
173
+ spec = precision or DEFAULT_PRECISION
174
+ price = validate_price(D(request.price), spec)
175
+ amount = validate_amount(D(request.amount), spec)
176
+
177
+ # The venue quotes the YES leg and so does this library: `buy` is a YES bid
178
+ # at the price given, `sell` is a YES ask at the same price, which is what
179
+ # the venue shows a NO buyer as.
180
+ book_side, yes_price = ("bid" if request.side == Side.BUY else "ask"), price
181
+
182
+ if request.type == OrderType.MARKET:
183
+ tif = "fill_or_kill" if request.time_in_force == TimeInForce.FOK else "immediate_or_cancel"
184
+ else:
185
+ tif = TIF_TO_VENUE[request.time_in_force]
186
+
187
+ params = dict(request.params)
188
+ body: dict[str, Any] = {
189
+ "ticker": ticker,
190
+ "client_order_id": request.client_order_id or str(uuid.uuid4()),
191
+ "side": book_side,
192
+ "count": kalshi_count(amount),
193
+ "price": kalshi_dollars(yes_price),
194
+ "time_in_force": tif,
195
+ "self_trade_prevention_type": params.pop("self_trade_prevention_type", "taker_at_cross"),
196
+ "post_only": request.post_only,
197
+ "reduce_only": request.reduce_only,
198
+ }
199
+ if request.time_in_force == TimeInForce.GTD and request.expires_at is not None:
200
+ body["expiration_time"] = int(request.expires_at // 1000)
201
+ if request.account and request.account.subaccount is not None:
202
+ body["subaccount"] = int(request.account.subaccount)
203
+ for key in ("cancel_order_on_pause", "order_group_id", "exchange_index"):
204
+ if key in params:
205
+ body[key] = params.pop(key)
206
+ return body
207
+
208
+
209
+ def _status_of(raw: dict[str, Any], *, filled: Decimal, remaining: Decimal) -> OrderStatus:
210
+ native = str(raw.get("status") or "").lower()
211
+ if native == "resting":
212
+ return OrderStatus.OPEN
213
+ if native == "executed":
214
+ return OrderStatus.CLOSED
215
+ if native == "canceled":
216
+ return OrderStatus.CANCELED
217
+ # The create, amend and decrease responses carry no status word, only the
218
+ # two counts; what they mean is unambiguous: nothing left to rest is done
219
+ # if anything matched and cancelled if nothing did (an IOC that found no
220
+ # liquidity, a decrease to zero).
221
+ if remaining == 0:
222
+ return OrderStatus.CLOSED if filled > 0 else OrderStatus.CANCELED
223
+ return OrderStatus.OPEN
224
+
225
+
226
+ def _side_of(raw: dict[str, Any]) -> Side:
227
+ book = str(raw.get("book_side") or raw.get("side") or "").lower()
228
+ if book in ("bid", "ask"):
229
+ return Side.BUY if book == "bid" else Side.SELL
230
+ # Legacy rows carry action + (yes|no) side: buy yes / sell no are bids.
231
+ action, side = str(raw.get("action") or "").lower(), str(raw.get("side") or "").lower()
232
+ return Side.BUY if (action == "buy") == (side == "yes") else Side.SELL
233
+
234
+
235
+ def order_of(raw: dict[str, Any], *, account: Account | None = None) -> Order:
236
+ """A venue order row as an `Order`, on the YES leg."""
237
+ ticker = str(raw.get("ticker") or "")
238
+ filled = D(raw.get("fill_count_fp") or raw.get("fill_count") or "0")
239
+ remaining = D(raw.get("remaining_count_fp") or raw.get("remaining_count") or "0")
240
+ initial = raw.get("initial_count_fp")
241
+ amount = D(initial) if initial is not None else filled + remaining
242
+ taker_cost = D(raw.get("taker_fill_cost_dollars") or "0")
243
+ maker_cost = D(raw.get("maker_fill_cost_dollars") or "0")
244
+ fee = D(raw.get("taker_fees_dollars") or "0") + D(raw.get("maker_fees_dollars") or "0")
245
+ cost = taker_cost + maker_cost
246
+ expires = parse_ts(raw.get("expiration_time"))
247
+ return Order(
248
+ id=str(raw.get("order_id") or ""),
249
+ client_order_id=raw.get("client_order_id") or None,
250
+ venue=VENUE,
251
+ account=account,
252
+ market_id=ids.qualify(VENUE, ticker),
253
+ side=_side_of(raw),
254
+ type=OrderType.MARKET if str(raw.get("type") or "").lower() == "market" else OrderType.LIMIT,
255
+ time_in_force=TimeInForce.GTD if expires else TimeInForce.GTC,
256
+ status=_status_of(raw, filled=filled, remaining=remaining),
257
+ held_by=HeldBy.VENUE,
258
+ price=D(raw["yes_price_dollars"]) if raw.get("yes_price_dollars") is not None else None,
259
+ amount=amount,
260
+ filled=filled,
261
+ remaining=remaining,
262
+ average_price=(cost / filled).quantize(Decimal("0.0001")) if filled > 0 and cost > 0 else None,
263
+ cost=cost if filled > 0 else None,
264
+ fee=fee if filled > 0 else None,
265
+ fee_currency="USD",
266
+ expires_at=expires,
267
+ created_at=parse_ts(raw.get("created_time")),
268
+ updated_at=parse_ts(raw.get("last_update_time")),
269
+ info=raw,
270
+ )
271
+
272
+
273
+ def order_from_response(
274
+ raw: dict[str, Any], *, body: dict[str, Any], account: Account | None = None,
275
+ filled: Decimal | None = None,
276
+ ) -> Order:
277
+ """The create, amend or decrease response, which carries only the counts,
278
+ joined with the request that produced it. `filled` stands in when the
279
+ response omits `fill_count` (amend and decrease do)."""
280
+ if raw.get("fill_count") not in (None, ""):
281
+ filled = D(raw["fill_count"])
282
+ elif filled is None:
283
+ filled = Decimal("0")
284
+ remaining = D(raw.get("remaining_count") or "0")
285
+ average = raw.get("average_fill_price")
286
+ ts_ms = raw.get("ts_ms")
287
+ return Order(
288
+ id=str(raw.get("order_id") or ""),
289
+ client_order_id=raw.get("client_order_id") or body.get("client_order_id"),
290
+ venue=VENUE,
291
+ account=account,
292
+ market_id=ids.qualify(VENUE, body["ticker"]),
293
+ side=Side.BUY if body.get("side") == "bid" else Side.SELL,
294
+ type=OrderType.LIMIT,
295
+ time_in_force=(
296
+ TimeInForce.GTD if body.get("expiration_time") else
297
+ {"good_till_canceled": TimeInForce.GTC, "immediate_or_cancel": TimeInForce.IOC,
298
+ "fill_or_kill": TimeInForce.FOK}.get(str(body.get("time_in_force")), TimeInForce.GTC)
299
+ ),
300
+ status=_status_of(raw, filled=filled, remaining=remaining),
301
+ price=D(body["price"]) if body.get("price") is not None else None,
302
+ amount=filled + remaining,
303
+ filled=filled,
304
+ remaining=remaining,
305
+ average_price=D(average) if average not in (None, "") else None,
306
+ fee=D(raw["average_fee_paid"]) * filled if raw.get("average_fee_paid") not in (None, "") and filled > 0 else None,
307
+ fee_currency="USD",
308
+ post_only=bool(body.get("post_only")),
309
+ reduce_only=bool(body.get("reduce_only")),
310
+ expires_at=int(body["expiration_time"]) * 1000 if body.get("expiration_time") else None,
311
+ created_at=int(ts_ms) if ts_ms is not None else None,
312
+ updated_at=int(ts_ms) if ts_ms is not None else None,
313
+ info={"response": raw, "request": body},
314
+ )
315
+
316
+
317
+ def fill_of(raw: dict[str, Any], *, account: Account | None = None) -> Fill:
318
+ ticker = str(raw.get("ticker") or raw.get("market_ticker") or "")
319
+ stamp = parse_ts(raw.get("created_time")) or parse_ts(raw.get("ts")) or 0
320
+ return Fill(
321
+ id=str(raw.get("fill_id") or raw.get("trade_id") or ""),
322
+ order_id=str(raw.get("order_id") or ""),
323
+ venue=VENUE,
324
+ account=account,
325
+ market_id=ids.qualify(VENUE, ticker),
326
+ side=_side_of(raw),
327
+ price=D(raw["yes_price_dollars"]),
328
+ amount=D(raw.get("count_fp") or raw.get("count") or "0"),
329
+ fee=D(raw["fee_cost"]) if raw.get("fee_cost") not in (None, "") else None,
330
+ fee_currency="USD",
331
+ liquidity=Liquidity.TAKER if raw.get("is_taker") else Liquidity.MAKER,
332
+ settlement=SettlementState.CONFIRMED,
333
+ timestamp=stamp,
334
+ info=raw,
335
+ )
336
+
337
+
338
+ def position_of(raw: dict[str, Any], *, account: Account | None = None) -> Position:
339
+ """A market position row. `position_fp` is signed: positive is YES
340
+ contracts (`long`), negative is NO (`short`). The venue nets, so there
341
+ is no inventory to carry."""
342
+ ticker = str(raw.get("ticker") or "")
343
+ signed = D(raw.get("position_fp") or "0")
344
+ contracts = abs(signed)
345
+ exposure = D(raw.get("market_exposure_dollars") or "0")
346
+ return Position(
347
+ venue=VENUE,
348
+ account=account,
349
+ market_id=ids.qualify(VENUE, ticker),
350
+ side=PositionSide.FLAT if contracts == 0 else PositionSide.LONG if signed > 0 else PositionSide.SHORT,
351
+ contracts=contracts,
352
+ entry_price=(exposure / contracts).quantize(Decimal("0.0001")) if contracts > 0 else None,
353
+ realized_pnl=D(raw["realized_pnl_dollars"]) if raw.get("realized_pnl_dollars") not in (None, "") else None,
354
+ margin=exposure if contracts > 0 else None,
355
+ timestamp=parse_ts(raw.get("last_updated_ts")),
356
+ info=raw,
357
+ )
358
+
359
+
360
+ def settlement_of(raw: dict[str, Any], *, account: Account | None = None) -> Settlement:
361
+ ticker = str(raw.get("ticker") or "")
362
+ result = str(raw.get("market_result") or "") or None
363
+ yes_count = D(raw.get("yes_count_fp") or "0")
364
+ no_count = D(raw.get("no_count_fp") or "0")
365
+ cost = D(raw.get("yes_total_cost_dollars") or "0") + D(raw.get("no_total_cost_dollars") or "0")
366
+ revenue = raw.get("revenue")
367
+ payout = (D(revenue) / 100).quantize(Decimal("0.01")) if revenue is not None else None
368
+ fee = D(raw["fee_cost"]) if raw.get("fee_cost") not in (None, "") else Decimal("0")
369
+ held = "yes" if yes_count > no_count else "no" if no_count > yes_count else None
370
+ won = (held == result) if held and result in ("yes", "no") else None
371
+ return Settlement(
372
+ venue=VENUE,
373
+ account=account,
374
+ market_id=ids.qualify(VENUE, ticker),
375
+ held=PositionSide.LONG if held == "yes" else PositionSide.SHORT if held == "no" else None,
376
+ result=result,
377
+ won=won,
378
+ amount=max(yes_count, no_count) if held else None,
379
+ cost=cost,
380
+ payout=payout,
381
+ pnl=(payout - cost - fee) if payout is not None else None,
382
+ timestamp=parse_ts(raw.get("settled_time")),
383
+ info=raw,
384
+ )
385
+
386
+
387
+ def balance_of(raw: dict[str, Any], *, account: Account) -> Balance:
388
+ """`balance` is the venue's available cash; the portfolio's market value
389
+ rides along in `info`. Kalshi reports no figure for cash reserved by
390
+ resting orders, so `locked` is `None` rather than a guess."""
391
+ available = D(raw["balance_dollars"]) if raw.get("balance_dollars") is not None else D(raw.get("balance") or 0) / 100
392
+ return Balance(
393
+ venue=VENUE,
394
+ account=account,
395
+ currency="USD",
396
+ total=available,
397
+ available=available,
398
+ locked=None,
399
+ buying_power=None,
400
+ timestamp=parse_ts(raw.get("updated_ts")),
401
+ info=raw,
402
+ )
403
+
404
+
405
+ def map_error(exc: ExchangeError, *, path: str = "") -> ExchangeError:
406
+ """The venue's error code as the typed error a caller branches on.
407
+
408
+ `path` is the request path: the venue answers `not_found` for a missing
409
+ order and a missing market alike, and only the path says which."""
410
+ code = (exc.code or "").lower()
411
+ message = str(exc)
412
+ text = f"{code} {message}".lower()
413
+ if isinstance(exc, AuthenticationError):
414
+ return exc
415
+ if "insufficient" in text and ("balance" in text or "fund" in text):
416
+ return InsufficientFunds(message, body=exc.body, status=exc.status)
417
+ missing = "not_found" in text or "not found" in text or "no such" in text or isinstance(exc, MarketNotFound)
418
+ if missing and ("/orders" in path or "order" in text):
419
+ return OrderNotFound(message, body=exc.body, status=exc.status)
420
+ if "client_order_id" in text and ("duplicate" in text or "already" in text or "exists" in text):
421
+ return DuplicateClientOrderId(message)
422
+ if any(word in text for word in ("market_closed", "market is closed", "not open", "paused", "halted", "market_not_open")):
423
+ return MarketHalted(message, body=exc.body, status=exc.status)
424
+ if isinstance(exc, MarketNotFound):
425
+ return exc
426
+ if isinstance(exc, BadRequest):
427
+ return OrderRejected(message, reason=code or None, info=exc.body if isinstance(exc.body, dict) else {}, body=exc.body, status=exc.status)
428
+ return exc
429
+
430
+
431
+ # ---------------------------------------------------------------------------
432
+ # Adapter
433
+ # ---------------------------------------------------------------------------
434
+
435
+ class KalshiTrading(TradingExchange):
436
+ """Kalshi order entry.
437
+
438
+ ```python
439
+ from synpath.trading.credentials import load_credentials, require
440
+ from synpath.trading.kalshi import KalshiTrading
441
+
442
+ creds = require("kalshi", load_credentials())
443
+ async with KalshiTrading(creds) as kalshi:
444
+ balance = await kalshi.fetch_balance()
445
+ ```
446
+ """
447
+
448
+ id = VENUE
449
+ name = "Kalshi"
450
+ has: dict[str, Capability] = {
451
+ "create_order": True,
452
+ "create_orders": True,
453
+ "cancel_order": True,
454
+ "cancel_orders": True,
455
+ "cancel_all_orders": True,
456
+ "edit_order": True,
457
+ "fetch_order": True,
458
+ "fetch_open_orders": True,
459
+ "fetch_orders": True,
460
+ "fetch_my_trades": True,
461
+ "fetch_positions": True,
462
+ "fetch_balance": True,
463
+ "fetch_settlements": True,
464
+ "fetch_queue_position": True,
465
+ "fetch_fee_estimate": True,
466
+ "rfq": True,
467
+ # Kalshi nets YES against NO in one account; there are no token
468
+ # inventories to split or merge.
469
+ "split_merge": False,
470
+ # The user WebSocket channels arrive with the WebSocket layer.
471
+ "watch_orders": False,
472
+ "watch_my_trades": False,
473
+ "watch_positions": False,
474
+ "watch_balance": False,
475
+ }
476
+
477
+ def __init__(
478
+ self,
479
+ credentials: KalshiCredentials,
480
+ *,
481
+ account_name: str = "default",
482
+ base_url: str | None = None,
483
+ limiter: BudgetLimiter | None = None,
484
+ timeout: float = 30.0,
485
+ client: Any = None,
486
+ ):
487
+ self.credentials = credentials
488
+ self.env = credentials.env
489
+ self.account = Account(venue=VENUE, name=account_name)
490
+ self.base_url = (base_url or BASE_URLS[credentials.env]).rstrip("/")
491
+ self._path_prefix = _path_of(self.base_url)
492
+ self.signer = KalshiSigner(credentials.key_id, credentials.private_key_pem)
493
+ # Basic tier until `fetch_limits` says otherwise: 200 read / 100 write
494
+ # tokens per second, the smallest budget any account has.
495
+ self.limiter = limiter or BudgetLimiter(read_per_second=200, write_per_second=100)
496
+ self.http = AsyncHttpClient(self.base_url, limiter=None, timeout=timeout, client=client, venue=VENUE)
497
+ self.default_cost = DEFAULT_COST
498
+ self.endpoint_costs: dict[tuple[str, str], int] = dict(KNOWN_COSTS)
499
+
500
+ # -- transport ------------------------------------------------------------
501
+
502
+ async def _call(
503
+ self, method: str, path: str, *, params: Any = None, json: Any = None,
504
+ kind: str = "read", cost: float | None = None, priority: Priority = Priority.NORMAL,
505
+ ) -> Any:
506
+ """One signed call, paced by the budget, errors mapped. `cost` is
507
+ looked up in the venue's endpoint-cost table unless given."""
508
+ full_path = f"{self._path_prefix}{path}"
509
+ if cost is None:
510
+ cost = self.cost_of(method, full_path)
511
+ await self.limiter.acquire(cost=cost, kind=kind, priority=priority) # type: ignore[arg-type]
512
+ headers = self.signer.headers(method, full_path)
513
+ try:
514
+ return await self.http.request(method, path, params=_clean(params), json=json, headers=headers)
515
+ except ExchangeError as exc:
516
+ raise map_error(exc, path=path) from None
517
+
518
+ def cost_of(self, method: str, full_path: str) -> int:
519
+ """Tokens the venue charges for this call, from its cost table.
520
+
521
+ The table's paths are patterns: `:order_id` matches one segment,
522
+ `*endpoint` everything after it. A literal entry wins over a pattern
523
+ so `/orders/batched` is not read as `/orders/:order_id`."""
524
+ method = method.upper()
525
+ literal = self.endpoint_costs.get((method, full_path))
526
+ if literal is not None:
527
+ return literal
528
+ segments = full_path.split("/")
529
+ for (table_method, pattern), cost in self.endpoint_costs.items():
530
+ if table_method != method:
531
+ continue
532
+ parts = pattern.split("/")
533
+ if _matches(parts, segments):
534
+ return cost
535
+ return self.default_cost
536
+
537
+ # -- orders ---------------------------------------------------------------
538
+
539
+ async def create_order(self, request: OrderRequest, *, precision: Precision | None = None) -> Order:
540
+ """Place one order. See the module docstring for how a NO order and a
541
+ market order reach the wire."""
542
+ body = translate_order(request, precision=precision)
543
+ raw = await self._call("POST", "/portfolio/events/orders", json=body, kind="write")
544
+ order = order_from_response(raw, body=body, account=request.account or self.account)
545
+ return order.model_copy(update={"book": request.book, "trader": request.trader, "tags": request.tags})
546
+
547
+ async def create_orders(
548
+ self, requests: list[OrderRequest], *, precision: Precision | None = None,
549
+ ) -> list[Order | Exception]:
550
+ """Many orders in one request. Each costs the venue what a single
551
+ order costs, so the budget is drawn per order, not per call."""
552
+ bodies: list[dict[str, Any] | Exception] = []
553
+ for request in requests:
554
+ try:
555
+ bodies.append(translate_order(request, precision=precision))
556
+ except InvalidOrder as exc:
557
+ bodies.append(exc)
558
+ sendable = [b for b in bodies if isinstance(b, dict)]
559
+ results: list[Order | Exception] = list(bodies) # type: ignore[arg-type]
560
+ if not sendable:
561
+ return results
562
+ raw = await self._call(
563
+ "POST", "/portfolio/events/orders/batched", json={"orders": sendable},
564
+ kind="write", cost=self.default_cost * len(sendable),
565
+ )
566
+ answers = list((raw or {}).get("orders") or [])
567
+ cursor = 0
568
+ for index, body in enumerate(bodies):
569
+ if not isinstance(body, dict):
570
+ continue
571
+ answer = answers[cursor] if cursor < len(answers) else {}
572
+ cursor += 1
573
+ error = answer.get("error")
574
+ if error:
575
+ results[index] = OrderRejected(str(error.get("message") or error), reason=str(error.get("code") or ""), info=error)
576
+ else:
577
+ results[index] = order_from_response(answer, body=body, account=requests[index].account or self.account)
578
+ return results
579
+
580
+ async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order:
581
+ raw = await self._call(
582
+ "DELETE", f"/portfolio/events/orders/{order_id}",
583
+ params={"market_ticker": ticker_of(market_id) if market_id else None}, kind="write", priority=Priority.HIGH,
584
+ )
585
+ return await self._after_cancel(order_id, raw)
586
+
587
+ async def _after_cancel(self, order_id: str, raw: dict[str, Any]) -> Order:
588
+ """The cancel response carries only `reduced_by`; the order itself is
589
+ read back so the caller sees what filled before the cancel landed.
590
+
591
+ The venue acknowledged the cancel, so the order *is* cancelled; the
592
+ read-back can still say `resting` for a beat, and is corrected
593
+ rather than trusted.
594
+ """
595
+ reduced = D(raw.get("reduced_by") or "0")
596
+ try:
597
+ order = await self.fetch_order(order_id)
598
+ except OrderNotFound:
599
+ return Order(
600
+ id=order_id, venue=VENUE, account=self.account, market_id="",
601
+ side=Side.BUY, type=OrderType.LIMIT, time_in_force=TimeInForce.GTC,
602
+ status=OrderStatus.CANCELED, amount=reduced, info=raw,
603
+ )
604
+ remaining = order.remaining if order.remaining is not None else order.amount - order.filled
605
+ if order.status == OrderStatus.OPEN:
606
+ remaining = max(Decimal("0"), remaining - reduced)
607
+ status = OrderStatus.CLOSED if order.status == OrderStatus.CLOSED else OrderStatus.CANCELED
608
+ return order.model_copy(update={
609
+ "status": status, "remaining": remaining, "info": {**order.info, "cancel": raw},
610
+ })
611
+
612
+ async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]:
613
+ raw = await self._call(
614
+ "DELETE", "/portfolio/events/orders/batched",
615
+ json={"orders": [{"order_id": oid, "market_ticker": ticker_of(market_id)} if market_id else {"order_id": oid} for oid in order_ids]},
616
+ kind="write", priority=Priority.HIGH,
617
+ )
618
+ answers = {str(a.get("order_id")): a for a in ((raw or {}).get("orders") or [])}
619
+ results: list[Order | Exception] = []
620
+ for order_id in order_ids:
621
+ answer = answers.get(order_id, {})
622
+ error = answer.get("error")
623
+ if error:
624
+ results.append(OrderRejected(str(error.get("message") or error), reason=str(error.get("code") or ""), info=error))
625
+ else:
626
+ results.append(await self._after_cancel(order_id, answer))
627
+ return results
628
+
629
+ async def cancel_all_orders(self, *, market_id: str | None = None) -> int | None:
630
+ """Every resting order in the account, or every one on a market.
631
+
632
+ The venue's cancel-all answers 204 with no count and works
633
+ asynchronously -- it may also cancel orders placed in the minute
634
+ after the call -- so the account-wide form returns `None`. The
635
+ per-market form is a batch cancel of that market's open orders and
636
+ does return how many it cancelled.
637
+ """
638
+ if market_id:
639
+ open_orders = await self.fetch_open_orders(market_id=market_id)
640
+ if not open_orders:
641
+ return 0
642
+ results = await self.cancel_orders([o.id for o in open_orders], market_id=market_id)
643
+ return sum(1 for r in results if not isinstance(r, Exception))
644
+ await self._call("DELETE", "/portfolio/events/orders", kind="write", priority=Priority.HIGH)
645
+ return None
646
+
647
+ async def edit_order(self, request: EditRequest, *, current: Order | None = None) -> Order:
648
+ """Change a resting order in place.
649
+
650
+ Only the amount coming down is a *decrease*, which keeps the order's
651
+ place in the queue. Anything else -- a new price, or more contracts --
652
+ is an *amend*, which the venue treats as a new order at the back of
653
+ the queue. `queue_priority_preserved` says which one this was.
654
+ """
655
+ current = current or await self.fetch_order(request.order_id)
656
+ if request.time_in_force is not None or request.expires_at is not None:
657
+ raise InvalidOrder("kalshi: time in force cannot be edited; cancel and replace")
658
+ price_changed = request.price is not None and D(request.price) != current.price
659
+ new_amount = D(request.amount) if request.amount is not None else None
660
+ as_sent = {
661
+ **(current.info.get("request") or {}), "ticker": ticker_of(current.market_id),
662
+ "side": "bid" if current.side == Side.BUY else "ask",
663
+ "price": kalshi_dollars(current.price) if current.price is not None else None,
664
+ "client_order_id": current.client_order_id,
665
+ }
666
+ if not price_changed and new_amount is not None and new_amount < current.amount:
667
+ raw = await self._call(
668
+ "POST", f"/portfolio/events/orders/{request.order_id}/decrease",
669
+ json={"reduce_to": kalshi_count(new_amount - current.filled), "market_ticker": ticker_of(current.market_id)},
670
+ kind="write", priority=Priority.HIGH,
671
+ )
672
+ order = self._edited(raw, body=as_sent, current=current)
673
+ return order.model_copy(update={"queue_priority_preserved": True})
674
+ if request.price is None and new_amount is None:
675
+ return current
676
+ price = D(request.price) if request.price is not None else current.price
677
+ amount = new_amount if new_amount is not None else current.amount
678
+ if price is None:
679
+ raise InvalidOrder("kalshi: the order has no price to amend")
680
+ body = {
681
+ "ticker": ticker_of(current.market_id),
682
+ "side": "bid" if current.side == Side.BUY else "ask",
683
+ "price": kalshi_dollars(validate_price(price, DEFAULT_PRECISION)),
684
+ "count": kalshi_count(validate_amount(amount, DEFAULT_PRECISION)),
685
+ "client_order_id": current.client_order_id,
686
+ "updated_client_order_id": request.client_order_id or str(uuid.uuid4()),
687
+ }
688
+ raw = await self._call(
689
+ "POST", f"/portfolio/events/orders/{request.order_id}/amend", json=body,
690
+ kind="write", priority=Priority.HIGH,
691
+ )
692
+ order = self._edited(raw, body={**body, "client_order_id": body["updated_client_order_id"]}, current=current)
693
+ return order.model_copy(update={"queue_priority_preserved": False})
694
+
695
+ def _edited(self, raw: dict[str, Any], *, body: dict[str, Any], current: Order) -> Order:
696
+ """An amend or decrease answer as an `Order`. The venue answers either
697
+ with the full order (`{"order": ...}`) or with the counts alone; the
698
+ counts alone omit what has filled, which the order read before the
699
+ edit supplies."""
700
+ if isinstance(raw, dict) and isinstance(raw.get("order"), dict):
701
+ return order_of(raw["order"], account=current.account)
702
+ answer = dict(raw or {})
703
+ if answer.get("remaining_count") in (None, "") and body.get("count") not in (None, ""):
704
+ # The amend answer carries no counts at all; the total it accepted
705
+ # is the one asked for, less what had already filled.
706
+ answer["remaining_count"] = kalshi_count(D(body["count"]) - current.filled)
707
+ order = order_from_response(answer, body=body, account=current.account, filled=current.filled)
708
+ return order.model_copy(update={"id": order.id or current.id, "created_at": current.created_at})
709
+
710
+ async def fetch_order(self, order_id: str, *, attempts: int = 3) -> Order:
711
+ """One order by id.
712
+
713
+ The venue's order store is a beat behind order entry: an order just
714
+ placed can answer 404 for a few hundred milliseconds. A miss is
715
+ retried briefly before it is reported, so read-after-write works.
716
+ """
717
+ import asyncio
718
+
719
+ for attempt in range(attempts):
720
+ try:
721
+ raw = await self._call("GET", f"/portfolio/orders/{order_id}")
722
+ except (MarketNotFound, OrderNotFound) as exc:
723
+ if attempt + 1 < attempts:
724
+ await asyncio.sleep(0.25 * (attempt + 1))
725
+ continue
726
+ raise OrderNotFound(f"kalshi: no order {order_id}", body=exc.body, status=exc.status) from None
727
+ inner = raw.get("order") if isinstance(raw, dict) else None
728
+ if inner:
729
+ return order_of(inner, account=self.account)
730
+ raise OrderNotFound(f"kalshi: no order {order_id}")
731
+
732
+ async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]:
733
+ orders: list[Order] = []
734
+ cursor: str | None = None
735
+ while True:
736
+ page = await self.fetch_orders(status="resting", market_id=market_id, limit=200, cursor=cursor)
737
+ orders.extend(page)
738
+ cursor = page.next_cursor
739
+ if not cursor or not page:
740
+ return orders
741
+
742
+ async def fetch_orders(
743
+ self, *, status: str | None = None, market_id: str | None = None,
744
+ since: int | None = None, limit: int | None = None, cursor: str | None = None,
745
+ ) -> Page[Order]:
746
+ """`status` is one of the venue's own words: resting, canceled, executed."""
747
+ raw = await self._call("GET", "/portfolio/orders", params={
748
+ "status": status, "ticker": ticker_of(market_id) if market_id else None,
749
+ "min_ts": int(since / 1000) if since else None,
750
+ "limit": limit, "cursor": cursor,
751
+ })
752
+ rows = (raw or {}).get("orders") or []
753
+ return Page([order_of(r, account=self.account) for r in rows], next_cursor=(raw or {}).get("cursor") or None)
754
+
755
+ async def fetch_my_trades(
756
+ self, *, market_id: str | None = None, order_id: str | None = None,
757
+ since: int | None = None, limit: int | None = None, cursor: str | None = None,
758
+ ) -> Page[Fill]:
759
+ raw = await self._call("GET", "/portfolio/fills", params={
760
+ "ticker": ticker_of(market_id) if market_id else None, "order_id": order_id,
761
+ "min_ts": int(since / 1000) if since else None,
762
+ "limit": limit, "cursor": cursor,
763
+ })
764
+ rows = (raw or {}).get("fills") or []
765
+ return Page([fill_of(r, account=self.account) for r in rows], next_cursor=(raw or {}).get("cursor") or None)
766
+
767
+ async def fetch_queue_position(self, order_id: str) -> Decimal:
768
+ raw = await self._call("GET", f"/portfolio/orders/{order_id}/queue_position")
769
+ return D((raw or {}).get("queue_position_fp") or "0")
770
+
771
+ # -- account --------------------------------------------------------------
772
+
773
+ async def fetch_balance(self, *, account: Account | None = None) -> Balance:
774
+ target = account or self.account
775
+ raw = await self._call("GET", "/portfolio/balance", params={
776
+ "subaccount": int(target.subaccount) if target.subaccount is not None else None,
777
+ })
778
+ return balance_of(raw, account=target)
779
+
780
+ async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]:
781
+ positions: list[Position] = []
782
+ cursor: str | None = None
783
+ while True:
784
+ raw = await self._call("GET", "/portfolio/positions", params={
785
+ "ticker": ticker_of(market_id) if market_id else None,
786
+ "event_ticker": ids.native(VENUE, event_id) if event_id else None, "count_filter": "position",
787
+ "limit": 200, "cursor": cursor,
788
+ })
789
+ rows = (raw or {}).get("market_positions") or []
790
+ positions.extend(position_of(r, account=self.account) for r in rows)
791
+ cursor = (raw or {}).get("cursor") or None
792
+ if not cursor or not rows:
793
+ return [p for p in positions if p.contracts > 0]
794
+
795
+ async def fetch_settlements(
796
+ self, *, market_id: str | None = None, since: int | None = None,
797
+ limit: int | None = None, cursor: str | None = None,
798
+ ) -> Page[Settlement]:
799
+ raw = await self._call("GET", "/portfolio/settlements", params={
800
+ "ticker": ticker_of(market_id) if market_id else None, "min_ts": int(since / 1000) if since else None,
801
+ "limit": limit, "cursor": cursor,
802
+ })
803
+ rows = (raw or {}).get("settlements") or []
804
+ return Page([settlement_of(r, account=self.account) for r in rows], next_cursor=(raw or {}).get("cursor") or None)
805
+
806
+ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, amount: Decimal) -> FeeEstimate:
807
+ """The venue's published fee for the market's series, evaluated at
808
+ `price` and `amount` -- the same formula the read API's
809
+ `FeeSchedule.estimate` uses. The fee is symmetric in the YES price,
810
+ so `side` does not change it."""
811
+ ticker = ticker_of(market_id)
812
+ series_id = ticker.split("-")[0]
813
+ raw = await self._call("GET", f"/series/{series_id}")
814
+ series = (raw or {}).get("series") or {}
815
+ if not series.get("fee_type"):
816
+ raise NotSupported(f"kalshi: series {series_id} publishes no fee schedule")
817
+ schedule = FeeSchedule(
818
+ venue=VENUE, scope="series", scope_id=series_id, fee_type=str(series["fee_type"]),
819
+ multiplier=to_float(series.get("fee_multiplier")), rounding="up_to_cent", info=series,
820
+ )
821
+ taker = schedule.estimate(float(price), float(amount), taker=True)
822
+ maker = schedule.estimate(float(price), float(amount), taker=False)
823
+ return FeeEstimate(
824
+ venue=VENUE, market_id=ids.qualify(VENUE, ticker), side=side, price=D(price), amount=D(amount),
825
+ taker_fee=D(str(taker)) if taker is not None else None,
826
+ maker_fee=D(str(maker)) if maker is not None else None,
827
+ currency="USD",
828
+ info={"schedule": schedule.model_dump()},
829
+ )
830
+
831
+ # -- budget ---------------------------------------------------------------
832
+
833
+ async def fetch_limits(self) -> dict[str, Any]:
834
+ """The account's live budget, adopted by the limiter.
835
+
836
+ Reads `GET /account/limits` (refill rate and capacity per bucket and
837
+ the usage tier) and `GET /account/endpoint_costs` (the endpoints that
838
+ cost other than the default 10 tokens), and reconfigures the limiter
839
+ to them. Call once at start-up; the tier changes with volume.
840
+ """
841
+ limits = await self._call("GET", "/account/limits")
842
+ costs = await self._call("GET", "/account/endpoint_costs")
843
+ read, write = (limits or {}).get("read") or {}, (limits or {}).get("write") or {}
844
+ if read.get("refill_rate") and write.get("refill_rate"):
845
+ self.limiter.configure(
846
+ read_per_second=float(read["refill_rate"]), write_per_second=float(write["refill_rate"]),
847
+ )
848
+ rows = (costs or {}).get("endpoint_costs") or []
849
+ if rows:
850
+ self.endpoint_costs = {
851
+ (str(row.get("method") or "").upper(), str(row.get("path") or "")): int(row.get("cost") or 0)
852
+ for row in rows
853
+ }
854
+ if (costs or {}).get("default_cost"):
855
+ self.default_cost = int(costs["default_cost"])
856
+ return {"limits": limits, "endpoint_costs": costs}
857
+
858
+ # -- order groups ---------------------------------------------------------
859
+
860
+ async def create_order_group(self, contracts_limit: Decimal | int) -> str:
861
+ """A venue-side kill switch: a rolling 15-second contract limit that,
862
+ when breached, cancels every order in the group and blocks new ones
863
+ until `reset_order_group`. Orders join a group through
864
+ `params={"order_group_id": ...}` on the request."""
865
+ raw = await self._call(
866
+ "POST", "/portfolio/order_groups/create",
867
+ json={"contracts_limit_fp": kalshi_count(D(contracts_limit))}, kind="write",
868
+ )
869
+ return str((raw or {}).get("order_group_id") or "")
870
+
871
+ async def trigger_order_group(self, order_group_id: str) -> None:
872
+ """Cancel every order in the group now and block new ones -- the kill switch, pulled."""
873
+ await self._call("PUT", f"/portfolio/order_groups/{order_group_id}/trigger", json={}, kind="write", priority=Priority.HIGH)
874
+
875
+ async def reset_order_group(self, order_group_id: str) -> None:
876
+ await self._call("PUT", f"/portfolio/order_groups/{order_group_id}/reset", json={}, kind="write")
877
+
878
+ async def update_order_group_limit(self, order_group_id: str, contracts_limit: Decimal | int) -> None:
879
+ await self._call(
880
+ "PUT", f"/portfolio/order_groups/{order_group_id}/limit",
881
+ json={"contracts_limit_fp": kalshi_count(D(contracts_limit))}, kind="write",
882
+ )
883
+
884
+ async def fetch_order_group(self, order_group_id: str) -> dict[str, Any]:
885
+ return await self._call("GET", f"/portfolio/order_groups/{order_group_id}")
886
+
887
+ async def delete_order_group(self, order_group_id: str) -> None:
888
+ await self._call("DELETE", f"/portfolio/order_groups/{order_group_id}", kind="write", priority=Priority.HIGH)
889
+
890
+ # -- RFQ ------------------------------------------------------------------
891
+
892
+ async def create_rfq(
893
+ self, market_id: str, contracts: Decimal | int, *, rest_remainder: bool = False,
894
+ target_cost: Decimal | None = None,
895
+ ) -> str:
896
+ """Ask market makers for a two-sided quote on `contracts` of a market.
897
+ `rest_remainder` leaves what a quote does not fill resting on the
898
+ book. Returns the RFQ id; quotes arrive through `fetch_quotes`."""
899
+ body: dict[str, Any] = {
900
+ "market_ticker": ticker_of(market_id), "contracts_fp": kalshi_count(D(contracts)), "rest_remainder": rest_remainder,
901
+ }
902
+ if target_cost is not None:
903
+ body["target_cost_dollars"] = kalshi_dollars(D(target_cost))
904
+ raw = await self._call("POST", "/communications/rfqs", json=body, kind="write")
905
+ return str((raw or {}).get("id") or "")
906
+
907
+ async def fetch_rfq(self, rfq_id: str) -> dict[str, Any]:
908
+ raw = await self._call("GET", f"/communications/rfqs/{rfq_id}")
909
+ return dict((raw or {}).get("rfq") or {})
910
+
911
+ async def fetch_quotes(self, rfq_id: str, *, status: str | None = "open") -> list[dict[str, Any]]:
912
+ """Quotes answering one of this account's RFQs. The venue filters by
913
+ the asking account, not by RFQ, so the RFQ is picked out here."""
914
+ raw = await self._call("GET", "/communications/quotes", params={
915
+ "rfq_user_filter": "self", "status": status, "limit": 500,
916
+ })
917
+ return [q for q in ((raw or {}).get("quotes") or []) if str(q.get("rfq_id")) == rfq_id]
918
+
919
+ async def accept_quote(self, rfq_id: str, quote_id: str, side: str) -> None:
920
+ """Take one side (`yes` or `no`) of a quote. The quoter then has a
921
+ last look and confirms; the trade prints on their confirmation."""
922
+ await self._call(
923
+ "PUT", f"/communications/rfqs/{rfq_id}/quotes/{quote_id}/accept",
924
+ json={"accepted_side": side}, kind="write",
925
+ )
926
+
927
+ async def delete_rfq(self, rfq_id: str) -> None:
928
+ await self._call("DELETE", f"/communications/rfqs/{rfq_id}", kind="write", priority=Priority.HIGH)
929
+
930
+ async def close(self) -> None:
931
+ await self.http.close()
932
+
933
+
934
+ def _path_of(url: str) -> str:
935
+ """`https://host/trade-api/v2` -> `/trade-api/v2`, the prefix the signature covers."""
936
+ from urllib.parse import urlsplit
937
+
938
+ return urlsplit(url).path.rstrip("/")
939
+
940
+
941
+ def _matches(pattern: list[str], segments: list[str]) -> bool:
942
+ for index, part in enumerate(pattern):
943
+ if part.startswith("*"):
944
+ return index < len(segments)
945
+ if index >= len(segments):
946
+ return False
947
+ if part.startswith(":"):
948
+ continue
949
+ if part != segments[index]:
950
+ return False
951
+ return len(pattern) == len(segments)
952
+
953
+
954
+ def _clean(params: Any) -> Any:
955
+ if params is None:
956
+ return None
957
+ return {key: value for key, value in params.items() if value is not None}