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
|
@@ -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}
|