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,441 @@
1
+ """Engine-held orders: the machinery every synthetic type is built on.
2
+
3
+ A venue holds limit and market orders. Everything else in the order set --
4
+ stops, icebergs, brackets, TWAP, pegs -- is a decision this engine makes
5
+ over time, which means it must survive the process making it. So each one is
6
+ a `ManagedOrder`: a small state machine whose whole state is a dictionary,
7
+ written to the journal whenever it changes, restored on start.
8
+
9
+ The rules that follow from that:
10
+
11
+ **A parent is not an order at the venue.** It is journaled with
12
+ `held_by="engine"` and a status of `waiting` until its condition fires, then
13
+ `triggered` while its children work. Reconciliation knows to skip it, because
14
+ asking a venue about an order it was never told about would report it missing
15
+ every minute.
16
+
17
+ **Children are ordinary orders.** They go through `Engine.submit`, so the
18
+ risk rules, the journal and the ledger treat them exactly like anything else.
19
+ A parent that wants ten contracts in slices of one submits ten orders, each
20
+ of which can be refused on its own.
21
+
22
+ **Events arrive, the parent decides.** `on_book`, `on_trade`, `on_fill`,
23
+ `on_child`, `on_timer` and `on_halt` are the only ways in. A type that needs
24
+ the touch reads it from the book the engine keeps; one that needs a clock
25
+ gets `on_timer` about once a second, which is also what makes a restart
26
+ harmless: the timer does not care how long it has been away.
27
+
28
+ **Cancelling is not optional.** Cancelling a parent cancels its live
29
+ children first and only then marks itself done; a halt does the same. A
30
+ parent that cannot reach the venue stays `cancelling` and says so rather
31
+ than pretending.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import asyncio
36
+ import logging
37
+ import time
38
+ import uuid
39
+ from dataclasses import dataclass, field
40
+ from decimal import Decimal
41
+ from typing import Any, Iterable, Literal, Mapping
42
+
43
+ from ...trading.types import (
44
+ Account, Fill, HeldBy, Order, OrderRequest, OrderStatus, OrderType, Side, TimeInForce,
45
+ )
46
+
47
+ log = logging.getLogger("synpath.engine.orders")
48
+
49
+ ZERO = Decimal("0")
50
+ ONE = Decimal("1")
51
+
52
+ State = Literal["waiting", "working", "cancelling", "done", "canceled", "rejected"]
53
+ LIVE_STATES: frozenset[str] = frozenset({"waiting", "working", "cancelling"})
54
+
55
+ STATUS_OF: dict[str, OrderStatus] = {
56
+ "waiting": OrderStatus.WAITING,
57
+ "working": OrderStatus.TRIGGERED,
58
+ "cancelling": OrderStatus.PENDING_CANCEL,
59
+ "done": OrderStatus.CLOSED,
60
+ "canceled": OrderStatus.CANCELED,
61
+ "rejected": OrderStatus.REJECTED,
62
+ }
63
+
64
+
65
+ def now_ms() -> int:
66
+ return int(time.time() * 1000)
67
+
68
+
69
+ def D(value: Any, default: Decimal | None = None) -> Decimal | None:
70
+ if value is None or value == "":
71
+ return default
72
+ return value if isinstance(value, Decimal) else Decimal(str(value))
73
+
74
+
75
+ @dataclass(slots=True)
76
+ class Child:
77
+ """One venue order a parent put out, and what became of it.
78
+
79
+ `filled`, `fee` and `status` are the venue's order record as it was last
80
+ seen: the placement's answer, then every order-status update, then a
81
+ direct read after a restart. Each is a whole snapshot and the latest
82
+ overwrites. Fill events are never added on top; they are the ledger's."""
83
+
84
+ order_id: str
85
+ venue: str
86
+ amount: Decimal
87
+ price: Decimal | None = None
88
+ filled: Decimal = ZERO
89
+ fee: Decimal = ZERO
90
+ status: str = "open"
91
+ created_ms: int = field(default_factory=now_ms)
92
+
93
+ @property
94
+ def live(self) -> bool:
95
+ return self.status in ("open", "pending")
96
+
97
+ def to_dict(self) -> dict[str, Any]:
98
+ return {
99
+ "order_id": self.order_id, "venue": self.venue, "amount": str(self.amount),
100
+ "price": str(self.price) if self.price is not None else None, "filled": str(self.filled),
101
+ "fee": str(self.fee),
102
+ "status": self.status, "created_ms": self.created_ms,
103
+ }
104
+
105
+ @classmethod
106
+ def of(cls, row: Mapping[str, Any]) -> "Child":
107
+ return cls(
108
+ order_id=row["order_id"], venue=row["venue"], amount=D(row["amount"]), price=D(row.get("price")),
109
+ filled=D(row.get("filled"), ZERO), fee=D(row.get("fee"), ZERO), status=row.get("status", "open"),
110
+ created_ms=int(row.get("created_ms") or now_ms()),
111
+ )
112
+
113
+
114
+ class Context:
115
+ """What a managed order is allowed to do: submit, cancel, look, report.
116
+
117
+ Deliberately narrow. A parent cannot reach the adapters, the journal or
118
+ the risk rules directly, so every child it places is an ordinary order
119
+ that the engine checks and records.
120
+ """
121
+
122
+ def __init__(self, engine: Any, parent: "ManagedOrder"):
123
+ self.engine = engine
124
+ self.parent = parent
125
+
126
+ @property
127
+ def now(self) -> float:
128
+ return self.engine.clock()
129
+
130
+ def book(self, market_id: str | None = None) -> Any | None:
131
+ """The engine's local book for this instrument, if a stream feeds one
132
+ and it is ready. A book that is not ready (no snapshot yet, or the
133
+ stream lost confidence in it after a gap) reads as no book: its levels
134
+ may be stale, and a stop must not fire on a price nobody is quoting."""
135
+ book = self.engine.books.get(market_id or self.parent.market_id)
136
+ if book is not None and getattr(book, "ready", True) is False:
137
+ return None
138
+ return book
139
+
140
+ def touch(self, side: Side, market_id: str | None = None) -> Decimal | None:
141
+ """The price a taker on `side` would pay: the ask to buy, the bid to sell."""
142
+ book = self.book(market_id)
143
+ if book is None:
144
+ return None
145
+ return book.best_ask if side == Side.BUY else book.best_bid
146
+
147
+ def mid(self, market_id: str | None = None) -> Decimal | None:
148
+ book = self.book(market_id)
149
+ if book is None or book.best_bid is None or book.best_ask is None:
150
+ return None
151
+ return (book.best_bid + book.best_ask) / 2
152
+
153
+ def last(self, market_id: str | None = None) -> Decimal | None:
154
+ return self.engine.last_trade.get(market_id or self.parent.market_id)
155
+
156
+ def mark(self, market_id: str | None = None) -> Decimal | None:
157
+ instrument = market_id or self.parent.market_id
158
+ return (self.engine.fair_values.get(self.parent.account_key, instrument)
159
+ or self.mid(instrument) or self.last(instrument))
160
+
161
+ def levels(self, side: Side, market_id: str | None = None) -> list[tuple[Decimal, Decimal]]:
162
+ """The far side of the book, best first: what a taker would eat."""
163
+ book = self.book(market_id)
164
+ if book is None:
165
+ return []
166
+ bids, asks = book.levels()
167
+ rows = asks if side == Side.BUY else bids
168
+ return [(level.price, level.size) for level in rows]
169
+
170
+ async def submit_child(self, request: OrderRequest, **kw: Any) -> Order:
171
+ return await self.engine.submit_child(self.parent, request, **kw)
172
+
173
+ async def cancel_child(self, order_id: str, venue: str | None = None) -> Order | None:
174
+ return await self.engine.cancel_child(self.parent, order_id, venue=venue)
175
+
176
+ async def publish(self, kind: str, payload: dict[str, Any] | None = None) -> None:
177
+ await self.engine.bus.publish(kind, {"parent_id": self.parent.id, **(payload or {})}, key=self.parent.id)
178
+
179
+ async def save(self) -> None:
180
+ await self.engine.orders.save(self.parent)
181
+
182
+
183
+ class ManagedOrder:
184
+ """One engine-held order. Subclasses implement the hooks they need."""
185
+
186
+ kind: str = "managed"
187
+ order_type: OrderType = OrderType.LIMIT
188
+
189
+ def __init__(
190
+ self,
191
+ request: OrderRequest,
192
+ *,
193
+ id: str | None = None,
194
+ venue: str,
195
+ account: Account,
196
+ state: State = "waiting",
197
+ ):
198
+ self.id = id or f"mo-{uuid.uuid4().hex[:16]}"
199
+ self.request = request
200
+ self.venue = venue
201
+ self.account = account
202
+ self.state: State = state
203
+ self.children: list[Child] = []
204
+ self.created_ms = now_ms()
205
+ self.updated_ms = self.created_ms
206
+ self.detail: str = ""
207
+ self.params: dict[str, Any] = dict(request.params or {})
208
+ self.owner_id: str | None = None
209
+ """Set when this parent is a leg of another one (a bracket's stop)."""
210
+
211
+ # -- identity -------------------------------------------------------------
212
+
213
+ @property
214
+ def market_id(self) -> str:
215
+ return self.request.market_id
216
+
217
+ @property
218
+ def account_key(self) -> str:
219
+ return self.account.key
220
+
221
+ @property
222
+ def side(self) -> Side:
223
+ return self.request.side
224
+
225
+ @property
226
+ def amount(self) -> Decimal:
227
+ return self.request.amount
228
+
229
+ @property
230
+ def filled(self) -> Decimal:
231
+ """What the venues say has filled across the children. Derived, never
232
+ accumulated, so a fill can only be counted once."""
233
+ return sum((c.filled for c in self.children), ZERO)
234
+
235
+ @property
236
+ def remaining(self) -> Decimal:
237
+ return max(ZERO, self.amount - self.filled)
238
+
239
+ @property
240
+ def live(self) -> bool:
241
+ return self.state in LIVE_STATES
242
+
243
+ @property
244
+ def live_children(self) -> list[Child]:
245
+ return [c for c in self.children if c.live]
246
+
247
+ def child_of(self, order_id: str, venue: str | None = None) -> Child | None:
248
+ """The child with this venue order id. Ids are the venue's own and only
249
+ unique there, so a parent with legs on several venues must say which."""
250
+ return next((c for c in self.children if c.order_id == order_id and (venue is None or c.venue == venue)), None)
251
+
252
+ def markets(self) -> list[str]:
253
+ """Which books this parent wants to hear about. Most want one; a
254
+ bracket or an OCO across two markets says so by overriding this."""
255
+ return [self.market_id]
256
+
257
+ def venues(self) -> set[str]:
258
+ """Every venue this parent has or may put a child on. A single-venue
259
+ parent is its own venue; a parent across venues overrides this so a
260
+ halt scoped to any one of them reaches it."""
261
+ return {self.venue} | {c.venue for c in self.children}
262
+
263
+ # -- hooks ----------------------------------------------------------------
264
+
265
+ async def start(self, ctx: Context) -> None:
266
+ """Called once, when the parent is accepted."""
267
+
268
+ async def on_book(self, ctx: Context, market_id: str) -> None:
269
+ """The book for one of this parent's instruments changed."""
270
+
271
+ async def on_trade(self, ctx: Context, market_id: str, price: Decimal, amount: Decimal) -> None:
272
+ """A public print."""
273
+
274
+ async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None:
275
+ """One of this parent's children filled, wholly or in part."""
276
+
277
+ async def on_child(self, ctx: Context, order: Order, child: Child) -> None:
278
+ """A child's status changed (cancelled, rejected, expired, closed)."""
279
+
280
+ async def on_timer(self, ctx: Context) -> None:
281
+ """About once a second, whatever else happened."""
282
+
283
+ async def on_halt(self, ctx: Context, reason: str) -> None:
284
+ """Trading stopped. The default pulls the children and stands down."""
285
+ await self.cancel(ctx, f"halted: {reason}")
286
+
287
+ # -- lifecycle ------------------------------------------------------------
288
+
289
+ async def cancel(self, ctx: Context, reason: str = "cancelled") -> None:
290
+ self.detail = reason
291
+ if not self.live:
292
+ return
293
+ self.state = "cancelling"
294
+ await self.pull_children(ctx)
295
+ if not self.live_children:
296
+ await self.finish(ctx, "canceled", reason)
297
+ else:
298
+ await ctx.save()
299
+
300
+ async def pull_children(self, ctx: Context) -> None:
301
+ """Cancel every live child.
302
+
303
+ A cancel the venue accepted counts, whatever status it echoes back:
304
+ Kalshi's demo answers a cancel with the order still `resting`, and a
305
+ parent that believed that would wait for ever to stand down. A cancel
306
+ that fails is a different matter, and the child stays live so the
307
+ parent keeps saying it is still cancelling.
308
+ """
309
+ for child in self.live_children:
310
+ try:
311
+ result = await ctx.cancel_child(child.order_id, child.venue)
312
+ child.status = result.status.value if result is not None and result.is_terminal else "canceled"
313
+ except Exception as exc:
314
+ log.debug("synpath.engine.orders: cancelling %s failed: %s", child.order_id, exc)
315
+
316
+ async def finish(self, ctx: Context, state: State, detail: str = "") -> None:
317
+ self.state = state
318
+ self.detail = detail or self.detail
319
+ self.updated_ms = now_ms()
320
+ await ctx.save()
321
+ await ctx.publish(f"managed.{state}", {"kind": self.kind, "filled": str(self.filled), "detail": self.detail})
322
+
323
+ def note_order(self, order: Order, child: Child) -> Fill | None:
324
+ """Take the venue's latest record of a child. Returns the progress
325
+ since the last record as a `Fill`-shaped step for the `on_fill`
326
+ hook, or `None` if nothing more filled. Built from the order record
327
+ alone: the venue's fill events go to the ledger, never here."""
328
+ before, fee_before = child.filled, child.fee
329
+ child.filled = order.filled or ZERO
330
+ child.fee = order.fee or ZERO
331
+ child.status = order.status.value
332
+ self.updated_ms = now_ms()
333
+ delta = child.filled - before
334
+ if delta <= 0:
335
+ return None
336
+ return Fill(
337
+ id=f"{order.venue}:{order.id}:{child.filled}", order_id=order.id, client_order_id=order.client_order_id,
338
+ venue=order.venue, account=order.account, market_id=order.market_id, side=order.side,
339
+ price=order.last_fill_price or order.average_price or order.price or ZERO, amount=delta,
340
+ fee=max(ZERO, child.fee - fee_before), fee_currency=order.fee_currency,
341
+ timestamp=order.updated_at or now_ms(),
342
+ )
343
+
344
+ async def complete_if_done(self, ctx: Context) -> bool:
345
+ """Finish when the parent has what it asked for and nothing is live."""
346
+ if self.remaining <= 0 and not self.live_children:
347
+ await self.finish(ctx, "done", "filled")
348
+ return True
349
+ return False
350
+
351
+ # -- children -------------------------------------------------------------
352
+
353
+ def child_request(
354
+ self,
355
+ *,
356
+ amount: Decimal,
357
+ price: Decimal | None,
358
+ type: OrderType = OrderType.LIMIT,
359
+ time_in_force: TimeInForce | None = None,
360
+ post_only: bool = False,
361
+ expires_at: int | None = None,
362
+ reduce_only: bool | None = None,
363
+ market_id: str | None = None,
364
+ side: Side | None = None,
365
+ params: dict[str, Any] | None = None,
366
+ ) -> OrderRequest:
367
+ """A venue order in this parent's name, carrying its book and trader."""
368
+ return OrderRequest(
369
+ market_id=market_id or self.market_id, side=side or self.side, amount=amount, type=type,
370
+ price=price, time_in_force=time_in_force or TimeInForce.GTC, post_only=post_only, expires_at=expires_at,
371
+ # A parent that may only reduce a position must not open one
372
+ # through its children: on Polymarket a reduce-only sell sells the
373
+ # YES held instead of buying NO.
374
+ reduce_only=self.request.reduce_only if reduce_only is None else reduce_only,
375
+ account=self.account, book=self.request.book, trader=self.request.trader,
376
+ tags={**self.request.tags, "parent": self.id}, params=params or {},
377
+ )
378
+
379
+ def track(self, order: Order, amount: Decimal, price: Decimal | None) -> Child:
380
+ """Record a child, with the status the venue already gave it: an
381
+ order that crossed on the way in comes back closed, and a parent that
382
+ recorded it as resting would wait for a fill that has happened."""
383
+ child = Child(order_id=order.id, venue=order.venue, amount=amount, price=price,
384
+ filled=order.filled, status=order.status.value)
385
+ self.children.append(child)
386
+ self.updated_ms = now_ms()
387
+ return child
388
+
389
+ # -- persistence ----------------------------------------------------------
390
+
391
+ def snapshot(self) -> dict[str, Any]:
392
+ """Everything needed to rebuild this parent after a restart."""
393
+ return {
394
+ "id": self.id, "kind": self.kind, "state": self.state, "venue": self.venue,
395
+ "account": self.account.model_dump(mode="json"), "request": self.request.model_dump(mode="json"),
396
+ "filled": str(self.filled), "children": [c.to_dict() for c in self.children],
397
+ "created_ms": self.created_ms, "updated_ms": self.updated_ms, "detail": self.detail,
398
+ "owner_id": self.owner_id, "extra": self.extra(),
399
+ }
400
+
401
+ def extra(self) -> dict[str, Any]:
402
+ """Type-specific state. Subclasses override."""
403
+ return {}
404
+
405
+ def load_extra(self, extra: dict[str, Any]) -> None:
406
+ """Take type-specific state back. Subclasses override."""
407
+
408
+ @classmethod
409
+ def from_snapshot(cls, snapshot: Mapping[str, Any]) -> "ManagedOrder":
410
+ request = OrderRequest.model_validate(snapshot["request"])
411
+ parent = cls(request, id=snapshot["id"], venue=snapshot["venue"],
412
+ account=Account.model_validate(snapshot["account"]), state=snapshot["state"])
413
+ parent.children = [Child.of(row) for row in snapshot.get("children") or []]
414
+ parent.created_ms = int(snapshot.get("created_ms") or now_ms())
415
+ parent.updated_ms = int(snapshot.get("updated_ms") or parent.created_ms)
416
+ parent.detail = snapshot.get("detail") or ""
417
+ parent.owner_id = snapshot.get("owner_id")
418
+ parent.load_extra(snapshot.get("extra") or {})
419
+ return parent
420
+
421
+ # -- how it looks from outside -------------------------------------------
422
+
423
+ def as_order(self) -> Order:
424
+ """The parent as an `Order`, so callers see one order, not a scheme."""
425
+ average = None
426
+ spent = sum((c.filled * (c.price or ZERO) for c in self.children), ZERO)
427
+ if self.filled > 0 and spent > 0:
428
+ average = spent / self.filled
429
+ return Order(
430
+ id=self.id, client_order_id=self.request.client_order_id, venue=self.venue, account=self.account,
431
+ market_id=self.market_id, side=self.side, type=self.order_type,
432
+ time_in_force=self.request.time_in_force, status=STATUS_OF[self.state], held_by=HeldBy.ENGINE,
433
+ price=self.request.price, stop_price=self.request.stop_price, amount=self.amount, filled=self.filled,
434
+ remaining=self.remaining, average_price=average, book=self.request.book, trader=self.request.trader,
435
+ tags=dict(self.request.tags), created_at=self.created_ms, updated_at=self.updated_ms,
436
+ info={"kind": self.kind, "state": self.state, "detail": self.detail,
437
+ "children": [c.order_id for c in self.children], "params": self.params},
438
+ )
439
+
440
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
441
+ return f"<{type(self).__name__} {self.id} {self.state} {self.filled}/{self.amount} {self.market_id}>"
@@ -0,0 +1,72 @@
1
+ """Day orders: an expiry the venue will honour after this process is gone.
2
+
3
+ None of the three venues has a Day order, because none of them has a trading
4
+ day: they run nearly around the clock. An engine that held Day orders itself
5
+ would cancel them when it stopped, which is the opposite of what a Day order
6
+ promises -- the one thing it must do is expire even if nobody is watching.
7
+
8
+ So Day is not an engine-held type at all. It is rewritten before the order
9
+ reaches an adapter: `time_in_force="day"` becomes `gtd` with `expires_at` at
10
+ the end of the configured session, in the configured timezone. The venue then
11
+ holds the expiry, and the journal keeps the original `day` alongside the
12
+ computed timestamp, so what the caller asked for is still visible.
13
+
14
+ The session is the operator's: `session_timezone` (the machine's zone by
15
+ default) and `session_end` (23:59:59). A desk in New York and one in London
16
+ mean different things by "today", and the engine should not guess.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from datetime import datetime, time as clock_time, timedelta
21
+ from typing import Any
22
+
23
+ from ...trading.types import OrderRequest, TimeInForce
24
+
25
+ try: # Python 3.9+ carries the zone database on most systems
26
+ from zoneinfo import ZoneInfo
27
+ except ImportError: # pragma: no cover - a build without zoneinfo
28
+ ZoneInfo = None # type: ignore[assignment]
29
+
30
+
31
+ def parse_session_end(text: str) -> clock_time:
32
+ parts = [int(p) for p in str(text).split(":")]
33
+ while len(parts) < 3:
34
+ parts.append(0)
35
+ return clock_time(*parts[:3])
36
+
37
+
38
+ def zone(name: str | None):
39
+ """The timezone to read `session_end` in; the machine's if none is named."""
40
+ if not name:
41
+ return datetime.now().astimezone().tzinfo
42
+ if ZoneInfo is None: # pragma: no cover - depends on the platform
43
+ raise RuntimeError(f"this build has no zoneinfo, so the session timezone {name!r} cannot be used")
44
+ return ZoneInfo(name)
45
+
46
+
47
+ def session_expiry(now_s: float, *, timezone_name: str | None = None, session_end: str = "23:59:59") -> int:
48
+ """When the current session ends, in milliseconds since the epoch.
49
+
50
+ A Day order placed after the session end belongs to the next session, not
51
+ to a moment in the past.
52
+ """
53
+ tz = zone(timezone_name)
54
+ end = parse_session_end(session_end)
55
+ local = datetime.fromtimestamp(now_s, tz=tz)
56
+ target = local.replace(hour=end.hour, minute=end.minute, second=end.second, microsecond=0)
57
+ if target <= local:
58
+ target += timedelta(days=1)
59
+ return int(target.timestamp() * 1000)
60
+
61
+
62
+ def as_gtd(request: OrderRequest, *, now_s: float, timezone_name: str | None = None,
63
+ session_end: str = "23:59:59") -> OrderRequest:
64
+ """A Day order as the venue will hold it. Anything else is returned as is."""
65
+ if request.time_in_force != TimeInForce.DAY:
66
+ return request
67
+ expires_at = request.expires_at or session_expiry(now_s, timezone_name=timezone_name, session_end=session_end)
68
+ return request.model_copy(update={
69
+ "time_in_force": TimeInForce.GTD,
70
+ "expires_at": expires_at,
71
+ "params": {**request.params, "requested_time_in_force": "day"},
72
+ })
@@ -0,0 +1,121 @@
1
+ """Iceberg: show a slice, keep the rest to yourself.
2
+
3
+ A large resting order tells everyone what you are doing, and on a book with
4
+ a few hundred contracts at the touch it moves the price before it fills. An
5
+ iceberg shows `display` contracts at a time and replaces the slice when it
6
+ is consumed.
7
+
8
+ Two details decide whether it is worth anything:
9
+
10
+ **The reload delay.** Replacing a filled slice instantly makes the iceberg
11
+ obvious: the size at that price never falls. A short, jittered pause makes it
12
+ look like separate participants, and `reload_delay_s` sets it (zero is
13
+ allowed, and honest, for callers who only want the smaller footprint).
14
+
15
+ **Queue priority is lost on every reload.** A new slice goes to the back of
16
+ the queue at its price, which is the real cost of hiding size. The parent
17
+ records how many reloads it has done, so a caller can see what patience was
18
+ spent.
19
+
20
+ The price may follow the market or stay put: with `follow=True` the next
21
+ slice is priced at the touch when it is placed, otherwise at the price the
22
+ parent was given.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import random
27
+ from decimal import Decimal
28
+ from typing import Any
29
+
30
+ from ...trading.types import Fill, Order, OrderRequest, OrderType, Side, TimeInForce
31
+ from .base import ZERO, Child, Context, D, ManagedOrder
32
+ from .manager import register
33
+
34
+
35
+ @register
36
+ class Iceberg(ManagedOrder):
37
+ """`params`: `display`, `reload_delay_s`, `jitter_s`, `follow`."""
38
+
39
+ kind = "iceberg"
40
+ order_type = OrderType.ICEBERG
41
+
42
+ def __init__(self, *args: Any, **kwargs: Any):
43
+ super().__init__(*args, **kwargs)
44
+ self.display = D(self.params.get("display"))
45
+ if self.display is None or self.display <= 0:
46
+ raise ValueError("an iceberg needs params display: how many contracts to show at a time")
47
+ if self.request.price is None:
48
+ raise ValueError("an iceberg needs a price; a hidden market order is just a market order")
49
+ self.reload_delay_s = float(self.params.get("reload_delay_s") or 0)
50
+ self.jitter_s = float(self.params.get("jitter_s") or 0)
51
+ self.follow = bool(self.params.get("follow"))
52
+ self.reloads = 0
53
+ self.next_slice_at: float = 0.0
54
+
55
+ def slice_size(self) -> Decimal:
56
+ return min(self.display, self.remaining - self.working())
57
+
58
+ def working(self) -> Decimal:
59
+ """Contracts currently resting in slices."""
60
+ return sum((c.amount - c.filled for c in self.live_children), ZERO)
61
+
62
+ def slice_price(self, ctx: Context) -> Decimal | None:
63
+ if not self.follow:
64
+ return D(self.request.price)
65
+ touch = ctx.book() and (ctx.book().best_bid if self.side == Side.BUY else ctx.book().best_ask)
66
+ return touch or D(self.request.price)
67
+
68
+ async def start(self, ctx: Context) -> None:
69
+ self.state = "working"
70
+ await self.place(ctx)
71
+
72
+ async def place(self, ctx: Context) -> None:
73
+ size = self.slice_size()
74
+ if size <= 0:
75
+ return
76
+ price = self.slice_price(ctx)
77
+ request = self.child_request(amount=size, price=price, type=OrderType.LIMIT,
78
+ time_in_force=self.request.time_in_force or TimeInForce.GTC,
79
+ post_only=self.request.post_only, expires_at=self.request.expires_at)
80
+ order = await ctx.submit_child(request)
81
+ self.track(order, size, price)
82
+ await ctx.publish("managed.slice", {"amount": str(size), "price": str(price), "reloads": self.reloads})
83
+ await ctx.save()
84
+
85
+ async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None:
86
+ if await self.complete_if_done(ctx):
87
+ return
88
+ if child.filled >= child.amount:
89
+ self.schedule_reload(ctx)
90
+ await ctx.save()
91
+
92
+ async def on_child(self, ctx: Context, order: Order, child: Child) -> None:
93
+ if order.is_terminal and self.state == "working" and self.remaining > 0:
94
+ self.schedule_reload(ctx)
95
+ await self.complete_if_done(ctx)
96
+
97
+ def schedule_reload(self, ctx: Context) -> None:
98
+ delay = self.reload_delay_s + (random.uniform(0, self.jitter_s) if self.jitter_s else 0)
99
+ self.next_slice_at = ctx.now + delay
100
+
101
+ async def on_timer(self, ctx: Context) -> None:
102
+ if self.state != "working" or self.remaining <= 0:
103
+ return
104
+ if self.working() > 0 or ctx.now < self.next_slice_at:
105
+ return
106
+ self.reloads += 1
107
+ await self.place(ctx)
108
+
109
+ def extra(self) -> dict[str, Any]:
110
+ return {
111
+ "display": str(self.display), "reload_delay_s": self.reload_delay_s, "jitter_s": self.jitter_s,
112
+ "follow": self.follow, "reloads": self.reloads, "next_slice_at": self.next_slice_at,
113
+ }
114
+
115
+ def load_extra(self, extra: dict[str, Any]) -> None:
116
+ self.display = D(extra.get("display"), self.display)
117
+ self.reload_delay_s = float(extra.get("reload_delay_s") or 0)
118
+ self.jitter_s = float(extra.get("jitter_s") or 0)
119
+ self.follow = bool(extra.get("follow"))
120
+ self.reloads = int(extra.get("reloads") or 0)
121
+ self.next_slice_at = float(extra.get("next_slice_at") or 0)