synpath 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. synpath/__init__.py +183 -0
  2. synpath/__main__.py +66 -0
  3. synpath/base.py +723 -0
  4. synpath/bucket.py +154 -0
  5. synpath/client.py +356 -0
  6. synpath/engine/__init__.py +37 -0
  7. synpath/engine/__main__.py +354 -0
  8. synpath/engine/alerts.py +170 -0
  9. synpath/engine/engine.py +888 -0
  10. synpath/engine/eod.py +154 -0
  11. synpath/engine/events.py +140 -0
  12. synpath/engine/fair_values.py +117 -0
  13. synpath/engine/feeds.py +220 -0
  14. synpath/engine/journal.py +907 -0
  15. synpath/engine/ledger.py +353 -0
  16. synpath/engine/orders/__init__.py +42 -0
  17. synpath/engine/orders/base.py +441 -0
  18. synpath/engine/orders/day.py +72 -0
  19. synpath/engine/orders/iceberg.py +121 -0
  20. synpath/engine/orders/manager.py +223 -0
  21. synpath/engine/orders/oco.py +255 -0
  22. synpath/engine/orders/peg.py +168 -0
  23. synpath/engine/orders/routed.py +496 -0
  24. synpath/engine/orders/stop.py +240 -0
  25. synpath/engine/orders/taker.py +187 -0
  26. synpath/engine/orders/twap.py +190 -0
  27. synpath/engine/paper.py +532 -0
  28. synpath/engine/reconcile.py +279 -0
  29. synpath/engine/risk.py +403 -0
  30. synpath/engine/router.py +261 -0
  31. synpath/errors.py +98 -0
  32. synpath/history.py +71 -0
  33. synpath/hosted.py +86 -0
  34. synpath/hosted_auth.py +201 -0
  35. synpath/ids.py +61 -0
  36. synpath/kalshi.py +1378 -0
  37. synpath/matching.py +86 -0
  38. synpath/polymarket.py +1004 -0
  39. synpath/polymarket_us.py +989 -0
  40. synpath/remote.py +195 -0
  41. synpath/server/__init__.py +98 -0
  42. synpath/server/__main__.py +118 -0
  43. synpath/server/api.py +439 -0
  44. synpath/server/errors.py +87 -0
  45. synpath/server/local.py +96 -0
  46. synpath/server/models.py +75 -0
  47. synpath/server/serve.py +236 -0
  48. synpath/server/store.py +363 -0
  49. synpath/server/trading.py +764 -0
  50. synpath/trading/__init__.py +79 -0
  51. synpath/trading/__main__.py +69 -0
  52. synpath/trading/base.py +126 -0
  53. synpath/trading/credentials.py +400 -0
  54. synpath/trading/errors.py +94 -0
  55. synpath/trading/init.py +233 -0
  56. synpath/trading/instruments.py +162 -0
  57. synpath/trading/kalshi.py +957 -0
  58. synpath/trading/limiter.py +177 -0
  59. synpath/trading/money.py +172 -0
  60. synpath/trading/polymarket.py +1362 -0
  61. synpath/trading/polymarket_signing.py +478 -0
  62. synpath/trading/polymarket_us.py +705 -0
  63. synpath/trading/polymarket_us_exchange.py +825 -0
  64. synpath/trading/types.py +414 -0
  65. synpath/types.py +608 -0
  66. synpath/ws/__init__.py +55 -0
  67. synpath/ws/base.py +544 -0
  68. synpath/ws/grpc.py +578 -0
  69. synpath/ws/kalshi.py +418 -0
  70. synpath/ws/polymarket.py +430 -0
  71. synpath/ws/polymarket_us.py +299 -0
  72. synpath/ws/polymarket_us_exchange.py +754 -0
  73. synpath-0.1.0.dist-info/METADATA +224 -0
  74. synpath-0.1.0.dist-info/RECORD +77 -0
  75. synpath-0.1.0.dist-info/WHEEL +4 -0
  76. synpath-0.1.0.dist-info/entry_points.txt +2 -0
  77. synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,532 @@
