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,496 @@
1
+ """A market order on a bucket: legs on every member venue, re-planned as they fill.
2
+
3
+ The bucket is the instrument, not a strategy: this parent is what a market
4
+ order becomes when its market id is `bucket:<id>`. Its price is the worst
5
+ price the caller accepts, in bucket terms (called `limit` inside). It asks the router
6
+ where the size should sit given the members' books, puts one leg per venue,
7
+ and every time a leg fills, a book moves or a leg is pulled, asks again for
8
+ the remainder. Legs that the new plan keeps at the same price and size are
9
+ left alone, so they hold their place in the queue; only what changed is
10
+ cancelled and re-placed, and never before `min_stay_s` has passed.
11
+
12
+ What it never does: put out legs whose sizes sum to more than is left, send
13
+ anything without a price, or keep going past the caller's limit. It stops,
14
+ and says why, when the worst price is reached with nothing resting, when what
15
+ is left is below every venue's minimum, or when `max_rounds` or `max_age_s`
16
+ is reached. A stop with a partial fill finishes `canceled`, because `done`
17
+ means filled.
18
+
19
+ What each leg has filled comes from one place, the venue's order record:
20
+ the placement's answer, then every order-status update, then a direct read
21
+ after a restart. Fill events never reach this parent; they are the ledger's.
22
+
23
+ Fees come from each venue adapter's estimate, one call per distinct level
24
+ price, cached for the parent's life. Precision is the venue's published
25
+ rules unless `params["precision"]` overrides it per market.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass, field
30
+ from decimal import Decimal
31
+ from typing import Any
32
+
33
+ from ...bucket import Bucket, bucket_id_of
34
+ from ...trading.types import Fill, Order, OrderRequest, OrderType, Precision, Side, TimeInForce
35
+ from .. import router
36
+ from .base import ZERO, Child, Context, D, ManagedOrder, now_ms
37
+ from .manager import register
38
+
39
+
40
+ @dataclass
41
+ class LegState:
42
+ """One resting leg, by market. The `Child` holds what the venue said;
43
+ this holds what the plan meant."""
44
+ order_id: str
45
+ venue: str
46
+ side: str
47
+ price: Decimal
48
+ """The member's own YES price, as sent."""
49
+ bucket_price: Decimal
50
+ amount: Decimal
51
+ placed_at: float
52
+ round: int
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ return {"order_id": self.order_id, "venue": self.venue, "side": self.side, "price": str(self.price),
56
+ "bucket_price": str(self.bucket_price), "amount": str(self.amount),
57
+ "placed_at": self.placed_at, "round": self.round}
58
+
59
+ @classmethod
60
+ def of(cls, row: dict[str, Any]) -> "LegState":
61
+ return cls(order_id=row["order_id"], venue=row.get("venue") or "", side=row["side"], price=D(row["price"]),
62
+ bucket_price=D(row["bucket_price"]),
63
+ amount=D(row["amount"]), placed_at=float(row.get("placed_at") or 0), round=int(row.get("round") or 0))
64
+
65
+
66
+ @register
67
+ class RoutedLimit(ManagedOrder):
68
+ """`params`: `bucket` (its definition; the engine fills this in from the
69
+ journal), `min_stay_s`, `max_rounds`, `max_age_s`, `precision`."""
70
+
71
+ kind = "routed_limit"
72
+ order_type = OrderType.MARKET
73
+
74
+ def __init__(self, *args: Any, **kwargs: Any):
75
+ super().__init__(*args, **kwargs)
76
+ definition = self.params.get("bucket")
77
+ if not definition:
78
+ raise ValueError("a routed limit needs its bucket definition in params['bucket']")
79
+ self.bucket = Bucket.model_validate(definition)
80
+ if self.request.price is None:
81
+ raise ValueError("a bucket order needs a price: the worst price the caller accepts")
82
+ self.limit: Decimal = D(self.request.price)
83
+ self.min_stay_s = float(self.params.get("min_stay_s") or 0)
84
+ self.max_rounds = int(self.params.get("max_rounds") or 0)
85
+ self.max_age_s = float(self.params.get("max_age_s") or 0)
86
+ self.precision: dict[str, Precision] = {}
87
+ for market_id in self.bucket.market_ids():
88
+ override = (self.params.get("precision") or {}).get(market_id)
89
+ self.precision[market_id] = Precision.model_validate(override) if override else router.default_precision(market_id)
90
+ self.legs: dict[str, LegState] = {}
91
+ self.rounds = 0
92
+ self.started_at = 0.0
93
+ self.stop_reason = ""
94
+ self.fees_paid = ZERO
95
+ self.fee_cache: dict[str, Decimal] = {}
96
+ """`market_id@price` -> taker fee per contract."""
97
+ self.fee_unknown: set[str] = set()
98
+ self._replanning = False
99
+ self._replan_again = False
100
+ self._resynced = False
101
+ """False until this parent has asked the venues about its legs after
102
+ a restart. A fresh parent sets it in `start`."""
103
+
104
+ # -- identity: the bucket, and every member ---------------------------------
105
+
106
+ @property
107
+ def market_id(self) -> str:
108
+ return self.request.market_id
109
+
110
+ def markets(self) -> list[str]:
111
+ return self.bucket.market_ids()
112
+
113
+ def venues(self) -> set[str]:
114
+ return self.bucket.venues()
115
+
116
+ # -- what is filled, by the venue's order record ----------------------------
117
+
118
+ @staticmethod
119
+ def key(child: Child) -> str:
120
+ return f"{child.venue}:{child.order_id}"
121
+
122
+ def known(self, child: Child) -> Decimal:
123
+ """Filled on this child as the venue's order record last said."""
124
+ return child.filled
125
+
126
+ @property
127
+ def known_filled(self) -> Decimal:
128
+ return self.filled
129
+
130
+ @property
131
+ def known_remaining(self) -> Decimal:
132
+ return self.remaining
133
+
134
+ def budget(self) -> Decimal:
135
+ """Size new legs may add: what is left after every live leg's whole
136
+ size is reserved and every closed leg's fill is counted."""
137
+ reserved = sum((c.amount if c.live else self.known(c) for c in self.children), ZERO)
138
+ return max(ZERO, self.amount - reserved)
139
+
140
+ # -- running --------------------------------------------------------------
141
+
142
+ async def start(self, ctx: Context) -> None:
143
+ self._resynced = True
144
+ self.state = "working"
145
+ self.started_at = ctx.now
146
+ await self.replan(ctx, first=True)
147
+
148
+ async def resync(self, ctx: Context) -> None:
149
+ """After a restart: the venues are the authority on what each leg
150
+ did while this process was away. Ask every venue for every child,
151
+ take in any child the journal links to this parent that the
152
+ snapshot did not carry (an intent that was in doubt when the
153
+ process died), then plan the remainder. Nothing is placed before
154
+ this has run."""
155
+ self._resynced = True
156
+ for child in list(self.children):
157
+ adapter = ctx.engine.adapters.get(child.venue)
158
+ if adapter is None:
159
+ continue
160
+ try:
161
+ order = await adapter.fetch_order(child.order_id)
162
+ except Exception as exc:
163
+ await ctx.publish("managed.resync_failed", {"order_id": child.order_id, "venue": child.venue,
164
+ "error": f"{type(exc).__name__}: {exc}"})
165
+ continue
166
+ progress = self.note_order(order, child) # the record read back overwrites the snapshot's
167
+ if progress is not None:
168
+ self.fees_paid += progress.fee or ZERO
169
+ known = {self.key(c) for c in self.children}
170
+ for order in await ctx.engine.journal.orders_for_parent(self.id):
171
+ if f"{order.venue}:{order.id}" in known:
172
+ continue
173
+ member = self.bucket.member(order.market_id)
174
+ _, bucket_price = member.to_bucket(order.side, order.price or ZERO)
175
+ child = self.track(order, order.amount, bucket_price)
176
+ child.fee = order.fee or ZERO
177
+ ctx.engine.orders.adopt_child(self.id, order.id, order.venue)
178
+ if child.live and order.market_id not in self.legs:
179
+ self.legs[order.market_id] = LegState(order_id=order.id, venue=order.venue, side=order.side.value,
180
+ price=order.price or ZERO, bucket_price=bucket_price,
181
+ amount=order.amount, placed_at=ctx.now, round=self.rounds)
182
+ self._forget_dead_legs()
183
+ await ctx.publish("managed.resynced", {"children": len(self.children), "filled": str(self.known_filled)})
184
+ if self.state == "working":
185
+ if self.remaining <= 0:
186
+ await self.pull_children(ctx)
187
+ await self.finish(ctx, "done", "filled")
188
+ else:
189
+ await self.replan(ctx)
190
+
191
+ async def _ready(self, ctx: Context) -> bool:
192
+ if not self._resynced:
193
+ await self.resync(ctx)
194
+ return self.state == "working"
195
+
196
+ async def on_book(self, ctx: Context, market_id: str) -> None:
197
+ if await self._ready(ctx):
198
+ await self.replan(ctx)
199
+
200
+ async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None:
201
+ self.fees_paid += fill.fee or ZERO
202
+ if not await self._ready(ctx):
203
+ return
204
+ if self.remaining <= 0:
205
+ await self.pull_children(ctx)
206
+ await self.finish(ctx, "done", "filled")
207
+ return
208
+ await self.replan(ctx)
209
+
210
+ async def on_child(self, ctx: Context, order: Order, child: Child) -> None:
211
+ if not await self._ready(ctx) or not order.is_terminal:
212
+ return
213
+ for market_id, leg in list(self.legs.items()):
214
+ if leg.order_id == order.id and leg.venue == order.venue:
215
+ self.legs.pop(market_id, None)
216
+ if self.remaining <= 0:
217
+ await self.complete_if_done(ctx)
218
+ else:
219
+ await self.replan(ctx)
220
+
221
+ async def on_timer(self, ctx: Context) -> None:
222
+ if not await self._ready(ctx):
223
+ return
224
+ if self.max_age_s and ctx.now - self.started_at >= self.max_age_s:
225
+ await self.stop(ctx, "max_age")
226
+ return
227
+ if not self.live_children and self.remaining > 0:
228
+ await self.replan(ctx)
229
+
230
+ async def stop(self, ctx: Context, reason: str) -> None:
231
+ """Stand down with what filled. `done` only when it all did."""
232
+ self.stop_reason = reason
233
+ await self.pull_children(ctx)
234
+ if self.remaining <= 0:
235
+ await self.finish(ctx, "done", "filled")
236
+ elif self.live_children:
237
+ self.state = "cancelling"
238
+ await ctx.save()
239
+ else:
240
+ await self.finish(ctx, "canceled", f"stopped: {reason}")
241
+
242
+ # -- the plan and the legs ------------------------------------------------
243
+
244
+ def may_move(self, ctx: Context, leg: LegState) -> bool:
245
+ return ctx.now - leg.placed_at >= self.min_stay_s
246
+
247
+ async def replan(self, ctx: Context, *, first: bool = False) -> None:
248
+ """Re-entrant safe: a cancel inside a plan can hand the engine a
249
+ terminal child, whose `on_child` asks for a plan of its own. That
250
+ request is noted and honoured once this plan is through."""
251
+ if self._replanning:
252
+ self._replan_again = True
253
+ return
254
+ self._replanning = True
255
+ try:
256
+ await self._replan(ctx, first=first)
257
+ while self._replan_again and self.state == "working":
258
+ self._replan_again = False
259
+ await self._replan(ctx)
260
+ finally:
261
+ self._replanning = False
262
+ self._replan_again = False
263
+
264
+ async def _replan(self, ctx: Context, *, first: bool = False) -> None:
265
+ if self.known_remaining <= 0:
266
+ # Everything is filled by the venue's word; the fills the stream
267
+ # has not delivered yet finish it through `on_fill`.
268
+ await self.complete_if_done(ctx)
269
+ return
270
+ self._forget_dead_legs()
271
+ pulled = await self._trim(ctx)
272
+ books = {}
273
+ for member in self.bucket.members:
274
+ book = ctx.book(member.market_id)
275
+ if book is not None:
276
+ books[member.market_id] = router.view(book, member)
277
+ if not books:
278
+ # No book for any member yet (a restart before the streams are
279
+ # back): nothing to plan against, and no reason to pull what
280
+ # rests beyond what the trim took. Wait for a book.
281
+ if not first and pulled:
282
+ self.rounds += 1
283
+ await ctx.publish("managed.routed", {"round": self.rounds, "placed": 0, "pulled": pulled,
284
+ "resting": len(self.legs), "remaining": str(self.known_remaining),
285
+ "reason": "no_books"})
286
+ await ctx.save()
287
+ return
288
+ await self._price_fees(ctx, books)
289
+ plan = router.plan(self.bucket, books, self.precision, self._fee,
290
+ side=self.side, amount=self.known_remaining, limit=self.limit)
291
+ wanted = {leg.market_id: leg for leg in plan.legs}
292
+
293
+ # Keep a leg that is still at a price the plan wants, as long as what
294
+ # is kept does not add up to more than is left; pull one whose price
295
+ # is wrong, unless it has not sat for min_stay_s yet. Shrinking a leg
296
+ # is a cancel and a re-place at the back of the queue, so a leg is
297
+ # not pulled merely because the plan would size it smaller today. A
298
+ # kept leg is not topped up in the same round.
299
+ kept_resting = ZERO
300
+ for market_id, leg in list(self.legs.items()):
301
+ child = self.child_of(leg.order_id, leg.venue)
302
+ if child is None or not child.live:
303
+ self.legs.pop(market_id, None)
304
+ continue
305
+ want = wanted.get(market_id)
306
+ resting = child.amount - self.known(child)
307
+ if market_id not in books:
308
+ # No view of this member's book: nothing says the leg is wrong.
309
+ kept_resting += resting
310
+ wanted.pop(market_id, None)
311
+ continue
312
+ if (want is not None and want.price == leg.price and want.side == leg.side
313
+ and kept_resting + resting <= self.known_remaining):
314
+ kept_resting += resting
315
+ wanted.pop(market_id)
316
+ continue
317
+ if not self.may_move(ctx, leg):
318
+ wanted.pop(market_id, None)
319
+ continue
320
+ self.legs.pop(market_id, None)
321
+ await ctx.cancel_child(child.order_id, child.venue)
322
+ child.status = "canceled"
323
+ pulled += 1
324
+
325
+ # Never put out more than is left once every live leg is reserved.
326
+ budget = self.budget()
327
+ placed = 0
328
+ for market_id, want in wanted.items():
329
+ size = min(want.amount, budget)
330
+ if size <= 0 or size < self.precision[market_id].min_amount:
331
+ continue
332
+ request = self.child_request(market_id=market_id, side=want.side, price=want.price, amount=size,
333
+ type=OrderType.LIMIT, time_in_force=TimeInForce.GTC)
334
+ order = await ctx.submit_child(request)
335
+ child = self.track(order, size, want.bucket_price) # the child's price in bucket terms
336
+ child.fee = order.fee or ZERO
337
+ self.legs[market_id] = LegState(order_id=order.id, venue=order.venue, side=want.side.value, price=want.price,
338
+ bucket_price=want.bucket_price, amount=size, placed_at=ctx.now,
339
+ round=self.rounds)
340
+ budget -= size
341
+ placed += 1
342
+ if not first and (placed or pulled):
343
+ # A round is a re-allocation that changed something. The re-plans
344
+ # a cancel's own echo triggers change nothing and do not count.
345
+ self.rounds += 1
346
+ await ctx.publish("managed.routed", {
347
+ "round": self.rounds, "placed": placed, "pulled": pulled, "resting": len(self.legs),
348
+ "remaining": str(self.known_remaining),
349
+ "unfilled": str(plan.unfilled), "reason": plan.reason,
350
+ "expected_net_price": str(plan.expected_net_price) if plan.expected_net_price is not None else None,
351
+ })
352
+ if not self.live_children and self.known_remaining > 0 and plan.reason:
353
+ await self.stop(ctx, plan.reason)
354
+ return
355
+ if self.max_rounds and self.rounds >= self.max_rounds and self.known_remaining > 0:
356
+ await self.stop(ctx, "max_rounds")
357
+ return
358
+ await ctx.save()
359
+
360
+ async def _trim(self, ctx: Context) -> int:
361
+ """Pull legs, newest first, until what rests no longer exceeds what
362
+ is left. The ordinary case never needs it; a child adopted after a
363
+ restart (an intent whose answer was lost) can put the total over."""
364
+ by_key = {f"{leg.venue}:{leg.order_id}": (market_id, leg) for market_id, leg in self.legs.items()}
365
+ live = [(by_key.get(self.key(child)), child) for child in self.live_children]
366
+ excess = sum((child.amount - self.known(child) for _, child in live), ZERO) - self.known_remaining
367
+ pulled = 0
368
+
369
+ def newest_first(item: Any) -> tuple[int, int, float]:
370
+ found, _ = item
371
+ if found is None: # a child no plan of this parent placed: first to go
372
+ return (1, 0, 0.0)
373
+ _, leg = found
374
+ return (0, leg.round, leg.placed_at)
375
+
376
+ for found, child in sorted(live, key=newest_first, reverse=True):
377
+ if excess <= 0:
378
+ break
379
+ excess -= child.amount - self.known(child)
380
+ if found is not None:
381
+ self.legs.pop(found[0], None)
382
+ await ctx.cancel_child(child.order_id, child.venue)
383
+ child.status = "canceled"
384
+ pulled += 1
385
+ if pulled:
386
+ await ctx.publish("managed.trimmed", {"pulled": pulled, "remaining": str(self.known_remaining)})
387
+ return pulled
388
+
389
+ def _forget_dead_legs(self) -> None:
390
+ for market_id, leg in list(self.legs.items()):
391
+ child = self.child_of(leg.order_id, leg.venue)
392
+ if child is None or not child.live:
393
+ self.legs.pop(market_id, None)
394
+
395
+ # -- fees -----------------------------------------------------------------
396
+
397
+ def _fee(self, market_id: str, price: Decimal, contracts: Decimal) -> Decimal:
398
+ return self.fee_cache.get(f"{market_id}@{price}", ZERO) * contracts
399
+
400
+ async def _price_fees(self, ctx: Context, books: dict[str, router.BookView]) -> None:
401
+ """One estimate per distinct level price, from the member's own venue."""
402
+ for member in self.bucket.members:
403
+ seen = books.get(member.market_id)
404
+ if seen is None:
405
+ continue
406
+ rows = seen.asks if self.side == Side.BUY else seen.bids
407
+ for price, _ in rows:
408
+ key = f"{member.market_id}@{price}"
409
+ if key in self.fee_cache:
410
+ continue
411
+ self.fee_cache[key] = await self._estimate(ctx, member.market_id, member.to_member(self.side, price))
412
+
413
+ async def _estimate(self, ctx: Context, market_id: str, member_order: tuple[Side, Decimal]) -> Decimal:
414
+ venue = market_id.split(":", 1)[0]
415
+ adapter = ctx.engine.adapters.get(venue)
416
+ has = getattr(adapter, "has", {}) or {}
417
+ if adapter is None or not has.get("fetch_fee_estimate"):
418
+ if market_id not in self.fee_unknown:
419
+ self.fee_unknown.add(market_id)
420
+ await ctx.publish("managed.fee_unknown", {"market_id": market_id})
421
+ return ZERO
422
+ side, price = member_order
423
+ try:
424
+ estimate = await adapter.fetch_fee_estimate(market_id, side, price, Decimal("1"))
425
+ except Exception as exc: # a fee the venue would not quote is not a reason to stop
426
+ await ctx.publish("managed.fee_unknown", {"market_id": market_id, "error": str(exc)})
427
+ return ZERO
428
+ fee = getattr(estimate, "taker_fee", None)
429
+ return D(fee, ZERO) if fee is not None else ZERO
430
+
431
+ # -- reporting ------------------------------------------------------------
432
+
433
+ def average_price(self) -> Decimal | None:
434
+ filled = self.known_filled
435
+ if filled <= 0:
436
+ return None
437
+ spent = sum((self.known(c) * (c.price or ZERO) for c in self.children), ZERO)
438
+ return spent / filled
439
+
440
+ def as_order(self) -> Order:
441
+ """As the base class, but filled and the average by the venue's word,
442
+ in bucket terms."""
443
+ order = super().as_order()
444
+ filled = self.known_filled
445
+ return order.model_copy(update={"filled": filled, "remaining": max(ZERO, self.amount - filled),
446
+ "average_price": self.average_price()})
447
+
448
+ def report(self) -> dict[str, Any]:
449
+ """What filled, where, at what: bucket-terms prices, per market."""
450
+ per_market: dict[str, dict[str, Any]] = {}
451
+ for child in self.children:
452
+ slot = per_market.setdefault(child.venue, {"filled": ZERO, "cost": ZERO})
453
+ slot["filled"] += self.known(child)
454
+ slot["cost"] += self.known(child) * (child.price or ZERO)
455
+ breakdown = []
456
+ for venue, slot in per_market.items():
457
+ breakdown.append({"venue": venue, "filled": str(slot["filled"]),
458
+ "average_price": str(slot["cost"] / slot["filled"]) if slot["filled"] > 0 else None})
459
+ return {
460
+ "order_id": self.id, "bucket_id": self.bucket.id, "side": self.side.value, "amount": str(self.amount), "worst_price": str(self.limit),
461
+ "filled": str(self.known_filled), "unfilled": str(self.known_remaining),
462
+ "average_price": str(self.average_price()) if self.average_price() is not None else None,
463
+ "fees_paid": str(self.fees_paid), "rounds": self.rounds, "state": self.state,
464
+ "stop_reason": self.stop_reason, "per_venue": breakdown, "detail": self.detail or None,
465
+ }
466
+
467
+ # -- persistence ----------------------------------------------------------
468
+
469
+ def extra(self) -> dict[str, Any]:
470
+ return {
471
+ "bucket": self.bucket.model_dump(mode="json"), "limit": str(self.limit),
472
+ "min_stay_s": self.min_stay_s, "max_rounds": self.max_rounds, "max_age_s": self.max_age_s,
473
+ "precision": {k: v.model_dump(mode="json") for k, v in self.precision.items()},
474
+ "legs": {k: v.to_dict() for k, v in self.legs.items()},
475
+ "rounds": self.rounds, "started_at": self.started_at, "stop_reason": self.stop_reason,
476
+ "fees_paid": str(self.fees_paid), "fee_cache": {k: str(v) for k, v in self.fee_cache.items()},
477
+ }
478
+
479
+ def load_extra(self, extra: dict[str, Any]) -> None:
480
+ if extra.get("bucket"):
481
+ self.bucket = Bucket.model_validate(extra["bucket"])
482
+ self.limit = D(extra.get("limit"), self.limit)
483
+ self.min_stay_s = float(extra.get("min_stay_s") or 0)
484
+ self.max_rounds = int(extra.get("max_rounds") or 0)
485
+ self.max_age_s = float(extra.get("max_age_s") or 0)
486
+ for market_id, row in (extra.get("precision") or {}).items():
487
+ self.precision[market_id] = Precision.model_validate(row)
488
+ self.legs = {k: LegState.of(v) for k, v in (extra.get("legs") or {}).items()}
489
+ self.rounds = int(extra.get("rounds") or 0)
490
+ self.started_at = float(extra.get("started_at") or 0)
491
+ self.stop_reason = extra.get("stop_reason") or ""
492
+ self.fees_paid = D(extra.get("fees_paid"), ZERO)
493
+ self.fee_cache = {k: D(v) for k, v in (extra.get("fee_cache") or {}).items()}
494
+
495
+
496
+ __all__ = ["RoutedLimit", "LegState", "bucket_id_of"]