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,240 @@
1
+ """Stops: stop-market, stop-limit, and trailing.
2
+
3
+ None of the three venues holds a stop on a prediction market, so the engine
4
+ watches the price and sends a child when the level is reached. What "the
5
+ price" means is the whole question, and the answer here is the touch on the
6
+ side that would fill you: a buy stop watches the best ask, a sell stop the
7
+ best bid.
8
+
9
+ Why not the last trade or the mid, which most venues use? A prediction
10
+ market's book is thin and its tape is slow. A market that last traded four
11
+ hours ago would leave a last-trade stop asleep through a move; a mid sitting
12
+ between a 0.30 bid and a 0.70 ask is a number nobody can trade at. The touch
13
+ is the price at which the triggered order could actually execute, so it is
14
+ the price the trigger should use. `trigger_source` takes `last` or `mid` for
15
+ callers who disagree, and says so in the journal either way.
16
+
17
+ **Trailing** ratchets: a sell stop follows the highest bid seen, a buy stop
18
+ the lowest ask, never the other way. The distance is `trail` in price units
19
+ or `trail_percent` of the watched price, and the current stop is persisted,
20
+ so a restart resumes from the level the market actually reached rather than
21
+ from where it started.
22
+
23
+ **Triggering is one-way.** Once fired, the child is out; a price that comes
24
+ back does not un-trigger it. A stop-market child is an immediate-or-cancel
25
+ limit at a protection price (the touch plus `max_slippage`, or the caller's
26
+ own), because no venue here accepts a market order without one.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ from decimal import Decimal
31
+ from typing import Any
32
+
33
+ from ...trading.types import Fill, Order, OrderRequest, OrderType, Side, TimeInForce
34
+ from .base import ZERO, Child, Context, D, ManagedOrder
35
+ from .manager import register
36
+
37
+ TRIGGER_SOURCES = ("touch", "last", "mid")
38
+
39
+
40
+ class _Stop(ManagedOrder):
41
+ """Shared machinery: watch a price, fire once, then work the child."""
42
+
43
+ default_slippage = Decimal("0.05")
44
+
45
+ def __init__(self, *args: Any, **kwargs: Any):
46
+ super().__init__(*args, **kwargs)
47
+ self.stop_price: Decimal = D(self.request.stop_price)
48
+ self.trigger_source: str = str(self.params.get("trigger_source") or "touch")
49
+ if self.trigger_source not in TRIGGER_SOURCES:
50
+ raise ValueError(f"trigger_source must be one of {TRIGGER_SOURCES}, not {self.trigger_source!r}")
51
+ self.triggered_at: int | None = None
52
+ self.trigger_price: Decimal | None = None
53
+
54
+ # -- the watched price ----------------------------------------------------
55
+
56
+ def watched(self, ctx: Context) -> Decimal | None:
57
+ if self.trigger_source == "touch":
58
+ return ctx.touch(self.side)
59
+ if self.trigger_source == "mid":
60
+ return ctx.mid()
61
+ return ctx.last()
62
+
63
+ def reached(self, price: Decimal) -> bool:
64
+ """A buy stop fires when the price rises to it, a sell stop when it falls."""
65
+ return price >= self.stop_price if self.side == Side.BUY else price <= self.stop_price
66
+
67
+ async def on_book(self, ctx: Context, market_id: str) -> None:
68
+ if self.trigger_source in ("touch", "mid"):
69
+ await self.consider(ctx)
70
+
71
+ async def on_trade(self, ctx: Context, market_id: str, price: Decimal, amount: Decimal) -> None:
72
+ if self.trigger_source == "last":
73
+ await self.consider(ctx, price)
74
+
75
+ async def consider(self, ctx: Context, price: Decimal | None = None) -> None:
76
+ if self.state != "waiting":
77
+ return
78
+ watched = price if price is not None else self.watched(ctx)
79
+ if watched is None:
80
+ return
81
+ await self.adjust(ctx, watched)
82
+ if self.reached(watched):
83
+ await self.trigger(ctx, watched)
84
+
85
+ async def adjust(self, ctx: Context, watched: Decimal) -> None:
86
+ """Trailing overrides this; a fixed stop does not move."""
87
+
88
+ # -- firing ---------------------------------------------------------------
89
+
90
+ async def trigger(self, ctx: Context, watched: Decimal) -> None:
91
+ self.state = "working"
92
+ self.triggered_at = int(ctx.now * 1000)
93
+ self.trigger_price = watched
94
+ await ctx.publish("managed.triggered", {
95
+ "kind": self.kind, "stop_price": str(self.stop_price), "trigger_price": str(watched),
96
+ "trigger_source": self.trigger_source,
97
+ })
98
+ await self.send_child(ctx, watched)
99
+
100
+ async def send_child(self, ctx: Context, watched: Decimal) -> None:
101
+ raise NotImplementedError
102
+
103
+ def protection(self, ctx: Context, watched: Decimal) -> Decimal | None:
104
+ """The worst price the triggered order may pay."""
105
+ given = D(self.params.get("protection"))
106
+ if given is not None:
107
+ return given
108
+ slippage = D(self.params.get("max_slippage"), self.default_slippage)
109
+ touch = ctx.touch(self.side)
110
+ if touch is None:
111
+ # No book to price against. A last-trade trigger can fire on a
112
+ # market nobody is quoting, and an order sent then would take
113
+ # whatever appears.
114
+ return None
115
+ return touch + slippage if self.side == Side.BUY else touch - slippage
116
+
117
+ async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None:
118
+ await self.complete_if_done(ctx)
119
+
120
+ async def on_child(self, ctx: Context, order: Order, child: Child) -> None:
121
+ if not order.is_terminal or self.state != "working":
122
+ return
123
+ if self.remaining <= 0:
124
+ await self.finish(ctx, "done", "filled")
125
+ elif not self.live_children:
126
+ # The child ended without finishing the job: say so rather than
127
+ # quietly leaving a stop that has already fired.
128
+ await self.finish(ctx, "done", f"child {order.status.value} with {self.remaining} unfilled")
129
+
130
+ # -- persistence ----------------------------------------------------------
131
+
132
+ def extra(self) -> dict[str, Any]:
133
+ return {
134
+ "stop_price": str(self.stop_price), "trigger_source": self.trigger_source,
135
+ "triggered_at": self.triggered_at,
136
+ "trigger_price": str(self.trigger_price) if self.trigger_price is not None else None,
137
+ }
138
+
139
+ def load_extra(self, extra: dict[str, Any]) -> None:
140
+ self.stop_price = D(extra.get("stop_price"), self.stop_price)
141
+ self.trigger_source = extra.get("trigger_source", self.trigger_source)
142
+ self.triggered_at = extra.get("triggered_at")
143
+ self.trigger_price = D(extra.get("trigger_price"))
144
+
145
+
146
+ @register
147
+ class StopMarket(_Stop):
148
+ """Fires an immediate-or-cancel child at the protection price."""
149
+
150
+ kind = "stop_market"
151
+ order_type = OrderType.STOP_MARKET
152
+
153
+ async def send_child(self, ctx: Context, watched: Decimal) -> None:
154
+ price = self.protection(ctx, watched)
155
+ if price is None:
156
+ await self.finish(ctx, "rejected", "no book and no protection price: refusing to send at any price")
157
+ return
158
+ request = self.child_request(amount=self.remaining, price=price, type=OrderType.MARKET,
159
+ time_in_force=TimeInForce.IOC)
160
+ order = await ctx.submit_child(request)
161
+ self.track(order, request.amount, price)
162
+ await ctx.save()
163
+
164
+
165
+ @register
166
+ class StopLimit(_Stop):
167
+ """Fires a limit child at the price the caller named."""
168
+
169
+ kind = "stop_limit"
170
+ order_type = OrderType.STOP_LIMIT
171
+
172
+ async def send_child(self, ctx: Context, watched: Decimal) -> None:
173
+ price = D(self.request.price)
174
+ request = self.child_request(amount=self.remaining, price=price, type=OrderType.LIMIT,
175
+ time_in_force=self.request.time_in_force or TimeInForce.GTC,
176
+ expires_at=self.request.expires_at)
177
+ order = await ctx.submit_child(request)
178
+ self.track(order, request.amount, price)
179
+ await ctx.save()
180
+
181
+
182
+ @register
183
+ class TrailingStop(_Stop):
184
+ """A stop that follows the market one way and never the other."""
185
+
186
+ kind = "trailing_stop"
187
+ order_type = OrderType.TRAILING_STOP
188
+
189
+ def __init__(self, *args: Any, **kwargs: Any):
190
+ super().__init__(*args, **kwargs)
191
+ self.trail = D(self.params.get("trail"))
192
+ self.trail_percent = D(self.params.get("trail_percent"))
193
+ if self.trail is None and self.trail_percent is None:
194
+ raise ValueError("a trailing stop needs params trail or trail_percent")
195
+ self.extreme: Decimal | None = None
196
+ """The best price seen: the highest for a sell stop, the lowest for a buy."""
197
+
198
+ def distance(self, watched: Decimal) -> Decimal:
199
+ if self.trail is not None:
200
+ return self.trail
201
+ return watched * self.trail_percent / Decimal("100")
202
+
203
+ async def adjust(self, ctx: Context, watched: Decimal) -> None:
204
+ improved = (
205
+ self.extreme is None
206
+ or (self.side == Side.SELL and watched > self.extreme)
207
+ or (self.side == Side.BUY and watched < self.extreme)
208
+ )
209
+ if not improved:
210
+ return
211
+ self.extreme = watched
212
+ distance = self.distance(watched)
213
+ moved = watched - distance if self.side == Side.SELL else watched + distance
214
+ # The stop only ever tightens towards the market.
215
+ if self.stop_price is None or (moved > self.stop_price if self.side == Side.SELL else moved < self.stop_price):
216
+ self.stop_price = moved
217
+ await ctx.publish("managed.trailed", {"stop_price": str(moved), "watched": str(watched)})
218
+ await ctx.save()
219
+
220
+ async def send_child(self, ctx: Context, watched: Decimal) -> None:
221
+ price = D(self.request.price) or self.protection(ctx, watched)
222
+ kind = OrderType.LIMIT if self.request.price is not None else OrderType.MARKET
223
+ request = self.child_request(amount=self.remaining, price=price, type=kind,
224
+ time_in_force=TimeInForce.IOC if kind == OrderType.MARKET else TimeInForce.GTC)
225
+ order = await ctx.submit_child(request)
226
+ self.track(order, request.amount, price)
227
+ await ctx.save()
228
+
229
+ def extra(self) -> dict[str, Any]:
230
+ return super().extra() | {
231
+ "trail": str(self.trail) if self.trail is not None else None,
232
+ "trail_percent": str(self.trail_percent) if self.trail_percent is not None else None,
233
+ "extreme": str(self.extreme) if self.extreme is not None else None,
234
+ }
235
+
236
+ def load_extra(self, extra: dict[str, Any]) -> None:
237
+ super().load_extra(extra)
238
+ self.trail = D(extra.get("trail"))
239
+ self.trail_percent = D(extra.get("trail_percent"))
240
+ self.extreme = D(extra.get("extreme"))
@@ -0,0 +1,187 @@
1
+ """Taking liquidity: the market order these venues do not have, and a patient one.
2
+
3
+ **Market.** None of the three venues holds a market order: Kalshi's V2 has
4
+ only limits, and the other two turn one into an immediate limit. Sending a
5
+ single limit at the far touch fills what is there and cancels the rest, which
6
+ is not what "market" means either. So the engine walks the book: an
7
+ immediate-or-cancel clip at each level in turn, stopping at the caller's
8
+ protection price or when `max_slippage` from the first touch is spent. What
9
+ it cannot buy within that range it reports unfilled, because the alternative
10
+ is paying any price at all.
11
+
12
+ **Smart taker** is the same walk with patience: clips of `clip` contracts,
13
+ `interval_s` apart, up to `limit`. It exists because taking two hundred
14
+ contracts in one clip pays for depth that would have refilled in thirty
15
+ seconds. Between clips it does nothing, which is the point; if the market
16
+ improves it takes the better price, and if it runs away it stops at the
17
+ limit.
18
+
19
+ Both count what the book actually offered, so a caller can tell a fill that
20
+ cost slippage from one that did not.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ from decimal import Decimal
25
+ from typing import Any
26
+
27
+ from ...trading.types import Fill, Order, OrderRequest, OrderType, Side, TimeInForce
28
+ from .base import ZERO, Child, Context, D, ManagedOrder
29
+ from .manager import register
30
+
31
+
32
+ class _Taker(ManagedOrder):
33
+ """Shared: a price bound, a clip, and one live child at a time."""
34
+
35
+ default_slippage = Decimal("0.05")
36
+
37
+ def __init__(self, *args: Any, **kwargs: Any):
38
+ super().__init__(*args, **kwargs)
39
+ self.limit = D(self.params.get("limit")) or D(self.request.price)
40
+ self.max_slippage = D(self.params.get("max_slippage"), self.default_slippage)
41
+ self.first_touch: Decimal | None = None
42
+ self.clips = 0
43
+ self.next_clip_at: float = 0.0
44
+ self.paid: Decimal = ZERO
45
+
46
+ def bound(self, ctx: Context) -> Decimal | None:
47
+ """The worst price this order may pay, from the limit or the slippage."""
48
+ if self.limit is not None:
49
+ return self.limit
50
+ if self.first_touch is None:
51
+ self.first_touch = ctx.touch(self.side)
52
+ if self.first_touch is None:
53
+ return None
54
+ return (self.first_touch + self.max_slippage) if self.side == Side.BUY else (self.first_touch - self.max_slippage)
55
+
56
+ def affordable(self, ctx: Context) -> tuple[Decimal, Decimal] | None:
57
+ """How much of the far side is inside the bound, and at what price."""
58
+ bound = self.bound(ctx)
59
+ if bound is None:
60
+ return None
61
+ available = ZERO
62
+ worst: Decimal | None = None
63
+ for price, size in ctx.levels(self.side):
64
+ if (self.side == Side.BUY and price > bound) or (self.side == Side.SELL and price < bound):
65
+ break
66
+ available += size
67
+ worst = price
68
+ if available <= 0 or worst is None:
69
+ return None
70
+ return available, bound
71
+
72
+ async def take(self, ctx: Context, size: Decimal) -> bool:
73
+ """One immediate-or-cancel clip. `False` when there is nothing to take."""
74
+ offered = self.affordable(ctx)
75
+ if offered is None:
76
+ return False
77
+ available, bound = offered
78
+ amount = min(size, available, self.remaining)
79
+ if amount <= 0:
80
+ return False
81
+ request = self.child_request(amount=amount, price=bound, type=OrderType.MARKET,
82
+ time_in_force=TimeInForce.IOC)
83
+ order = await ctx.submit_child(request)
84
+ self.track(order, amount, bound)
85
+ self.clips += 1
86
+ await ctx.publish("managed.clip", {"amount": str(amount), "bound": str(bound), "clip": self.clips})
87
+ await ctx.save()
88
+ return True
89
+
90
+ async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None:
91
+ self.paid += fill.price * fill.amount
92
+ await self.complete_if_done(ctx)
93
+
94
+ def extra(self) -> dict[str, Any]:
95
+ return {
96
+ "limit": str(self.limit) if self.limit is not None else None,
97
+ "max_slippage": str(self.max_slippage),
98
+ "first_touch": str(self.first_touch) if self.first_touch is not None else None,
99
+ "clips": self.clips, "next_clip_at": self.next_clip_at, "paid": str(self.paid),
100
+ }
101
+
102
+ def load_extra(self, extra: dict[str, Any]) -> None:
103
+ self.limit = D(extra.get("limit"))
104
+ self.max_slippage = D(extra.get("max_slippage"), self.default_slippage)
105
+ self.first_touch = D(extra.get("first_touch"))
106
+ self.clips = int(extra.get("clips") or 0)
107
+ self.next_clip_at = float(extra.get("next_clip_at") or 0)
108
+ self.paid = D(extra.get("paid"), ZERO)
109
+
110
+
111
+ @register
112
+ class MarketOrder(_Taker):
113
+ """A market order the engine holds: walk the book inside the bound, now."""
114
+
115
+ kind = "market_engine"
116
+ order_type = OrderType.MARKET
117
+
118
+ async def start(self, ctx: Context) -> None:
119
+ self.state = "working"
120
+ if not await self.take(ctx, self.remaining):
121
+ await self.finish(ctx, "canceled", "nothing on the book inside the price bound")
122
+
123
+ async def on_child(self, ctx: Context, order: Order, child: Child) -> None:
124
+ if self.state != "working" or not order.is_terminal:
125
+ return
126
+ if self.remaining <= 0:
127
+ await self.finish(ctx, "done", "filled")
128
+ return
129
+ # The clip took what was there; try the next level, once.
130
+ if not await self.take(ctx, self.remaining):
131
+ await self.finish(ctx, "done" if self.filled else "canceled",
132
+ f"stopped with {self.remaining} unfilled: the book ran out inside the bound")
133
+
134
+
135
+ @register
136
+ class SmartTaker(_Taker):
137
+ """`params`: `clip`, `interval_s`, `limit`, `max_slippage`, `expires_s`."""
138
+
139
+ kind = "smart_taker"
140
+ order_type = OrderType.SMART_TAKER
141
+
142
+ def __init__(self, *args: Any, **kwargs: Any):
143
+ super().__init__(*args, **kwargs)
144
+ self.clip = D(self.params.get("clip")) or self.amount
145
+ self.interval_s = float(self.params.get("interval_s") or 1.0)
146
+ self.expires_s = float(self.params.get("expires_s") or 0)
147
+ self.started_at: float = 0.0
148
+
149
+ async def start(self, ctx: Context) -> None:
150
+ self.state = "working"
151
+ self.started_at = ctx.now
152
+ await self.clip_now(ctx)
153
+
154
+ async def clip_now(self, ctx: Context) -> None:
155
+ if await self.take(ctx, self.clip):
156
+ self.next_clip_at = ctx.now + self.interval_s
157
+ else:
158
+ # Nothing inside the bound: wait and look again.
159
+ self.next_clip_at = ctx.now + self.interval_s
160
+
161
+ async def on_timer(self, ctx: Context) -> None:
162
+ if self.state != "working":
163
+ return
164
+ if self.expires_s and ctx.now - self.started_at >= self.expires_s:
165
+ await self.finish(ctx, "done" if self.filled else "canceled",
166
+ f"expired with {self.remaining} unfilled")
167
+ return
168
+ if self.live_children or ctx.now < self.next_clip_at or self.remaining <= 0:
169
+ return
170
+ await self.clip_now(ctx)
171
+
172
+ async def on_child(self, ctx: Context, order: Order, child: Child) -> None:
173
+ if self.state == "working" and self.remaining <= 0 and not self.live_children:
174
+ await self.finish(ctx, "done", "filled")
175
+
176
+ def extra(self) -> dict[str, Any]:
177
+ return super().extra() | {
178
+ "clip": str(self.clip), "interval_s": self.interval_s, "expires_s": self.expires_s,
179
+ "started_at": self.started_at,
180
+ }
181
+
182
+ def load_extra(self, extra: dict[str, Any]) -> None:
183
+ super().load_extra(extra)
184
+ self.clip = D(extra.get("clip"), self.amount)
185
+ self.interval_s = float(extra.get("interval_s") or 1.0)
186
+ self.expires_s = float(extra.get("expires_s") or 0)
187
+ self.started_at = float(extra.get("started_at") or 0)
@@ -0,0 +1,190 @@
1
+ """TWAP: the same order, spread over time.
2
+
3
+ Buying two hundred contracts at once on a book with forty at the touch pays
4
+ for the privilege. A TWAP cuts the order into slices and works them across a
5
+ window, so the average price is closer to the market's own average than to
6
+ the depth of one moment.
7
+
8
+ How it decides what to do, each slice:
9
+
10
+ * **The schedule is by clock, not by fill.** Slices are due at even
11
+ intervals across the window. A slice that misses its turn is not skipped;
12
+ the next one carries what is behind, so the order still finishes on time.
13
+ * **Patient or aggressive is a choice.** `style="limit"` posts at the near
14
+ touch and lets the market come; `style="taker"` crosses with an
15
+ immediate-or-cancel at the far touch. A limit slice that is still resting
16
+ when the next one is due is pulled first, so the order cannot end up with
17
+ five stale slices in the book.
18
+ * **The end is respected.** With `finish="complete"` the remainder is taken
19
+ at the end of the window; with `finish="stop"` whatever is unfilled is
20
+ abandoned, which is what a caller who was only ever price-sensitive wants.
21
+
22
+ A restart resumes the schedule from the clock, because the window's start and
23
+ end are persisted; the engine does not need to remember how many timers it
24
+ had running.
25
+ """
26
+ from __future__ import annotations
27
+
28
+ from decimal import Decimal
29
+ from typing import Any
30
+
31
+ from ...trading.types import Fill, Order, OrderRequest, OrderType, Side, TimeInForce
32
+ from .base import ZERO, Child, Context, D, ManagedOrder
33
+ from .manager import register
34
+
35
+
36
+ @register
37
+ class TWAP(ManagedOrder):
38
+ """`params`: `window_s`, `slices`, `style`, `limit`, `finish`."""
39
+
40
+ kind = "twap"
41
+ order_type = OrderType.TWAP
42
+
43
+ def __init__(self, *args: Any, **kwargs: Any):
44
+ super().__init__(*args, **kwargs)
45
+ self.window_s = float(self.params.get("window_s") or 0)
46
+ self.slices = int(self.params.get("slices") or 0)
47
+ if self.window_s <= 0 or self.slices <= 0:
48
+ raise ValueError("a TWAP needs params window_s and slices, both above zero")
49
+ self.style = str(self.params.get("style") or "limit")
50
+ if self.style not in ("limit", "taker"):
51
+ raise ValueError("a TWAP's style is 'limit' or 'taker'")
52
+ self.limit = D(self.params.get("limit")) or D(self.request.price)
53
+ """The worst price any slice may pay. `None` means the market's."""
54
+ self.at_end = str(self.params.get("finish") or "complete")
55
+ """What the end of the window does with anything unfilled."""
56
+ self.started_at: float = 0.0
57
+ self.sent_slices = 0
58
+
59
+ # -- the schedule ---------------------------------------------------------
60
+
61
+ @property
62
+ def interval(self) -> float:
63
+ return self.window_s / self.slices
64
+
65
+ def due_by(self, now: float) -> int:
66
+ """How many slices should have gone out by now."""
67
+ if self.started_at <= 0:
68
+ return 0
69
+ elapsed = max(0.0, now - self.started_at)
70
+ return min(self.slices, int(elapsed // self.interval) + 1)
71
+
72
+ def slice_size(self) -> Decimal:
73
+ per_slice = self.amount / self.slices
74
+ return min(per_slice, self.remaining)
75
+
76
+ def ends_at(self) -> float:
77
+ return self.started_at + self.window_s
78
+
79
+ # -- running --------------------------------------------------------------
80
+
81
+ async def start(self, ctx: Context) -> None:
82
+ self.state = "working"
83
+ self.started_at = ctx.now
84
+ await self.work(ctx)
85
+
86
+ async def on_timer(self, ctx: Context) -> None:
87
+ if self.state == "working":
88
+ await self.work(ctx)
89
+
90
+ async def work(self, ctx: Context) -> None:
91
+ if self.remaining <= 0:
92
+ await self.complete_if_done(ctx)
93
+ return
94
+ now = ctx.now
95
+ if now >= self.ends_at():
96
+ await self.close_out(ctx)
97
+ return
98
+ due = self.due_by(now)
99
+ if self.sent_slices >= due:
100
+ return
101
+ # A limit slice still resting when the next is due has had its turn.
102
+ for child in self.live_children:
103
+ await ctx.cancel_child(child.order_id)
104
+ child.status = "canceled"
105
+ behind = due - self.sent_slices
106
+ size = min(self.remaining, self.slice_size() * behind)
107
+ if size <= 0:
108
+ return
109
+ await self.send(ctx, size)
110
+ self.sent_slices = due
111
+ await ctx.save()
112
+
113
+ async def send(self, ctx: Context, size: Decimal) -> None:
114
+ if self.style == "taker":
115
+ price = ctx.touch(self.side) or self.limit
116
+ if self.limit is not None and price is not None:
117
+ price = min(price, self.limit) if self.side == Side.BUY else max(price, self.limit)
118
+ if price is None:
119
+ return
120
+ request = self.child_request(amount=size, price=price, type=OrderType.MARKET,
121
+ time_in_force=TimeInForce.IOC)
122
+ else:
123
+ near = ctx.book()
124
+ price = None
125
+ if near is not None:
126
+ price = near.best_bid if self.side == Side.BUY else near.best_ask
127
+ price = price or self.limit
128
+ if price is None:
129
+ return
130
+ if self.limit is not None:
131
+ price = min(price, self.limit) if self.side == Side.BUY else max(price, self.limit)
132
+ request = self.child_request(amount=size, price=price, type=OrderType.LIMIT,
133
+ time_in_force=TimeInForce.GTC, post_only=self.request.post_only)
134
+ order = await ctx.submit_child(request)
135
+ self.track(order, size, D(request.price))
136
+ await ctx.publish("managed.slice", {"amount": str(size), "price": str(request.price),
137
+ "slice": self.sent_slices + 1, "of": self.slices, "style": self.style})
138
+
139
+ async def close_out(self, ctx: Context) -> None:
140
+ """The window is over."""
141
+ for child in self.live_children:
142
+ await ctx.cancel_child(child.order_id)
143
+ child.status = "canceled"
144
+ if self.remaining > 0 and self.at_end == "complete":
145
+ price = ctx.touch(self.side) or self.limit
146
+ if price is not None:
147
+ if self.limit is not None:
148
+ price = min(price, self.limit) if self.side == Side.BUY else max(price, self.limit)
149
+ request = self.child_request(amount=self.remaining, price=price, type=OrderType.MARKET,
150
+ time_in_force=TimeInForce.IOC)
151
+ order = await ctx.submit_child(request)
152
+ self.track(order, request.amount, price)
153
+ await ctx.publish("managed.close_out", {"amount": str(request.amount), "price": str(price)})
154
+ await ctx.save()
155
+ return
156
+ await self.finish_window(ctx)
157
+
158
+ async def finish_window(self, ctx: Context) -> None:
159
+ if self.remaining <= 0:
160
+ await self.finish(ctx, "done", "filled")
161
+ else:
162
+ await self.finish(ctx, "done" if self.filled > 0 else "canceled",
163
+ f"the window ended with {self.remaining} unfilled")
164
+
165
+ async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None:
166
+ await self.complete_if_done(ctx)
167
+
168
+ async def on_child(self, ctx: Context, order: Order, child: Child) -> None:
169
+ if self.state != "working" or not order.is_terminal:
170
+ return
171
+ if self.remaining <= 0:
172
+ await self.finish(ctx, "done", "filled")
173
+ elif ctx.now >= self.ends_at() and not self.live_children:
174
+ await self.finish_window(ctx)
175
+
176
+ def extra(self) -> dict[str, Any]:
177
+ return {
178
+ "window_s": self.window_s, "slices": self.slices, "style": self.style, "finish": self.at_end,
179
+ "limit": str(self.limit) if self.limit is not None else None,
180
+ "started_at": self.started_at, "sent_slices": self.sent_slices,
181
+ }
182
+
183
+ def load_extra(self, extra: dict[str, Any]) -> None:
184
+ self.window_s = float(extra.get("window_s") or self.window_s)
185
+ self.slices = int(extra.get("slices") or self.slices)
186
+ self.style = extra.get("style", self.style)
187
+ self.at_end = extra.get("finish", self.at_end)
188
+ self.limit = D(extra.get("limit"))
189
+ self.started_at = float(extra.get("started_at") or 0)
190
+ self.sent_slices = int(extra.get("sent_slices") or 0)