1
+ """Paper trading: a venue that fills orders the way a real book would.
2
+
3
+ A backtest that fills every order at the touch teaches a strategy to be
4
+ wrong. This one is deliberately pessimistic in the two places that matter:
5
+
6
+ **Taking costs the spread and walks the book.** A marketable order eats
7
+ levels in order and pays the average of what it ate, not the touch. If the
8
+ book is thinner than the order, the rest rests or is cancelled, exactly as
9
+ the venue's time-in-force says.
10
+
11
+ **Resting means queuing.** An order joining a price level is behind
12
+ everything already there. It fills only after the trades printed at that
13
+ price have consumed the size ahead of it, and if the level trades away
14
+ without reaching it, it does not fill. Size added at the same price later
15
+ sits behind, and size cancelled ahead moves it up only when the venue tells
16
+ us so, which is why the queue is an estimate and says so.
17
+
18
+ Fees come from the venue's own schedule, passed in as a callable, so a paper
19
+ run's profit and loss uses the same formula the live venue would charge.
20
+
21
+ It implements `TradingExchange`, so the engine, the risk rules and the
22
+ journal cannot tell it from a real adapter: the same code path runs in paper
23
+ and in production, which is the only way a paper run proves anything.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import time
28
+ import uuid
29
+ from dataclasses import dataclass, field
30
+ from decimal import Decimal
31
+ from typing import Any, Callable, Iterable, Sequence
32
+
33
+ from ..base import Capability
34
+ from ..trading.base import TradingExchange
35
+ from ..trading.errors import InvalidOrder, OrderNotFound
36
+ from ..trading.types import (
37
+ Account, Balance, EditRequest, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide,
38
+ Side, TimeInForce,
39
+ )
40
+
41
+ ZERO = Decimal("0")
42
+ ONE = Decimal("1")
43
+
44
+ Level = tuple[Decimal, Decimal]
45
+ FeeModel = Callable[[str, Decimal, Decimal, Liquidity], Decimal]
46
+
47
+
48
+ def no_fees(market_id: str, price: Decimal, amount: Decimal, liquidity: Liquidity) -> Decimal:
49
+ return ZERO
50
+
51
+
52
+ def quadratic_fee(rate: Decimal = Decimal("0.07"), *, exponent: Decimal = ONE, maker_rate: Decimal | None = ZERO) -> FeeModel:
53
+ """The prediction-market fee shape: `rate * contracts * (p(1-p))^exponent`.
54
+
55
+ Kalshi charges it on takers at 0.07 and rounds up to the cent; Polymarket
56
+ uses the same shape with its own rate and exponent. `maker_rate` of zero
57
+ is both venues today.
58
+ """
59
+ def fee(market_id: str, price: Decimal, amount: Decimal, liquidity: Liquidity) -> Decimal:
60
+ applied = rate if liquidity != Liquidity.MAKER else (maker_rate if maker_rate is not None else rate)
61
+ if not applied:
62
+ return ZERO
63
+ edge = price * (ONE - price)
64
+ if exponent != ONE:
65
+ edge = Decimal(str(float(edge) ** float(exponent)))
66
+ raw = applied * amount * edge
67
+ cents = (raw * 100).to_integral_value(rounding="ROUND_CEILING")
68
+ return cents / 100
69
+ return fee
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class PriceLevel:
74
+ """One level, shaped like the streaming layer's, so code that reads a
75
+ live book reads a simulated one without noticing."""
76
+
77
+ price: Decimal
78
+ size: Decimal
79
+
80
+
81
+ @dataclass(slots=True)
82
+ class BookState:
83
+ """One instrument's book, as the simulator sees it."""
84
+
85
+ bids: list[Level] = field(default_factory=list)
86
+ asks: list[Level] = field(default_factory=list)
87
+
88
+ def levels(self, depth: int | None = None) -> tuple[tuple[PriceLevel, ...], tuple[PriceLevel, ...]]:
89
+ """Bids best first, asks best first, as `synpath.ws.LocalBook` gives them."""
90
+ bids = [PriceLevel(p, s) for p, s in self.bids[: depth or len(self.bids)]]
91
+ asks = [PriceLevel(p, s) for p, s in self.asks[: depth or len(self.asks)]]
92
+ return tuple(bids), tuple(asks)
93
+
94
+ @property
95
+ def ready(self) -> bool:
96
+ return bool(self.bids or self.asks)
97
+
98
+ @property
99
+ def best_bid(self) -> Decimal | None:
100
+ return self.bids[0][0] if self.bids else None
101
+
102
+ @property
103
+ def best_ask(self) -> Decimal | None:
104
+ return self.asks[0][0] if self.asks else None
105
+
106
+ def size_at(self, side: Side, price: Decimal) -> Decimal:
107
+ levels = self.bids if side == Side.BUY else self.asks
108
+ for level_price, size in levels:
109
+ if level_price == price:
110
+ return size
111
+ return ZERO
112
+
113
+
114
+ @dataclass(slots=True)
115
+ class Resting:
116
+ """A resting paper order and where it stands in the queue."""
117
+
118
+ order: Order
119
+ queue_ahead: Decimal
120
+ """Contracts that must trade at this price before this order fills."""
121
+ request: OrderRequest
122
+
123
+
124
+ class PaperVenue(TradingExchange):
125
+ """A venue that exists only in this process.
126
+
127
+ ```python
128
+ paper = PaperVenue(venue="kalshi", fees=quadratic_fee())
129
+ paper.set_book("KXX:yes", bids=[(D("0.41"), D("500"))], asks=[(D("0.43"), D("300"))])
130
+ order = await paper.create_order(request) # rests behind 500 contracts
131
+ paper.on_trade("KXX:yes", price=D("0.41"), amount=D("520")) # 20 of them are ours
132
+ ```
133
+
134
+ Feed it books and trades from `synpath.ws` and a strategy runs against
135
+ the real tape without sending anything.
136
+ """
137
+
138
+ id = "paper"
139
+ name = "Paper trading"
140
+ has: dict[str, Capability] = {
141
+ "create_order": True, "create_orders": True, "cancel_order": True, "cancel_orders": True,
142
+ "cancel_all_orders": True, "edit_order": True, "fetch_order": True, "fetch_open_orders": True,
143
+ "fetch_orders": True, "fetch_my_trades": True, "fetch_positions": True, "fetch_balance": True,
144
+ "fetch_settlements": False, "fetch_queue_position": True, "fetch_fee_estimate": True,
145
+ "rfq": False, "split_merge": False,
146
+ "watch_orders": False, "watch_my_trades": False, "watch_positions": False, "watch_balance": False,
147
+ }
148
+
149
+ def __init__(
150
+ self,
151
+ *,
152
+ venue: str = "paper",
153
+ cash: Decimal = Decimal("10000"),
154
+ fees: FeeModel = no_fees,
155
+ account: Account | None = None,
156
+ clock: Callable[[], float] = time.time,
157
+ face_value: Decimal = ONE,
158
+ ):
159
+ self.venue = venue
160
+ self.account = account or Account(venue=venue, name="paper")
161
+ self.fees = fees
162
+ self.clock = clock
163
+ self.face_value = face_value
164
+ self.cash = cash
165
+ self.start_cash = cash
166
+ self.books: dict[str, BookState] = {}
167
+ self.resting: dict[str, Resting] = {}
168
+ self.orders: dict[str, Order] = {}
169
+ self.fills: list[Fill] = []
170
+ self.positions_held: dict[str, Decimal] = {}
171
+ self.locked = ZERO
172
+ self._n = 0
173
+ self.pending: list[Fill] = []
174
+ """Fills made but not yet delivered. A real venue reports a fill on a
175
+ stream, after the call that caused it has returned, and code that
176
+ reacts to fills must work that way here too."""
177
+ self.listeners: list[Callable[[Fill], Any]] = []
178
+ self.order_listeners: list[Callable[[Order], Any]] = []
179
+ self.pending_orders: list[Order] = []
180
+
181
+ def subscribe(self, listener: Callable[[Fill], Any]) -> Callable[[Fill], Any]:
182
+ """Receive fills as they are delivered, as a user stream would send them."""
183
+ self.listeners.append(listener)
184
+ return listener
185
+
186
+ def subscribe_orders(self, listener: Callable[[Order], Any]) -> Callable[[Order], Any]:
187
+ """Receive order updates, as a user stream would send them."""
188
+ self.order_listeners.append(listener)
189
+ return listener
190
+
191
+ async def deliver(self) -> list[Fill]:
192
+ """Hand over everything that has happened since the last call: fills
193
+ first, then the order updates they caused, which is the order a venue's
194
+ own stream reports them in."""
195
+ made, self.pending = self.pending, []
196
+ for fill in made:
197
+ for listener in list(self.listeners):
198
+ result = listener(fill)
199
+ if hasattr(result, "__await__"):
200
+ await result
201
+ updates, self.pending_orders = self.pending_orders, []
202
+ for order in updates:
203
+ latest = self.orders.get(order.id, order)
204
+ for listener in list(self.order_listeners):
205
+ result = listener(latest)
206
+ if hasattr(result, "__await__"):
207
+ await result
208
+ return made
209
+
210
+ # -- feeding it -----------------------------------------------------------
211
+
212
+ def set_book(self, market_id: str, *, bids: Sequence[Level] = (), asks: Sequence[Level] = ()) -> None:
213
+ self.books[market_id] = BookState(
214
+ bids=sorted(((Decimal(p), Decimal(s)) for p, s in bids), key=lambda l: -l[0]),
215
+ asks=sorted(((Decimal(p), Decimal(s)) for p, s in asks), key=lambda l: l[0]),
216
+ )
217
+
218
+ def from_local_book(self, market_id: str, book: Any, depth: int | None = None) -> None:
219
+ """Take a `synpath.ws` `LocalBook` as it stands."""
220
+ bids, asks = book.levels(depth)
221
+ self.set_book(market_id, bids=[(l.price, l.size) for l in bids], asks=[(l.price, l.size) for l in asks])
222
+
223
+ def on_trade(self, market_id: str, *, price: Decimal, amount: Decimal, taker_side: Side | None = None) -> list[Fill]:
224
+ """A print on the tape. Consumes queue ahead and fills what it reaches."""
225
+ made: list[Fill] = []
226
+ for order_id, rest in list(self.resting.items()):
227
+ order = rest.order
228
+ if order.market_id != market_id or order.price != price:
229
+ continue
230
+ if taker_side is not None and taker_side == order.side:
231
+ # A taker on our own side lifts the other side of the book.
232
+ continue
233
+ consumed = min(rest.queue_ahead, amount)
234
+ rest.queue_ahead -= consumed
235
+ left = amount - consumed
236
+ if left <= 0:
237
+ continue
238
+ fillable = min(left, order.remaining or ZERO)
239
+ if fillable > 0:
240
+ made.append(self._fill(rest, price=price, amount=fillable, liquidity=Liquidity.MAKER))
241
+ amount = left - fillable
242
+ if amount <= 0:
243
+ break
244
+ return made
245
+
246
+ # -- the venue interface --------------------------------------------------
247
+
248
+ async def create_order(self, request: OrderRequest) -> Order:
249
+ if request.type not in (OrderType.LIMIT, OrderType.MARKET):
250
+ raise InvalidOrder(f"paper trading holds {request.type.value} orders in the engine, not the venue")
251
+ book = self.books.get(request.market_id, BookState())
252
+ self._n += 1
253
+ order_id = f"paper-{self._n}"
254
+ price = request.price
255
+ order = Order(
256
+ id=order_id, client_order_id=request.client_order_id, venue=self.venue, account=request.account or self.account,
257
+ market_id=request.market_id, side=request.side,
258
+ type=request.type, time_in_force=request.time_in_force, status=OrderStatus.OPEN, price=price,
259
+ amount=request.amount, filled=ZERO, remaining=request.amount, book=request.book, trader=request.trader,
260
+ created_at=int(self.clock() * 1000), tags=dict(request.tags), expires_at=request.expires_at,
261
+ post_only=request.post_only, reduce_only=request.reduce_only,
262
+ )
263
+ self.orders[order_id] = order
264
+ rest = Resting(order=order, queue_ahead=book.size_at(request.side, price) if price is not None else ZERO, request=request)
265
+ self.resting[order_id] = rest
266
+
267
+ if not request.post_only:
268
+ self._cross(rest, book)
269
+ order = self.orders[order_id]
270
+ if order.remaining and order.remaining > 0:
271
+ if request.time_in_force in (TimeInForce.IOC, TimeInForce.FOK):
272
+ if request.time_in_force == TimeInForce.FOK and order.filled > 0 and order.remaining > 0:
273
+ # Fill or kill: what filled should not have. Undo it.
274
+ self._unfill(order_id)
275
+ self._close(order_id, OrderStatus.CANCELED)
276
+ elif request.post_only and self._would_cross(order, book):
277
+ self._close(order_id, OrderStatus.CANCELED)
278
+ return self.orders[order_id]
279
+
280
+ async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]:
281
+ out: list[Order | Exception] = []
282
+ for request in requests:
283
+ try:
284
+ out.append(await self.create_order(request))
285
+ except Exception as exc:
286
+ out.append(exc)
287
+ return out
288
+
289
+ async def cancel_order(self, order_id: str, *, market_id: str | None = None, current: Order | None = None) -> Order:
290
+ if order_id not in self.orders:
291
+ raise OrderNotFound(f"paper: no order {order_id}")
292
+ if self.orders[order_id].is_terminal:
293
+ return self.orders[order_id]
294
+ return self._close(order_id, OrderStatus.CANCELED)
295
+
296
+ async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]:
297
+ out: list[Order | Exception] = []
298
+ for order_id in order_ids:
299
+ try:
300
+ out.append(await self.cancel_order(order_id))
301
+ except Exception as exc:
302
+ out.append(exc)
303
+ return out
304
+
305
+ async def cancel_all_orders(self, *, market_id: str | None = None) -> int:
306
+ count = 0
307
+ for order_id, rest in list(self.resting.items()):
308
+ if market_id and rest.order.market_id != market_id:
309
+ continue
310
+ self._close(order_id, OrderStatus.CANCELED)
311
+ count += 1
312
+ return count
313
+
314
+ async def edit_order(self, request: EditRequest, *, current: Order | None = None) -> Order:
315
+ rest = self.resting.get(request.order_id)
316
+ if rest is None:
317
+ raise OrderNotFound(f"paper: no resting order {request.order_id}")
318
+ order = rest.order
319
+ new_price = request.price if request.price is not None else order.price
320
+ new_amount = request.amount if request.amount is not None else order.amount
321
+ shrinking = new_price == order.price and new_amount < order.amount
322
+ updated = order.model_copy(update={
323
+ "price": new_price, "amount": new_amount,
324
+ "remaining": max(ZERO, new_amount - order.filled),
325
+ "queue_priority_preserved": shrinking,
326
+ })
327
+ self.orders[order.id] = updated
328
+ rest.order = updated
329
+ if not shrinking:
330
+ # A price change goes to the back of the new level's queue.
331
+ book = self.books.get(order.market_id, BookState())
332
+ rest.queue_ahead = book.size_at(order.side, new_price) if new_price is not None else ZERO
333
+ return updated
334
+
335
+ async def fetch_order(self, order_id: str) -> Order:
336
+ if order_id not in self.orders:
337
+ raise OrderNotFound(f"paper: no order {order_id}")
338
+ return self.orders[order_id]
339
+
340
+ async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]:
341
+ return [r.order for r in self.resting.values() if not market_id or r.order.market_id == market_id]
342
+
343
+ async def fetch_orders(self, *, market_id: str | None = None, status: Any = None, since: int | None = None,
344
+ until: int | None = None, limit: int | None = None, cursor: str | None = None) -> list[Order]:
345
+ orders = list(self.orders.values())
346
+ if market_id:
347
+ orders = [o for o in orders if o.market_id == market_id]
348
+ if since:
349
+ orders = [o for o in orders if (o.created_at or 0) >= since]
350
+ return orders[: limit or len(orders)]
351
+
352
+ async def fetch_my_trades(self, *, market_id: str | None = None, since: int | None = None, until: int | None = None,
353
+ limit: int | None = None, cursor: str | None = None) -> list[Fill]:
354
+ fills = self.fills
355
+ if market_id:
356
+ fills = [f for f in fills if f.market_id == market_id]
357
+ if since:
358
+ fills = [f for f in fills if f.timestamp >= since]
359
+ return fills[: limit or len(fills)]
360
+
361
+ async def fetch_queue_position(self, order_id: str) -> Decimal:
362
+ rest = self.resting.get(order_id)
363
+ if rest is None:
364
+ raise OrderNotFound(f"paper: no resting order {order_id}")
365
+ return rest.queue_ahead
366
+
367
+ async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]:
368
+ out = []
369
+ for held_market, contracts in self.positions_held.items():
370
+ if contracts == 0:
371
+ continue
372
+ if market_id and held_market != market_id:
373
+ continue
374
+ out.append(Position(
375
+ venue=self.venue, account=self.account, market_id=held_market,
376
+ side=PositionSide.LONG if contracts > 0 else PositionSide.SHORT, contracts=abs(contracts),
377
+ timestamp=int(self.clock() * 1000),
378
+ ))
379
+ return out
380
+
381
+ async def fetch_balance(self, *, account: Account | None = None) -> Balance:
382
+ return Balance(
383
+ venue=self.venue, account=account or self.account, currency="USD", total=self.cash,
384
+ available=self.cash - self.locked, locked=self.locked, timestamp=int(self.clock() * 1000),
385
+ )
386
+
387
+ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, amount: Decimal) -> Any:
388
+ from ..trading.types import FeeEstimate
389
+
390
+ return FeeEstimate(
391
+ venue=self.venue, market_id=market_id, side=side, price=price, amount=amount,
392
+ taker_fee=self.fees(market_id, price, amount, Liquidity.TAKER),
393
+ maker_fee=self.fees(market_id, price, amount, Liquidity.MAKER),
394
+ )
395
+
396
+ async def close(self) -> None:
397
+ return None
398
+
399
+ # -- the simulation itself ------------------------------------------------
400
+
401
+ def _would_cross(self, order: Order, book: BookState) -> bool:
402
+ if order.price is None:
403
+ return True
404
+ if order.side == Side.BUY:
405
+ return book.best_ask is not None and order.price >= book.best_ask
406
+ return book.best_bid is not None and order.price <= book.best_bid
407
+
408
+ def _cross(self, rest: Resting, book: BookState) -> None:
409
+ """Take whatever of the book this order can reach, level by level."""
410
+ order = rest.order
411
+ levels = book.asks if order.side == Side.BUY else book.bids
412
+ limit = order.price
413
+ remaining = order.remaining or ZERO
414
+ taken: list[Level] = []
415
+ for price, size in list(levels):
416
+ if remaining <= 0:
417
+ break
418
+ if limit is not None:
419
+ if order.side == Side.BUY and price > limit:
420
+ break
421
+ if order.side == Side.SELL and price < limit:
422
+ break
423
+ amount = min(size, remaining)
424
+ taken.append((price, amount))
425
+ remaining -= amount
426
+ for price, amount in taken:
427
+ self._fill(rest, price=price, amount=amount, liquidity=Liquidity.TAKER)
428
+ self._consume(book, order.side, price, amount)
429
+ if taken:
430
+ # Whatever is left joins the queue at its own price, behind the rest.
431
+ rest.queue_ahead = book.size_at(order.side, order.price) if order.price is not None else ZERO
432
+
433
+ @staticmethod
434
+ def _consume(book: BookState, side: Side, price: Decimal, amount: Decimal) -> None:
435
+ levels = book.asks if side == Side.BUY else book.bids
436
+ for index, (level_price, size) in enumerate(levels):
437
+ if level_price == price:
438
+ left = size - amount
439
+ if left > 0:
440
+ levels[index] = (level_price, left)
441
+ else:
442
+ levels.pop(index)
443
+ return
444
+
445
+ def _fill(self, rest: Resting, *, price: Decimal, amount: Decimal, liquidity: Liquidity) -> Fill:
446
+ order = rest.order
447
+ fee = self.fees(order.market_id, price, amount, liquidity)
448
+ stamp = int(self.clock() * 1000)
449
+ fill = Fill(
450
+ id=f"pf-{uuid.uuid4().hex[:12]}", order_id=order.id, client_order_id=order.client_order_id,
451
+ venue=self.venue, account=order.account or self.account,
452
+ market_id=order.market_id, side=order.side, price=price, amount=amount, fee=fee, fee_currency="USD",
453
+ liquidity=liquidity, timestamp=stamp,
454
+ )
455
+ self.fills.append(fill)
456
+ self.pending.append(fill)
457
+ filled = order.filled + amount
458
+ average = ((order.average_price or ZERO) * order.filled + price * amount) / filled if filled else ZERO
459
+ updated = order.model_copy(update={
460
+ "filled": filled, "remaining": order.amount - filled, "average_price": average,
461
+ "last_fill_price": price, "last_fill_amount": amount, "fee": (order.fee or ZERO) + fee,
462
+ "status": OrderStatus.CLOSED if order.amount - filled <= 0 else OrderStatus.OPEN,
463
+ "updated_at": stamp,
464
+ })
465
+ self.orders[order.id] = updated
466
+ rest.order = updated
467
+ self._changed(updated)
468
+ signed = amount if order.side == Side.BUY else -amount
469
+ self.positions_held[order.market_id] = self.positions_held.get(order.market_id, ZERO) + signed
470
+ self.cash -= (price * amount if order.side == Side.BUY else -price * amount) + fee
471
+ if updated.remaining is not None and updated.remaining <= 0:
472
+ self.resting.pop(order.id, None)
473
+ return fill
474
+
475
+ def _unfill(self, order_id: str) -> None:
476
+ """Undo the fills of a fill-or-kill that could not complete."""
477
+ order = self.orders[order_id]
478
+ mine = [f for f in self.fills if f.order_id == order_id]
479
+ for fill in mine:
480
+ signed = fill.amount if fill.side == Side.BUY else -fill.amount
481
+ self.positions_held[fill.market_id] = self.positions_held.get(fill.market_id, ZERO) - signed
482
+ self.cash += (fill.price * fill.amount if fill.side == Side.BUY else -fill.price * fill.amount) + (fill.fee or ZERO)
483
+ self.fills = [f for f in self.fills if f.order_id != order_id]
484
+ self.pending = [f for f in self.pending if f.order_id != order_id]
485
+ self.orders[order_id] = order.model_copy(update={
486
+ "filled": ZERO, "remaining": order.amount, "average_price": None, "fee": ZERO,
487
+ })
488
+ if order_id in self.resting:
489
+ self.resting[order_id].order = self.orders[order_id]
490
+
491
+ def _close(self, order_id: str, status: OrderStatus) -> Order:
492
+ order = self.orders[order_id]
493
+ closed = order.model_copy(update={"status": status, "updated_at": int(self.clock() * 1000)})
494
+ self.orders[order_id] = closed
495
+ self.resting.pop(order_id, None)
496
+ self._changed(closed)
497
+ return closed
498
+
499
+ def _changed(self, order: Order) -> None:
500
+ """Queue an order update for delivery, newest state per order."""
501
+ self.pending_orders = [o for o in self.pending_orders if o.id != order.id]
502
+ self.pending_orders.append(order)
503
+
504
+ # -- reporting ------------------------------------------------------------
505
+
506
+ def settle(self, market_id: str, *, yes_wins: bool) -> Decimal:
507
+ """Resolve a market: pay the holders and flatten the position. The
508
+ position is signed on the YES leg: a long is paid the face value if
509
+ YES wins, a short (NO held) if it does not."""
510
+ contracts = self.positions_held.get(market_id, ZERO)
511
+ if contracts == 0:
512
+ return ZERO
513
+ if contracts > 0:
514
+ proceeds = (self.face_value if yes_wins else ZERO) * contracts
515
+ else:
516
+ proceeds = (ZERO if yes_wins else self.face_value) * -contracts
517
+ self.cash += proceeds
518
+ self.positions_held[market_id] = ZERO
519
+ return proceeds
520
+
521
+ @property
522
+ def equity(self) -> Decimal:
523
+ """Cash plus what the positions would fetch at the touch."""
524
+ total = self.cash
525
+ for market_id, contracts in self.positions_held.items():
526
+ if contracts == 0:
527
+ continue
528
+ book = self.books.get(market_id, BookState())
529
+ price = book.best_bid if contracts > 0 else book.best_ask
530
+ if price is not None:
531
+ total += price * contracts
532
+ return total