pykalshi 0.1.0__py3-none-any.whl → 0.2.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.
- pykalshi/__init__.py +144 -0
- pykalshi/api_keys.py +59 -0
- pykalshi/client.py +526 -0
- pykalshi/enums.py +54 -0
- pykalshi/events.py +87 -0
- pykalshi/exceptions.py +115 -0
- pykalshi/exchange.py +37 -0
- pykalshi/feed.py +592 -0
- pykalshi/markets.py +234 -0
- pykalshi/models.py +552 -0
- pykalshi/orderbook.py +146 -0
- pykalshi/orders.py +144 -0
- pykalshi/portfolio.py +542 -0
- pykalshi/py.typed +0 -0
- pykalshi/rate_limiter.py +171 -0
- {pykalshi-0.1.0.dist-info → pykalshi-0.2.0.dist-info}/METADATA +8 -8
- pykalshi-0.2.0.dist-info/RECORD +35 -0
- pykalshi-0.2.0.dist-info/top_level.txt +1 -0
- pykalshi-0.1.0.dist-info/RECORD +0 -20
- pykalshi-0.1.0.dist-info/top_level.txt +0 -1
- {pykalshi-0.1.0.dist-info → pykalshi-0.2.0.dist-info}/WHEEL +0 -0
- {pykalshi-0.1.0.dist-info → pykalshi-0.2.0.dist-info}/licenses/LICENSE +0 -0
pykalshi/models.py
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from functools import cached_property
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from pydantic import BaseModel, ConfigDict
|
|
5
|
+
from .enums import OrderStatus, Side, Action, OrderType, MarketStatus
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MarketModel(BaseModel):
|
|
9
|
+
"""Pydantic model for Market data."""
|
|
10
|
+
|
|
11
|
+
ticker: str
|
|
12
|
+
event_ticker: Optional[str] = None
|
|
13
|
+
series_ticker: Optional[str] = None
|
|
14
|
+
market_type: Optional[str] = None
|
|
15
|
+
title: Optional[str] = None
|
|
16
|
+
subtitle: Optional[str] = None
|
|
17
|
+
yes_sub_title: Optional[str] = None
|
|
18
|
+
no_sub_title: Optional[str] = None
|
|
19
|
+
|
|
20
|
+
# Timing
|
|
21
|
+
open_time: Optional[str] = None
|
|
22
|
+
close_time: Optional[str] = None
|
|
23
|
+
expiration_time: Optional[str] = None
|
|
24
|
+
expected_expiration_time: Optional[str] = None
|
|
25
|
+
latest_expiration_time: Optional[str] = None
|
|
26
|
+
created_time: Optional[str] = None
|
|
27
|
+
updated_time: Optional[str] = None
|
|
28
|
+
|
|
29
|
+
# Status & Result
|
|
30
|
+
status: Optional[MarketStatus] = None
|
|
31
|
+
result: Optional[str] = None
|
|
32
|
+
settlement_value: Optional[int] = None
|
|
33
|
+
|
|
34
|
+
# Pricing
|
|
35
|
+
yes_bid: Optional[int] = None
|
|
36
|
+
yes_ask: Optional[int] = None
|
|
37
|
+
no_bid: Optional[int] = None
|
|
38
|
+
no_ask: Optional[int] = None
|
|
39
|
+
last_price: Optional[int] = None
|
|
40
|
+
previous_yes_bid: Optional[int] = None
|
|
41
|
+
previous_yes_ask: Optional[int] = None
|
|
42
|
+
previous_price: Optional[int] = None
|
|
43
|
+
notional_value: Optional[int] = None
|
|
44
|
+
|
|
45
|
+
# Volume & Liquidity
|
|
46
|
+
volume: Optional[int] = None
|
|
47
|
+
volume_24h: Optional[int] = None
|
|
48
|
+
open_interest: Optional[int] = None
|
|
49
|
+
liquidity: Optional[int] = None
|
|
50
|
+
|
|
51
|
+
# Market structure
|
|
52
|
+
tick_size: Optional[int] = None
|
|
53
|
+
strike_type: Optional[str] = None
|
|
54
|
+
can_close_early: Optional[bool] = None
|
|
55
|
+
rules_primary: Optional[str] = None
|
|
56
|
+
rules_secondary: Optional[str] = None
|
|
57
|
+
|
|
58
|
+
model_config = ConfigDict(extra="ignore")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EventModel(BaseModel):
|
|
62
|
+
"""Pydantic model for Event data."""
|
|
63
|
+
|
|
64
|
+
event_ticker: str
|
|
65
|
+
series_ticker: str
|
|
66
|
+
title: Optional[str] = None
|
|
67
|
+
sub_title: Optional[str] = None
|
|
68
|
+
category: Optional[str] = None
|
|
69
|
+
|
|
70
|
+
# Event properties
|
|
71
|
+
mutually_exclusive: bool = False
|
|
72
|
+
collateral_return_type: Optional[str] = None
|
|
73
|
+
|
|
74
|
+
# Timing
|
|
75
|
+
strike_date: Optional[str] = None
|
|
76
|
+
strike_period: Optional[str] = None
|
|
77
|
+
|
|
78
|
+
# Availability
|
|
79
|
+
available_on_brokers: bool = False
|
|
80
|
+
|
|
81
|
+
model_config = ConfigDict(extra="ignore")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class OrderModel(BaseModel):
|
|
85
|
+
"""Pydantic model for Order data."""
|
|
86
|
+
|
|
87
|
+
order_id: str
|
|
88
|
+
ticker: str
|
|
89
|
+
status: OrderStatus
|
|
90
|
+
action: Optional[Action] = None
|
|
91
|
+
side: Optional[Side] = None
|
|
92
|
+
type: Optional[OrderType] = None
|
|
93
|
+
|
|
94
|
+
# Pricing
|
|
95
|
+
yes_price: Optional[int] = None
|
|
96
|
+
no_price: Optional[int] = None
|
|
97
|
+
|
|
98
|
+
# Counts
|
|
99
|
+
initial_count: Optional[int] = None
|
|
100
|
+
fill_count: Optional[int] = None
|
|
101
|
+
remaining_count: Optional[int] = None
|
|
102
|
+
|
|
103
|
+
# Fees & costs (in cents)
|
|
104
|
+
taker_fees: Optional[int] = None
|
|
105
|
+
maker_fees: Optional[int] = None
|
|
106
|
+
taker_fill_cost: Optional[int] = None
|
|
107
|
+
maker_fill_cost: Optional[int] = None
|
|
108
|
+
|
|
109
|
+
# Metadata
|
|
110
|
+
user_id: Optional[str] = None
|
|
111
|
+
client_order_id: Optional[str] = None
|
|
112
|
+
created_time: Optional[str] = None
|
|
113
|
+
last_update_time: Optional[str] = None
|
|
114
|
+
expiration_time: Optional[str] = None
|
|
115
|
+
|
|
116
|
+
model_config = ConfigDict(extra="ignore")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class BalanceModel(BaseModel):
|
|
120
|
+
"""Pydantic model for Balance data. Values are in cents."""
|
|
121
|
+
|
|
122
|
+
balance: int
|
|
123
|
+
portfolio_value: int
|
|
124
|
+
updated_ts: Optional[int] = None
|
|
125
|
+
|
|
126
|
+
model_config = ConfigDict(extra="ignore")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class PositionModel(BaseModel):
|
|
130
|
+
"""Pydantic model for a portfolio position."""
|
|
131
|
+
|
|
132
|
+
ticker: str
|
|
133
|
+
position: int # Net position (positive = yes, negative = no)
|
|
134
|
+
market_exposure: Optional[int] = None
|
|
135
|
+
total_traded: Optional[int] = None
|
|
136
|
+
resting_orders_count: Optional[int] = None
|
|
137
|
+
fees_paid: Optional[int] = None
|
|
138
|
+
realized_pnl: Optional[int] = None
|
|
139
|
+
last_updated_ts: Optional[str] = None
|
|
140
|
+
|
|
141
|
+
model_config = ConfigDict(extra="ignore")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class FillModel(BaseModel):
|
|
145
|
+
"""Pydantic model for a trade fill/execution."""
|
|
146
|
+
|
|
147
|
+
trade_id: str
|
|
148
|
+
ticker: str
|
|
149
|
+
order_id: str
|
|
150
|
+
side: Side
|
|
151
|
+
action: Action
|
|
152
|
+
count: int
|
|
153
|
+
yes_price: int
|
|
154
|
+
no_price: int
|
|
155
|
+
is_taker: Optional[bool] = None
|
|
156
|
+
fill_id: Optional[str] = None
|
|
157
|
+
market_ticker: Optional[str] = None
|
|
158
|
+
fee_cost: Optional[str] = None # Dollar amount string (e.g., "0.3200")
|
|
159
|
+
created_time: Optional[str] = None
|
|
160
|
+
ts: Optional[int] = None
|
|
161
|
+
|
|
162
|
+
model_config = ConfigDict(extra="ignore")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class OHLCData(BaseModel):
|
|
166
|
+
"""OHLC price data."""
|
|
167
|
+
|
|
168
|
+
open: Optional[int] = None
|
|
169
|
+
high: Optional[int] = None
|
|
170
|
+
low: Optional[int] = None
|
|
171
|
+
close: Optional[int] = None
|
|
172
|
+
open_dollars: Optional[str] = None
|
|
173
|
+
high_dollars: Optional[str] = None
|
|
174
|
+
low_dollars: Optional[str] = None
|
|
175
|
+
close_dollars: Optional[str] = None
|
|
176
|
+
|
|
177
|
+
model_config = ConfigDict(extra="ignore")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class PriceData(BaseModel):
|
|
181
|
+
"""Price data with additional fields."""
|
|
182
|
+
|
|
183
|
+
open: Optional[int] = None
|
|
184
|
+
high: Optional[int] = None
|
|
185
|
+
low: Optional[int] = None
|
|
186
|
+
close: Optional[int] = None
|
|
187
|
+
max: Optional[int] = None
|
|
188
|
+
min: Optional[int] = None
|
|
189
|
+
mean: Optional[int] = None
|
|
190
|
+
previous: Optional[int] = None
|
|
191
|
+
|
|
192
|
+
model_config = ConfigDict(extra="ignore")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class Candlestick(BaseModel):
|
|
196
|
+
"""Pydantic model for a single Candlestick."""
|
|
197
|
+
|
|
198
|
+
end_period_ts: int
|
|
199
|
+
volume: int
|
|
200
|
+
open_interest: int
|
|
201
|
+
price: PriceData
|
|
202
|
+
yes_bid: Optional[OHLCData] = None
|
|
203
|
+
yes_ask: Optional[OHLCData] = None
|
|
204
|
+
|
|
205
|
+
model_config = ConfigDict(extra="ignore")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class CandlestickResponse(BaseModel):
|
|
209
|
+
"""Pydantic model for Candlestick API response."""
|
|
210
|
+
|
|
211
|
+
candlesticks: list[Candlestick]
|
|
212
|
+
ticker: str
|
|
213
|
+
|
|
214
|
+
model_config = ConfigDict(extra="ignore")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# Orderbook Models
|
|
218
|
+
class OrderbookLevel(BaseModel):
|
|
219
|
+
"""A single price level in the orderbook (price, quantity)."""
|
|
220
|
+
|
|
221
|
+
price: int # Price in cents (1-99)
|
|
222
|
+
quantity: int # Number of contracts at this price level
|
|
223
|
+
|
|
224
|
+
model_config = ConfigDict(extra="ignore")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class Orderbook(BaseModel):
|
|
228
|
+
"""Orderbook with yes/no price levels."""
|
|
229
|
+
|
|
230
|
+
yes: Optional[list[tuple[int, int]]] = None # [(price, quantity), ...]
|
|
231
|
+
no: Optional[list[tuple[int, int]]] = None
|
|
232
|
+
yes_dollars: Optional[list[tuple[str, int]]] = None # [(price_str, quantity_int), ...]
|
|
233
|
+
no_dollars: Optional[list[tuple[str, int]]] = None
|
|
234
|
+
|
|
235
|
+
model_config = ConfigDict(extra="ignore")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class OrderbookFp(BaseModel):
|
|
239
|
+
"""Fixed-point orderbook data."""
|
|
240
|
+
|
|
241
|
+
yes_dollars: Optional[list[tuple[str, int]]] = None # [(price_str, quantity_int), ...]
|
|
242
|
+
no_dollars: Optional[list[tuple[str, int]]] = None
|
|
243
|
+
|
|
244
|
+
model_config = ConfigDict(extra="ignore")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class OrderbookResponse(BaseModel):
|
|
248
|
+
"""Pydantic model for the orderbook API response."""
|
|
249
|
+
|
|
250
|
+
orderbook: Orderbook
|
|
251
|
+
orderbook_fp: Optional[OrderbookFp] = None
|
|
252
|
+
|
|
253
|
+
model_config = ConfigDict(extra="ignore")
|
|
254
|
+
|
|
255
|
+
@cached_property
|
|
256
|
+
def yes_levels(self) -> list[OrderbookLevel]:
|
|
257
|
+
"""Get YES price levels as typed objects."""
|
|
258
|
+
if not self.orderbook.yes:
|
|
259
|
+
return []
|
|
260
|
+
return [OrderbookLevel(price=p[0], quantity=p[1]) for p in self.orderbook.yes]
|
|
261
|
+
|
|
262
|
+
@cached_property
|
|
263
|
+
def no_levels(self) -> list[OrderbookLevel]:
|
|
264
|
+
"""Get NO price levels as typed objects."""
|
|
265
|
+
if not self.orderbook.no:
|
|
266
|
+
return []
|
|
267
|
+
return [OrderbookLevel(price=p[0], quantity=p[1]) for p in self.orderbook.no]
|
|
268
|
+
|
|
269
|
+
@cached_property
|
|
270
|
+
def best_yes_bid(self) -> Optional[int]:
|
|
271
|
+
"""Highest YES bid price, or None if no bids."""
|
|
272
|
+
if not self.orderbook.yes:
|
|
273
|
+
return None
|
|
274
|
+
return max(p[0] for p in self.orderbook.yes)
|
|
275
|
+
|
|
276
|
+
@cached_property
|
|
277
|
+
def best_no_bid(self) -> Optional[int]:
|
|
278
|
+
"""Highest NO bid price, or None if no bids."""
|
|
279
|
+
if not self.orderbook.no:
|
|
280
|
+
return None
|
|
281
|
+
return max(p[0] for p in self.orderbook.no)
|
|
282
|
+
|
|
283
|
+
@cached_property
|
|
284
|
+
def best_yes_ask(self) -> Optional[int]:
|
|
285
|
+
"""Lowest YES ask (= 100 - best NO bid)."""
|
|
286
|
+
if self.best_no_bid is None:
|
|
287
|
+
return None
|
|
288
|
+
return 100 - self.best_no_bid
|
|
289
|
+
|
|
290
|
+
@cached_property
|
|
291
|
+
def spread(self) -> Optional[int]:
|
|
292
|
+
"""Bid-ask spread in cents. None if no two-sided market."""
|
|
293
|
+
if self.best_yes_bid is None or self.best_yes_ask is None:
|
|
294
|
+
return None
|
|
295
|
+
return self.best_yes_ask - self.best_yes_bid
|
|
296
|
+
|
|
297
|
+
@cached_property
|
|
298
|
+
def mid(self) -> Optional[float]:
|
|
299
|
+
"""Mid price. None if no two-sided market."""
|
|
300
|
+
if self.best_yes_bid is None or self.best_yes_ask is None:
|
|
301
|
+
return None
|
|
302
|
+
return (self.best_yes_bid + self.best_yes_ask) / 2
|
|
303
|
+
|
|
304
|
+
@cached_property
|
|
305
|
+
def spread_bps(self) -> Optional[float]:
|
|
306
|
+
"""Spread as basis points of mid. None if no two-sided market."""
|
|
307
|
+
if self.spread is None or self.mid is None or self.mid == 0:
|
|
308
|
+
return None
|
|
309
|
+
return (self.spread / self.mid) * 10000
|
|
310
|
+
|
|
311
|
+
def yes_depth(self, through_price: int) -> int:
|
|
312
|
+
"""Total YES bid quantity at or above `through_price`."""
|
|
313
|
+
if not self.orderbook.yes:
|
|
314
|
+
return 0
|
|
315
|
+
return sum(q for p, q in self.orderbook.yes if p >= through_price)
|
|
316
|
+
|
|
317
|
+
def no_depth(self, through_price: int) -> int:
|
|
318
|
+
"""Total NO bid quantity at or above `through_price`."""
|
|
319
|
+
if not self.orderbook.no:
|
|
320
|
+
return 0
|
|
321
|
+
return sum(q for p, q in self.orderbook.no if p >= through_price)
|
|
322
|
+
|
|
323
|
+
@cached_property
|
|
324
|
+
def imbalance(self) -> Optional[float]:
|
|
325
|
+
"""Order imbalance: (yes_depth - no_depth) / (yes_depth + no_depth). Range [-1, 1]."""
|
|
326
|
+
yes_total = sum(q for _, q in self.orderbook.yes) if self.orderbook.yes else 0
|
|
327
|
+
no_total = sum(q for _, q in self.orderbook.no) if self.orderbook.no else 0
|
|
328
|
+
total = yes_total + no_total
|
|
329
|
+
if total == 0:
|
|
330
|
+
return None
|
|
331
|
+
return (yes_total - no_total) / total
|
|
332
|
+
|
|
333
|
+
def vwap_to_fill(self, side: str, size: int) -> Optional[float]:
|
|
334
|
+
"""Volume-weighted average price to fill `size` contracts.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
side: "yes" or "no" - the side you're buying
|
|
338
|
+
size: Number of contracts to fill
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
VWAP in cents, or None if insufficient liquidity.
|
|
342
|
+
"""
|
|
343
|
+
# To buy YES, you lift NO offers (sorted by price descending = best first)
|
|
344
|
+
# To buy NO, you lift YES offers (sorted by price descending = best first)
|
|
345
|
+
levels = self.orderbook.no if side == "yes" else self.orderbook.yes
|
|
346
|
+
if not levels:
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
# Sort by price descending (best offer = highest price for the other side)
|
|
350
|
+
sorted_levels = sorted(levels, key=lambda x: x[0], reverse=True)
|
|
351
|
+
|
|
352
|
+
remaining = size
|
|
353
|
+
cost = 0
|
|
354
|
+
for price, qty in sorted_levels:
|
|
355
|
+
take = min(remaining, qty)
|
|
356
|
+
# If buying YES, you pay (100 - no_price) per contract
|
|
357
|
+
# If buying NO, you pay (100 - yes_price) per contract
|
|
358
|
+
fill_price = 100 - price
|
|
359
|
+
cost += take * fill_price
|
|
360
|
+
remaining -= take
|
|
361
|
+
if remaining <= 0:
|
|
362
|
+
break
|
|
363
|
+
|
|
364
|
+
if remaining > 0:
|
|
365
|
+
return None # Insufficient liquidity
|
|
366
|
+
return cost / size
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
# --- Exchange Models ---
|
|
370
|
+
|
|
371
|
+
class ExchangeStatus(BaseModel):
|
|
372
|
+
"""Exchange operational status."""
|
|
373
|
+
exchange_active: bool
|
|
374
|
+
trading_active: bool
|
|
375
|
+
|
|
376
|
+
model_config = ConfigDict(extra="ignore")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class Announcement(BaseModel):
|
|
380
|
+
"""Exchange announcement."""
|
|
381
|
+
id: Optional[str] = None
|
|
382
|
+
title: str
|
|
383
|
+
body: Optional[str] = None
|
|
384
|
+
type: Optional[str] = None
|
|
385
|
+
created_time: Optional[str] = None
|
|
386
|
+
delivery_time: Optional[str] = None
|
|
387
|
+
status: Optional[str] = None
|
|
388
|
+
|
|
389
|
+
model_config = ConfigDict(extra="ignore")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# --- Account Models ---
|
|
393
|
+
|
|
394
|
+
class RateLimitTier(BaseModel):
|
|
395
|
+
"""Rate limit for a specific tier."""
|
|
396
|
+
max_requests: int
|
|
397
|
+
period_seconds: int
|
|
398
|
+
|
|
399
|
+
model_config = ConfigDict(extra="ignore")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
class APILimits(BaseModel):
|
|
403
|
+
"""API rate limits for the authenticated user."""
|
|
404
|
+
tier: Optional[str] = None
|
|
405
|
+
limits: Optional[dict[str, RateLimitTier]] = None
|
|
406
|
+
remaining: Optional[int] = None
|
|
407
|
+
reset_at: Optional[int] = None
|
|
408
|
+
|
|
409
|
+
model_config = ConfigDict(extra="ignore")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# --- API Key Models ---
|
|
413
|
+
|
|
414
|
+
class APIKey(BaseModel):
|
|
415
|
+
"""API key information."""
|
|
416
|
+
id: str
|
|
417
|
+
name: Optional[str] = None
|
|
418
|
+
created_time: Optional[str] = None
|
|
419
|
+
last_used: Optional[str] = None
|
|
420
|
+
scopes: Optional[list[str]] = None
|
|
421
|
+
|
|
422
|
+
model_config = ConfigDict(extra="ignore")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
class GeneratedAPIKey(BaseModel):
|
|
426
|
+
"""Newly generated API key with private key (only returned once)."""
|
|
427
|
+
id: str
|
|
428
|
+
private_key: str
|
|
429
|
+
name: Optional[str] = None
|
|
430
|
+
|
|
431
|
+
model_config = ConfigDict(extra="ignore")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
# --- Series & Trade Models ---
|
|
435
|
+
|
|
436
|
+
class SeriesModel(BaseModel):
|
|
437
|
+
"""Pydantic model for Series data."""
|
|
438
|
+
ticker: str
|
|
439
|
+
title: Optional[str] = None
|
|
440
|
+
category: Optional[str] = None
|
|
441
|
+
tags: Optional[list[str]] = None
|
|
442
|
+
settlement_timer_seconds: Optional[int] = None
|
|
443
|
+
frequency: Optional[str] = None
|
|
444
|
+
|
|
445
|
+
model_config = ConfigDict(extra="ignore")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class TradeModel(BaseModel):
|
|
449
|
+
"""Public trade execution record."""
|
|
450
|
+
trade_id: str
|
|
451
|
+
ticker: str
|
|
452
|
+
count: int
|
|
453
|
+
yes_price: int
|
|
454
|
+
no_price: int
|
|
455
|
+
taker_side: Optional[str] = None
|
|
456
|
+
created_time: Optional[str] = None
|
|
457
|
+
ts: Optional[int] = None
|
|
458
|
+
|
|
459
|
+
model_config = ConfigDict(extra="ignore")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class SettlementModel(BaseModel):
|
|
463
|
+
"""Settlement record for a resolved position."""
|
|
464
|
+
ticker: str
|
|
465
|
+
event_ticker: Optional[str] = None
|
|
466
|
+
market_result: Optional[str] = None # "yes" or "no"
|
|
467
|
+
yes_count: int = 0
|
|
468
|
+
no_count: int = 0
|
|
469
|
+
yes_total_cost: int = 0
|
|
470
|
+
no_total_cost: int = 0
|
|
471
|
+
revenue: int = 0 # Payout in cents
|
|
472
|
+
value: int = 0
|
|
473
|
+
fee_cost: Optional[str] = None # Dollar string like "0.3200"
|
|
474
|
+
settled_time: Optional[str] = None
|
|
475
|
+
|
|
476
|
+
model_config = ConfigDict(extra="ignore")
|
|
477
|
+
|
|
478
|
+
@property
|
|
479
|
+
def net_position(self) -> int:
|
|
480
|
+
"""Net position: positive = yes, negative = no."""
|
|
481
|
+
return self.yes_count - self.no_count
|
|
482
|
+
|
|
483
|
+
@property
|
|
484
|
+
def pnl(self) -> int:
|
|
485
|
+
"""Net P&L in cents (revenue - costs - fees)."""
|
|
486
|
+
fee_cents = int(float(self.fee_cost or 0) * 100)
|
|
487
|
+
return self.revenue - self.yes_total_cost - self.no_total_cost - fee_cents
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class QueuePositionModel(BaseModel):
|
|
491
|
+
"""Order's position in the queue at its price level."""
|
|
492
|
+
order_id: str
|
|
493
|
+
queue_position: int # 0-indexed position in queue
|
|
494
|
+
|
|
495
|
+
model_config = ConfigDict(extra="ignore")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class OrderGroupModel(BaseModel):
|
|
499
|
+
"""Order group for linked order strategies (OCO, bracket)."""
|
|
500
|
+
order_group_id: str
|
|
501
|
+
status: Optional[str] = None # "active", "triggered", "canceled"
|
|
502
|
+
orders: Optional[list[str]] = None # Order IDs in group
|
|
503
|
+
created_time: Optional[str] = None
|
|
504
|
+
|
|
505
|
+
model_config = ConfigDict(extra="ignore")
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# --- Subaccount Models ---
|
|
509
|
+
|
|
510
|
+
class SubaccountModel(BaseModel):
|
|
511
|
+
"""Subaccount info."""
|
|
512
|
+
subaccount_id: str
|
|
513
|
+
subaccount_number: int
|
|
514
|
+
created_time: Optional[str] = None
|
|
515
|
+
|
|
516
|
+
model_config = ConfigDict(extra="ignore")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
class SubaccountBalanceModel(BaseModel):
|
|
520
|
+
"""Balance for a single subaccount."""
|
|
521
|
+
subaccount_id: str
|
|
522
|
+
balance: int # In cents
|
|
523
|
+
portfolio_value: Optional[int] = None
|
|
524
|
+
|
|
525
|
+
model_config = ConfigDict(extra="ignore")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class SubaccountTransferModel(BaseModel):
|
|
529
|
+
"""Record of a transfer between subaccounts."""
|
|
530
|
+
transfer_id: str
|
|
531
|
+
from_subaccount_id: str
|
|
532
|
+
to_subaccount_id: str
|
|
533
|
+
amount: int # In cents
|
|
534
|
+
created_time: Optional[str] = None
|
|
535
|
+
|
|
536
|
+
model_config = ConfigDict(extra="ignore")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
class ForecastPoint(BaseModel):
|
|
540
|
+
"""A single point in forecast percentile history."""
|
|
541
|
+
ts: int # Unix timestamp
|
|
542
|
+
value: int # Forecast value in cents
|
|
543
|
+
|
|
544
|
+
model_config = ConfigDict(extra="ignore")
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
class ForecastPercentileHistory(BaseModel):
|
|
548
|
+
"""Historical forecast data at various percentiles for an event."""
|
|
549
|
+
event_ticker: str
|
|
550
|
+
percentiles: dict[str, list[ForecastPoint]] # Maps percentile (e.g., "50") to history
|
|
551
|
+
|
|
552
|
+
model_config = ConfigDict(extra="ignore")
|
pykalshi/orderbook.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Orderbook management utilities for maintaining local state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class OrderbookManager:
|
|
10
|
+
"""Maintains local orderbook state from WebSocket updates.
|
|
11
|
+
|
|
12
|
+
Usage with Feed:
|
|
13
|
+
feed = client.feed()
|
|
14
|
+
books = {} # ticker -> OrderbookManager
|
|
15
|
+
|
|
16
|
+
@feed.on("orderbook_delta")
|
|
17
|
+
def handle_book(msg):
|
|
18
|
+
ticker = msg.market_ticker
|
|
19
|
+
if ticker not in books:
|
|
20
|
+
books[ticker] = OrderbookManager(ticker)
|
|
21
|
+
|
|
22
|
+
if hasattr(msg, 'yes'): # Snapshot
|
|
23
|
+
books[ticker].apply_snapshot(msg.yes, msg.no)
|
|
24
|
+
else: # Delta
|
|
25
|
+
books[ticker].apply_delta(msg.side, msg.price, msg.delta)
|
|
26
|
+
|
|
27
|
+
print(f"{ticker}: {books[ticker].spread}c spread")
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
ticker: str
|
|
31
|
+
yes: dict[int, int] = field(default_factory=dict) # price -> quantity
|
|
32
|
+
no: dict[int, int] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
def apply_snapshot(
|
|
35
|
+
self,
|
|
36
|
+
yes_levels: list[tuple[int, int]] | None,
|
|
37
|
+
no_levels: list[tuple[int, int]] | None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Reset book from snapshot message."""
|
|
40
|
+
self.yes = {p: q for p, q in (yes_levels or [])}
|
|
41
|
+
self.no = {p: q for p, q in (no_levels or [])}
|
|
42
|
+
|
|
43
|
+
def apply_delta(self, side: str, price: int, delta: int) -> None:
|
|
44
|
+
"""Apply incremental update. Removes level if quantity hits zero."""
|
|
45
|
+
book = self.yes if side == "yes" else self.no
|
|
46
|
+
new_qty = book.get(price, 0) + delta
|
|
47
|
+
if new_qty <= 0:
|
|
48
|
+
book.pop(price, None)
|
|
49
|
+
else:
|
|
50
|
+
book[price] = new_qty
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def best_bid(self) -> Optional[int]:
|
|
54
|
+
"""Best YES bid price."""
|
|
55
|
+
return max(self.yes.keys()) if self.yes else None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def best_ask(self) -> Optional[int]:
|
|
59
|
+
"""Best YES ask (= 100 - best NO bid)."""
|
|
60
|
+
if not self.no:
|
|
61
|
+
return None
|
|
62
|
+
return 100 - max(self.no.keys())
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def mid(self) -> Optional[float]:
|
|
66
|
+
"""Mid price."""
|
|
67
|
+
if self.best_bid is None or self.best_ask is None:
|
|
68
|
+
return None
|
|
69
|
+
return (self.best_bid + self.best_ask) / 2
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def spread(self) -> Optional[int]:
|
|
73
|
+
"""Bid-ask spread in cents."""
|
|
74
|
+
if self.best_bid is None or self.best_ask is None:
|
|
75
|
+
return None
|
|
76
|
+
return self.best_ask - self.best_bid
|
|
77
|
+
|
|
78
|
+
def bid_depth(self, levels: int = 5) -> int:
|
|
79
|
+
"""Total quantity in top N bid levels."""
|
|
80
|
+
if not self.yes:
|
|
81
|
+
return 0
|
|
82
|
+
sorted_prices = sorted(self.yes.keys(), reverse=True)[:levels]
|
|
83
|
+
return sum(self.yes[p] for p in sorted_prices)
|
|
84
|
+
|
|
85
|
+
def ask_depth(self, levels: int = 5) -> int:
|
|
86
|
+
"""Total quantity in top N ask levels."""
|
|
87
|
+
if not self.no:
|
|
88
|
+
return 0
|
|
89
|
+
sorted_prices = sorted(self.no.keys(), reverse=True)[:levels]
|
|
90
|
+
return sum(self.no[p] for p in sorted_prices)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def imbalance(self) -> Optional[float]:
|
|
94
|
+
"""Order imbalance [-1, 1]. Positive = more bids."""
|
|
95
|
+
bid_total = sum(self.yes.values()) if self.yes else 0
|
|
96
|
+
ask_total = sum(self.no.values()) if self.no else 0
|
|
97
|
+
total = bid_total + ask_total
|
|
98
|
+
if total == 0:
|
|
99
|
+
return None
|
|
100
|
+
return (bid_total - ask_total) / total
|
|
101
|
+
|
|
102
|
+
def cost_to_buy(self, size: int) -> Optional[tuple[int, float]]:
|
|
103
|
+
"""Calculate cost to buy `size` YES contracts.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Tuple of (total_cost, avg_price) or None if insufficient liquidity.
|
|
107
|
+
"""
|
|
108
|
+
if not self.no:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
remaining = size
|
|
112
|
+
cost = 0
|
|
113
|
+
for no_price in sorted(self.no.keys(), reverse=True):
|
|
114
|
+
qty = self.no[no_price]
|
|
115
|
+
take = min(remaining, qty)
|
|
116
|
+
yes_price = 100 - no_price
|
|
117
|
+
cost += take * yes_price
|
|
118
|
+
remaining -= take
|
|
119
|
+
if remaining <= 0:
|
|
120
|
+
return (cost, cost / size)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
def cost_to_sell(self, size: int) -> Optional[tuple[int, float]]:
|
|
124
|
+
"""Calculate proceeds from selling `size` YES contracts.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Tuple of (total_proceeds, avg_price) or None if insufficient liquidity.
|
|
128
|
+
"""
|
|
129
|
+
if not self.yes:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
remaining = size
|
|
133
|
+
proceeds = 0
|
|
134
|
+
for price in sorted(self.yes.keys(), reverse=True):
|
|
135
|
+
qty = self.yes[price]
|
|
136
|
+
take = min(remaining, qty)
|
|
137
|
+
proceeds += take * price
|
|
138
|
+
remaining -= take
|
|
139
|
+
if remaining <= 0:
|
|
140
|
+
return (proceeds, proceeds / size)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
def __repr__(self) -> str:
|
|
144
|
+
bid = self.best_bid or "—"
|
|
145
|
+
ask = self.best_ask or "—"
|
|
146
|
+
return f"<Orderbook {self.ticker} {bid}/{ask}>"
|