pykalshi 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.
kalshi_api/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")
@@ -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}>"