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,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)
|