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.
- synpath/__init__.py +183 -0
- synpath/__main__.py +66 -0
- synpath/base.py +723 -0
- synpath/bucket.py +154 -0
- synpath/client.py +356 -0
- synpath/engine/__init__.py +37 -0
- synpath/engine/__main__.py +354 -0
- synpath/engine/alerts.py +170 -0
- synpath/engine/engine.py +888 -0
- synpath/engine/eod.py +154 -0
- synpath/engine/events.py +140 -0
- synpath/engine/fair_values.py +117 -0
- synpath/engine/feeds.py +220 -0
- synpath/engine/journal.py +907 -0
- synpath/engine/ledger.py +353 -0
- synpath/engine/orders/__init__.py +42 -0
- synpath/engine/orders/base.py +441 -0
- synpath/engine/orders/day.py +72 -0
- synpath/engine/orders/iceberg.py +121 -0
- synpath/engine/orders/manager.py +223 -0
- synpath/engine/orders/oco.py +255 -0
- synpath/engine/orders/peg.py +168 -0
- synpath/engine/orders/routed.py +496 -0
- synpath/engine/orders/stop.py +240 -0
- synpath/engine/orders/taker.py +187 -0
- synpath/engine/orders/twap.py +190 -0
- synpath/engine/paper.py +532 -0
- synpath/engine/reconcile.py +279 -0
- synpath/engine/risk.py +403 -0
- synpath/engine/router.py +261 -0
- synpath/errors.py +98 -0
- synpath/history.py +71 -0
- synpath/hosted.py +86 -0
- synpath/hosted_auth.py +201 -0
- synpath/ids.py +61 -0
- synpath/kalshi.py +1378 -0
- synpath/matching.py +86 -0
- synpath/polymarket.py +1004 -0
- synpath/polymarket_us.py +989 -0
- synpath/remote.py +195 -0
- synpath/server/__init__.py +98 -0
- synpath/server/__main__.py +118 -0
- synpath/server/api.py +439 -0
- synpath/server/errors.py +87 -0
- synpath/server/local.py +96 -0
- synpath/server/models.py +75 -0
- synpath/server/serve.py +236 -0
- synpath/server/store.py +363 -0
- synpath/server/trading.py +764 -0
- synpath/trading/__init__.py +79 -0
- synpath/trading/__main__.py +69 -0
- synpath/trading/base.py +126 -0
- synpath/trading/credentials.py +400 -0
- synpath/trading/errors.py +94 -0
- synpath/trading/init.py +233 -0
- synpath/trading/instruments.py +162 -0
- synpath/trading/kalshi.py +957 -0
- synpath/trading/limiter.py +177 -0
- synpath/trading/money.py +172 -0
- synpath/trading/polymarket.py +1362 -0
- synpath/trading/polymarket_signing.py +478 -0
- synpath/trading/polymarket_us.py +705 -0
- synpath/trading/polymarket_us_exchange.py +825 -0
- synpath/trading/types.py +414 -0
- synpath/types.py +608 -0
- synpath/ws/__init__.py +55 -0
- synpath/ws/base.py +544 -0
- synpath/ws/grpc.py +578 -0
- synpath/ws/kalshi.py +418 -0
- synpath/ws/polymarket.py +430 -0
- synpath/ws/polymarket_us.py +299 -0
- synpath/ws/polymarket_us_exchange.py +754 -0
- synpath-0.1.0.dist-info/METADATA +224 -0
- synpath-0.1.0.dist-info/RECORD +77 -0
- synpath-0.1.0.dist-info/WHEEL +4 -0
- synpath-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|