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