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,223 @@
|
|
|
1
|
+
"""The supervisor: every engine-held order, and what reaches it.
|
|
2
|
+
|
|
3
|
+
One `ManagedOrders` per engine. It creates parents, hands them the events
|
|
4
|
+
they asked for, writes their state down whenever it changes, and brings them
|
|
5
|
+
back after a restart. Three details are worth stating, because they are what
|
|
6
|
+
makes an engine-held order safe rather than a background task:
|
|
7
|
+
|
|
8
|
+
**Every change is persisted before the next one.** A stop that has just
|
|
9
|
+
triggered is written as triggered before its child is sent, so a crash in
|
|
10
|
+
between leaves a parent whose child is in doubt, which the engine's own
|
|
11
|
+
recovery already knows how to resolve.
|
|
12
|
+
|
|
13
|
+
**Events are routed, not broadcast.** A parent hears about its own children
|
|
14
|
+
and about the instruments it named. A thousand resting parents on other
|
|
15
|
+
markets cost nothing when a book ticks.
|
|
16
|
+
|
|
17
|
+
**One bad parent does not stop the rest.** A hook that raises is logged, the
|
|
18
|
+
parent is marked rejected with the reason, and every other parent carries on.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import logging
|
|
24
|
+
from decimal import Decimal
|
|
25
|
+
from typing import Any, Iterable, Mapping, Type
|
|
26
|
+
|
|
27
|
+
from ...trading.types import Fill, Order, OrderRequest, OrderType
|
|
28
|
+
from .base import Child, Context, ManagedOrder, State, now_ms
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger("synpath.engine.orders")
|
|
31
|
+
|
|
32
|
+
REGISTRY: dict[str, Type[ManagedOrder]] = {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def register(cls: Type[ManagedOrder]) -> Type[ManagedOrder]:
|
|
36
|
+
"""Make a managed order type known to `submit` and to recovery."""
|
|
37
|
+
REGISTRY[cls.kind] = cls
|
|
38
|
+
return cls
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ManagedOrders:
|
|
42
|
+
"""Every engine-held order this engine is running."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, engine: Any):
|
|
45
|
+
self.engine = engine
|
|
46
|
+
self.parents: dict[str, ManagedOrder] = {}
|
|
47
|
+
self.by_child: dict[str, str] = {}
|
|
48
|
+
"""Child order id to parent id."""
|
|
49
|
+
self.by_market: dict[str, set[str]] = {}
|
|
50
|
+
|
|
51
|
+
# -- lookup ---------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def live(self) -> list[ManagedOrder]:
|
|
54
|
+
return [p for p in self.parents.values() if p.live]
|
|
55
|
+
|
|
56
|
+
def get(self, parent_id: str) -> ManagedOrder | None:
|
|
57
|
+
return self.parents.get(parent_id)
|
|
58
|
+
|
|
59
|
+
def parent_of(self, order_id: str, venue: str | None = None) -> ManagedOrder | None:
|
|
60
|
+
parent_id = (self.by_child.get(f"{venue}:{order_id}") if venue else None) or self.by_child.get(order_id)
|
|
61
|
+
return self.parents.get(parent_id) if parent_id else None
|
|
62
|
+
|
|
63
|
+
def adopt_child(self, parent_id: str, order_id: str, venue: str | None = None) -> None:
|
|
64
|
+
self.by_child[order_id] = parent_id
|
|
65
|
+
if venue:
|
|
66
|
+
self.by_child[f"{venue}:{order_id}"] = parent_id
|
|
67
|
+
|
|
68
|
+
def context(self, parent: ManagedOrder) -> Context:
|
|
69
|
+
return Context(self.engine, parent)
|
|
70
|
+
|
|
71
|
+
def watching(self, market_id: str) -> list[ManagedOrder]:
|
|
72
|
+
return [self.parents[pid] for pid in self.by_market.get(market_id, set())
|
|
73
|
+
if pid in self.parents and self.parents[pid].live]
|
|
74
|
+
|
|
75
|
+
# -- creating -------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def build(self, request: OrderRequest, *, venue: str, account: Any) -> ManagedOrder:
|
|
78
|
+
# `managed_kind` lets one venue type map to an engine-held variant:
|
|
79
|
+
# a market order the engine walks the book with, rather than the
|
|
80
|
+
# adapter's single immediate limit.
|
|
81
|
+
kind = str(request.params.get("managed_kind") or request.type.value)
|
|
82
|
+
cls = REGISTRY.get(kind)
|
|
83
|
+
if cls is None:
|
|
84
|
+
raise NotImplementedError(
|
|
85
|
+
f"{kind} is an engine-held order type with no implementation loaded; "
|
|
86
|
+
f"known: {sorted(REGISTRY)}"
|
|
87
|
+
)
|
|
88
|
+
return cls(request, venue=venue, account=account)
|
|
89
|
+
|
|
90
|
+
async def create(self, request: OrderRequest, *, venue: str, account: Any, owner: ManagedOrder | None = None) -> ManagedOrder:
|
|
91
|
+
parent = self.build(request, venue=venue, account=account)
|
|
92
|
+
parent.owner_id = owner.id if owner else None
|
|
93
|
+
self.remember(parent)
|
|
94
|
+
if owner is not None:
|
|
95
|
+
self.by_child[parent.id] = owner.id
|
|
96
|
+
await self.save(parent)
|
|
97
|
+
await self.engine.bus.publish("managed.created", {
|
|
98
|
+
"parent_id": parent.id, "kind": parent.kind, "market_id": parent.market_id,
|
|
99
|
+
"side": parent.side.value, "amount": str(parent.amount), "book": request.book,
|
|
100
|
+
}, key=parent.id)
|
|
101
|
+
await self.run(parent, lambda ctx: parent.start(ctx))
|
|
102
|
+
return parent
|
|
103
|
+
|
|
104
|
+
def remember(self, parent: ManagedOrder) -> None:
|
|
105
|
+
self.parents[parent.id] = parent
|
|
106
|
+
for instrument in parent.markets():
|
|
107
|
+
self.by_market.setdefault(instrument, set()).add(parent.id)
|
|
108
|
+
for child in parent.children:
|
|
109
|
+
self.adopt_child(parent.id, child.order_id, child.venue)
|
|
110
|
+
|
|
111
|
+
def forget(self, parent: ManagedOrder) -> None:
|
|
112
|
+
for instrument in parent.markets():
|
|
113
|
+
self.by_market.get(instrument, set()).discard(parent.id)
|
|
114
|
+
|
|
115
|
+
# -- routing --------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
async def run(self, parent: ManagedOrder, call: Any) -> None:
|
|
118
|
+
"""Run one hook, catching what it raises so the others keep working."""
|
|
119
|
+
ctx = self.context(parent)
|
|
120
|
+
try:
|
|
121
|
+
await call(ctx)
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
log.exception("synpath.engine.orders: %s %s failed", parent.kind, parent.id)
|
|
124
|
+
parent.detail = f"{type(exc).__name__}: {exc}"
|
|
125
|
+
try:
|
|
126
|
+
await parent.finish(ctx, "rejected", parent.detail)
|
|
127
|
+
except Exception: # pragma: no cover - the journal is the only thing left
|
|
128
|
+
log.exception("synpath.engine.orders: could not record the failure of %s", parent.id)
|
|
129
|
+
finally:
|
|
130
|
+
if not parent.live:
|
|
131
|
+
self.forget(parent)
|
|
132
|
+
await self.save(parent)
|
|
133
|
+
if not parent.live and parent.owner_id:
|
|
134
|
+
await self.tell_owner(parent)
|
|
135
|
+
|
|
136
|
+
async def tell_owner(self, leg: ManagedOrder) -> None:
|
|
137
|
+
"""A leg of a bracket or an OCO ended: its owner decides what that means."""
|
|
138
|
+
owner = self.parents.get(leg.owner_id or "")
|
|
139
|
+
if owner is None or not owner.live:
|
|
140
|
+
return
|
|
141
|
+
child = owner.child_of(leg.id)
|
|
142
|
+
if child is None:
|
|
143
|
+
return
|
|
144
|
+
child.status = leg.state
|
|
145
|
+
child.filled = leg.filled
|
|
146
|
+
await self.run(owner, lambda ctx: owner.on_child(ctx, leg.as_order(), child))
|
|
147
|
+
|
|
148
|
+
async def on_book(self, market_id: str) -> None:
|
|
149
|
+
for parent in self.watching(market_id):
|
|
150
|
+
await self.run(parent, lambda ctx, p=parent: p.on_book(ctx, market_id))
|
|
151
|
+
|
|
152
|
+
async def on_trade(self, market_id: str, price: Decimal, amount: Decimal) -> None:
|
|
153
|
+
for parent in self.watching(market_id):
|
|
154
|
+
await self.run(parent, lambda ctx, p=parent: p.on_trade(ctx, market_id, price, amount))
|
|
155
|
+
|
|
156
|
+
async def on_order(self, order: Order) -> None:
|
|
157
|
+
"""The venue's record of a child changed. This is the one channel a
|
|
158
|
+
parent learns its children's fills from: the record is a snapshot,
|
|
159
|
+
the latest overwrites, and the progress since the last one drives
|
|
160
|
+
`on_fill`. Fill events are the ledger's and never reach a parent."""
|
|
161
|
+
parent = self.parent_of(order.id, order.venue)
|
|
162
|
+
if parent is None:
|
|
163
|
+
return
|
|
164
|
+
child = parent.child_of(order.id, order.venue)
|
|
165
|
+
if child is None:
|
|
166
|
+
return
|
|
167
|
+
progress = parent.note_order(order, child)
|
|
168
|
+
if progress is not None:
|
|
169
|
+
await self.run(parent, lambda ctx: parent.on_fill(ctx, progress, child))
|
|
170
|
+
await self.run(parent, lambda ctx: parent.on_child(ctx, order, child))
|
|
171
|
+
|
|
172
|
+
async def on_timer(self) -> None:
|
|
173
|
+
now_ms = int(self.engine.clock() * 1000)
|
|
174
|
+
for parent in self.live():
|
|
175
|
+
expires_at = parent.request.expires_at
|
|
176
|
+
if expires_at is not None and now_ms >= expires_at:
|
|
177
|
+
# The parent's own expiry: the whole order ends, every live
|
|
178
|
+
# child pulled, whatever its type does on a timer.
|
|
179
|
+
await self.cancel(parent.id, reason="expired")
|
|
180
|
+
continue
|
|
181
|
+
await self.run(parent, lambda ctx, p=parent: p.on_timer(ctx))
|
|
182
|
+
|
|
183
|
+
async def on_halt(self, reason: str, *, scope: str = "*") -> int:
|
|
184
|
+
halted = 0
|
|
185
|
+
for parent in self.live():
|
|
186
|
+
if scope != "*" and scope not in parent.venues():
|
|
187
|
+
continue
|
|
188
|
+
await self.run(parent, lambda ctx, p=parent: p.on_halt(ctx, reason))
|
|
189
|
+
halted += 1
|
|
190
|
+
return halted
|
|
191
|
+
|
|
192
|
+
async def cancel(self, parent_id: str, *, reason: str = "cancelled by the caller") -> ManagedOrder:
|
|
193
|
+
parent = self.parents.get(parent_id)
|
|
194
|
+
if parent is None:
|
|
195
|
+
raise KeyError(f"no engine-held order {parent_id!r}")
|
|
196
|
+
await self.run(parent, lambda ctx: parent.cancel(ctx, reason))
|
|
197
|
+
return parent
|
|
198
|
+
|
|
199
|
+
# -- persistence ----------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
async def save(self, parent: ManagedOrder) -> None:
|
|
202
|
+
for child in parent.children:
|
|
203
|
+
self.by_child.setdefault(child.order_id, parent.id)
|
|
204
|
+
self.by_child.setdefault(f"{child.venue}:{child.order_id}", parent.id)
|
|
205
|
+
await self.engine.journal.save_managed(parent.snapshot())
|
|
206
|
+
await self.engine.journal.upsert_order(parent.as_order(), event=f"managed.{parent.state}")
|
|
207
|
+
self.engine._remember(parent.as_order())
|
|
208
|
+
|
|
209
|
+
async def restore(self) -> int:
|
|
210
|
+
"""Bring back every parent that was still live, after a restart."""
|
|
211
|
+
restored = 0
|
|
212
|
+
for snapshot in await self.engine.journal.managed(states=("waiting", "working", "cancelling")):
|
|
213
|
+
cls = REGISTRY.get(snapshot.get("kind", ""))
|
|
214
|
+
if cls is None:
|
|
215
|
+
log.warning("synpath.engine.orders: no implementation for %s; leaving %s alone",
|
|
216
|
+
snapshot.get("kind"), snapshot.get("id"))
|
|
217
|
+
continue
|
|
218
|
+
parent = cls.from_snapshot(snapshot)
|
|
219
|
+
self.remember(parent)
|
|
220
|
+
restored += 1
|
|
221
|
+
if restored:
|
|
222
|
+
await self.engine.bus.publish("managed.restored", {"count": restored})
|
|
223
|
+
return restored
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""One cancels the other, and the bracket built on it.
|
|
2
|
+
|
|
3
|
+
**OCO** holds two orders where only one should happen: take profit at 0.70
|
|
4
|
+
or stop out at 0.40, buy this market or that one. Each leg is a real order
|
|
5
|
+
in its own right -- a limit at the venue, or another engine-held order like
|
|
6
|
+
a stop -- and the parent's job is to keep them in step.
|
|
7
|
+
|
|
8
|
+
The linkage is by size, not by existence, because a partial fill is the
|
|
9
|
+
common case on a thin book. When one leg fills two of five contracts, the
|
|
10
|
+
other is replaced at three: leaving it at five would let the pair fill seven.
|
|
11
|
+
When one leg is done, the other is pulled.
|
|
12
|
+
|
|
13
|
+
**Bracket** is an entry with that pair attached: buy ten at 0.42, and when it
|
|
14
|
+
fills, protect it with a take-profit and a stop-loss sized to what actually
|
|
15
|
+
filled. The protection goes out as the entry fills, contract by contract, so
|
|
16
|
+
a position is never unprotected while the entry works. An entry that is
|
|
17
|
+
cancelled with nothing filled takes the bracket with it.
|
|
18
|
+
|
|
19
|
+
Both are written to the journal like any other engine-held order, so a
|
|
20
|
+
restart finds the pair and its sizes rather than two loose orders nobody
|
|
21
|
+
links any more.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from decimal import Decimal
|
|
26
|
+
from typing import Any, Mapping
|
|
27
|
+
|
|
28
|
+
from ...trading.types import Fill, Order, OrderRequest, OrderType, Side, TimeInForce
|
|
29
|
+
from .base import ZERO, Child, Context, D, ManagedOrder
|
|
30
|
+
from .manager import register
|
|
31
|
+
|
|
32
|
+
VENUE_TYPES = (OrderType.LIMIT, OrderType.MARKET)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def leg_request(spec: Mapping[str, Any], *, parent: ManagedOrder, amount: Decimal | None = None) -> OrderRequest:
|
|
36
|
+
"""One leg's request, inheriting what the parent already knows."""
|
|
37
|
+
body: dict[str, Any] = {
|
|
38
|
+
"market_id": spec.get("market_id", parent.market_id),
|
|
39
|
+
"side": spec.get("side", parent.side.value),
|
|
40
|
+
"amount": str(amount if amount is not None else D(spec.get("amount"), parent.amount)),
|
|
41
|
+
"type": spec.get("type", "limit"),
|
|
42
|
+
"account": parent.account.model_dump(mode="json"),
|
|
43
|
+
"book": parent.request.book,
|
|
44
|
+
"trader": parent.request.trader,
|
|
45
|
+
"tags": {**parent.request.tags, "parent": parent.id},
|
|
46
|
+
}
|
|
47
|
+
for key in ("price", "stop_price", "time_in_force", "expires_at", "post_only", "reduce_only", "params"):
|
|
48
|
+
if spec.get(key) is not None:
|
|
49
|
+
body[key] = spec[key]
|
|
50
|
+
if "reduce_only" not in body and parent.request.reduce_only:
|
|
51
|
+
body["reduce_only"] = True
|
|
52
|
+
return OrderRequest.model_validate(body)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _Linked(ManagedOrder):
|
|
56
|
+
"""Shared machinery for parents whose legs must be resized together."""
|
|
57
|
+
|
|
58
|
+
async def place_leg(self, ctx: Context, spec: Mapping[str, Any], amount: Decimal, tag: str) -> Child:
|
|
59
|
+
request = leg_request(spec, parent=self, amount=amount)
|
|
60
|
+
order = await ctx.submit_child(request)
|
|
61
|
+
child = self.track(order, amount, D(request.price))
|
|
62
|
+
self.leg_of[child.order_id] = tag
|
|
63
|
+
self.spec_of[tag] = dict(spec)
|
|
64
|
+
await ctx.publish("managed.leg", {"leg": tag, "order_id": order.id, "amount": str(amount),
|
|
65
|
+
"type": str(spec.get("type", "limit"))})
|
|
66
|
+
return child
|
|
67
|
+
|
|
68
|
+
async def resize_leg(self, ctx: Context, tag: str, amount: Decimal) -> None:
|
|
69
|
+
"""Cancel and re-place a leg at a new size; below one contract, pull it."""
|
|
70
|
+
for child in list(self.live_children):
|
|
71
|
+
if self.leg_of.get(child.order_id) != tag:
|
|
72
|
+
continue
|
|
73
|
+
await ctx.cancel_child(child.order_id)
|
|
74
|
+
child.status = "canceled"
|
|
75
|
+
if amount <= 0:
|
|
76
|
+
return
|
|
77
|
+
spec = self.spec_of.get(tag)
|
|
78
|
+
if spec:
|
|
79
|
+
await self.place_leg(ctx, spec, amount, tag)
|
|
80
|
+
|
|
81
|
+
def leg_children(self, tag: str) -> list[Child]:
|
|
82
|
+
return [c for c in self.children if self.leg_of.get(c.order_id) == tag]
|
|
83
|
+
|
|
84
|
+
def extra(self) -> dict[str, Any]:
|
|
85
|
+
return {"leg_of": dict(self.leg_of), "spec_of": {k: dict(v) for k, v in self.spec_of.items()}}
|
|
86
|
+
|
|
87
|
+
def load_extra(self, extra: dict[str, Any]) -> None:
|
|
88
|
+
self.leg_of = dict(extra.get("leg_of") or {})
|
|
89
|
+
self.spec_of = {k: dict(v) for k, v in (extra.get("spec_of") or {}).items()}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@register
|
|
93
|
+
class OCO(_Linked):
|
|
94
|
+
"""`params`: `legs`, a list of two order specifications."""
|
|
95
|
+
|
|
96
|
+
kind = "oco"
|
|
97
|
+
order_type = OrderType.OCO
|
|
98
|
+
|
|
99
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
|
100
|
+
super().__init__(*args, **kwargs)
|
|
101
|
+
self.leg_of: dict[str, str] = {}
|
|
102
|
+
self.spec_of: dict[str, dict[str, Any]] = {}
|
|
103
|
+
legs = self.params.get("legs") or []
|
|
104
|
+
if len(legs) != 2:
|
|
105
|
+
raise ValueError("an OCO needs params legs: exactly two order specifications")
|
|
106
|
+
self.legs = [dict(leg) for leg in legs]
|
|
107
|
+
|
|
108
|
+
def markets(self) -> list[str]:
|
|
109
|
+
return list({self.market_id, *(str(leg.get("market_id") or self.market_id) for leg in self.legs)})
|
|
110
|
+
|
|
111
|
+
async def start(self, ctx: Context) -> None:
|
|
112
|
+
self.state = "working"
|
|
113
|
+
for index, spec in enumerate(self.legs):
|
|
114
|
+
await self.place_leg(ctx, spec, self.remaining, f"leg{index}")
|
|
115
|
+
await ctx.save()
|
|
116
|
+
|
|
117
|
+
async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None:
|
|
118
|
+
tag = self.leg_of.get(child.order_id, "")
|
|
119
|
+
other = "leg1" if tag == "leg0" else "leg0"
|
|
120
|
+
if self.remaining <= 0:
|
|
121
|
+
await self.cancel_other(ctx, other, "the other leg filled")
|
|
122
|
+
await self.finish(ctx, "done", "filled")
|
|
123
|
+
return
|
|
124
|
+
# Partly filled: the other leg must not be able to fill the same size.
|
|
125
|
+
await self.resize_leg(ctx, other, self.remaining)
|
|
126
|
+
await ctx.save()
|
|
127
|
+
|
|
128
|
+
async def cancel_other(self, ctx: Context, tag: str, reason: str) -> None:
|
|
129
|
+
for child in self.leg_children(tag):
|
|
130
|
+
if child.live:
|
|
131
|
+
await ctx.cancel_child(child.order_id)
|
|
132
|
+
child.status = "canceled"
|
|
133
|
+
await ctx.publish("managed.leg_canceled", {"leg": tag, "reason": reason})
|
|
134
|
+
|
|
135
|
+
async def on_child(self, ctx: Context, order: Order, child: Child) -> None:
|
|
136
|
+
if not order.is_terminal or self.state != "working":
|
|
137
|
+
return
|
|
138
|
+
if self.remaining <= 0:
|
|
139
|
+
await self.finish(ctx, "done", "filled")
|
|
140
|
+
return
|
|
141
|
+
if not self.live_children:
|
|
142
|
+
await self.finish(ctx, "canceled", f"both legs ended with {self.remaining} unfilled")
|
|
143
|
+
|
|
144
|
+
def extra(self) -> dict[str, Any]:
|
|
145
|
+
return super().extra() | {"legs": self.legs}
|
|
146
|
+
|
|
147
|
+
def load_extra(self, extra: dict[str, Any]) -> None:
|
|
148
|
+
super().load_extra(extra)
|
|
149
|
+
self.legs = [dict(leg) for leg in extra.get("legs") or self.params.get("legs") or []]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@register
|
|
153
|
+
class Bracket(_Linked):
|
|
154
|
+
"""`params`: `entry` (optional), `take_profit`, `stop_loss`.
|
|
155
|
+
|
|
156
|
+
Without an entry the protection is placed at once, for a position that
|
|
157
|
+
already exists.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
kind = "bracket"
|
|
161
|
+
order_type = OrderType.BRACKET
|
|
162
|
+
|
|
163
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
|
164
|
+
super().__init__(*args, **kwargs)
|
|
165
|
+
self.leg_of: dict[str, str] = {}
|
|
166
|
+
self.spec_of: dict[str, dict[str, Any]] = {}
|
|
167
|
+
self.entry = dict(self.params["entry"]) if self.params.get("entry") else None
|
|
168
|
+
self.take_profit = dict(self.params.get("take_profit") or {})
|
|
169
|
+
self.stop_loss = dict(self.params.get("stop_loss") or {})
|
|
170
|
+
if not self.take_profit and not self.stop_loss:
|
|
171
|
+
raise ValueError("a bracket needs params take_profit, stop_loss, or both")
|
|
172
|
+
self.entered: Decimal = ZERO
|
|
173
|
+
"""Contracts the entry has filled, which is what the protection covers."""
|
|
174
|
+
self.protected: Decimal = ZERO
|
|
175
|
+
|
|
176
|
+
def markets(self) -> list[str]:
|
|
177
|
+
return [self.market_id]
|
|
178
|
+
|
|
179
|
+
def exit_side(self) -> str:
|
|
180
|
+
"""Protection closes the position the entry opens."""
|
|
181
|
+
return (Side.SELL if self.side == Side.BUY else Side.BUY).value
|
|
182
|
+
|
|
183
|
+
async def start(self, ctx: Context) -> None:
|
|
184
|
+
self.state = "working"
|
|
185
|
+
if self.entry is not None:
|
|
186
|
+
await self.place_leg(ctx, {**self.entry, "side": self.side.value}, self.amount, "entry")
|
|
187
|
+
else:
|
|
188
|
+
self.entered = self.amount
|
|
189
|
+
await self.protect(ctx, self.amount)
|
|
190
|
+
await ctx.save()
|
|
191
|
+
|
|
192
|
+
async def protect(self, ctx: Context, amount: Decimal) -> None:
|
|
193
|
+
"""Put the take-profit and the stop-loss out for `amount` contracts."""
|
|
194
|
+
if amount <= 0:
|
|
195
|
+
return
|
|
196
|
+
side = self.exit_side()
|
|
197
|
+
if self.take_profit:
|
|
198
|
+
await self.place_leg(ctx, {**self.take_profit, "side": side}, amount, "take_profit")
|
|
199
|
+
if self.stop_loss:
|
|
200
|
+
spec = {"type": "stop_market", **self.stop_loss, "side": side}
|
|
201
|
+
await self.place_leg(ctx, spec, amount, "stop_loss")
|
|
202
|
+
self.protected += amount
|
|
203
|
+
await ctx.publish("managed.protected", {"amount": str(amount), "protected": str(self.protected)})
|
|
204
|
+
|
|
205
|
+
async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None:
|
|
206
|
+
tag = self.leg_of.get(child.order_id, "")
|
|
207
|
+
if tag == "entry":
|
|
208
|
+
self.entered += fill.amount
|
|
209
|
+
# Protect what has actually been bought, as it is bought.
|
|
210
|
+
await self.protect(ctx, fill.amount)
|
|
211
|
+
await ctx.save()
|
|
212
|
+
return
|
|
213
|
+
# An exit filled: the other side of the protection must shrink.
|
|
214
|
+
other = "stop_loss" if tag == "take_profit" else "take_profit"
|
|
215
|
+
remaining = max(ZERO, self.protected - self.exit_filled())
|
|
216
|
+
await self.resize_leg(ctx, other, remaining)
|
|
217
|
+
if remaining <= 0 and self.entry_done():
|
|
218
|
+
await self.finish(ctx, "done", "the position was closed")
|
|
219
|
+
await ctx.save()
|
|
220
|
+
|
|
221
|
+
def exit_filled(self) -> Decimal:
|
|
222
|
+
return sum((c.filled for c in self.children if self.leg_of.get(c.order_id) in ("take_profit", "stop_loss")), ZERO)
|
|
223
|
+
|
|
224
|
+
def entry_done(self) -> bool:
|
|
225
|
+
if self.entry is None:
|
|
226
|
+
return True
|
|
227
|
+
entry_children = self.leg_children("entry")
|
|
228
|
+
return bool(entry_children) and not any(c.live for c in entry_children)
|
|
229
|
+
|
|
230
|
+
async def on_child(self, ctx: Context, order: Order, child: Child) -> None:
|
|
231
|
+
tag = self.leg_of.get(child.order_id, "")
|
|
232
|
+
if self.state != "working" or not order.is_terminal:
|
|
233
|
+
return
|
|
234
|
+
if tag == "entry" and self.entered <= 0:
|
|
235
|
+
await self.finish(ctx, "canceled", "the entry ended without filling")
|
|
236
|
+
return
|
|
237
|
+
if tag in ("take_profit", "stop_loss") and self.exit_filled() >= self.protected > 0:
|
|
238
|
+
await self.finish(ctx, "done", "the position was closed")
|
|
239
|
+
|
|
240
|
+
async def cancel(self, ctx: Context, reason: str = "cancelled") -> None:
|
|
241
|
+
await super().cancel(ctx, reason)
|
|
242
|
+
|
|
243
|
+
def extra(self) -> dict[str, Any]:
|
|
244
|
+
return super().extra() | {
|
|
245
|
+
"entry": self.entry, "take_profit": self.take_profit, "stop_loss": self.stop_loss,
|
|
246
|
+
"entered": str(self.entered), "protected": str(self.protected),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
def load_extra(self, extra: dict[str, Any]) -> None:
|
|
250
|
+
super().load_extra(extra)
|
|
251
|
+
self.entry = dict(extra["entry"]) if extra.get("entry") else None
|
|
252
|
+
self.take_profit = dict(extra.get("take_profit") or {})
|
|
253
|
+
self.stop_loss = dict(extra.get("stop_loss") or {})
|
|
254
|
+
self.entered = D(extra.get("entered"), ZERO)
|
|
255
|
+
self.protected = D(extra.get("protected"), ZERO)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Peg: stay at the touch without chasing it.
|
|
2
|
+
|
|
3
|
+
A pegged order follows a reference price -- the near touch, the far touch or
|
|
4
|
+
the mid -- and re-prices when the reference moves. On a thin prediction
|
|
5
|
+
market that is how a maker stays at the front of the queue without watching
|
|
6
|
+
a screen, and also how a naive implementation burns its rate limit and its
|
|
7
|
+
queue position in a minute.
|
|
8
|
+
|
|
9
|
+
Three restraints, all of them the point:
|
|
10
|
+
|
|
11
|
+
**A minimum stay.** `min_stay_s` is how long an order must sit before it may
|
|
12
|
+
be moved at all. Without it, a flickering touch re-prices the order every few
|
|
13
|
+
hundred milliseconds, and each move goes to the back of the queue.
|
|
14
|
+
|
|
15
|
+
**A level cap.** `level_cap` counts how many times the order may be moved
|
|
16
|
+
before the peg gives up and stays where it is. A market that runs away from
|
|
17
|
+
you is not one to follow to the end of the book.
|
|
18
|
+
|
|
19
|
+
**Bounds.** `min_price` and `max_price` are the range within which the peg
|
|
20
|
+
may operate. Outside them it waits rather than trades, because a peg with no
|
|
21
|
+
bound is an instruction to pay anything.
|
|
22
|
+
|
|
23
|
+
`offset` sits the order behind the reference: a buy peg with offset 0.01 at a
|
|
24
|
+
0.41 bid rests at 0.40. Re-pricing is cancel and replace, which loses queue
|
|
25
|
+
priority on every venue here, so the parent counts the moves and reports
|
|
26
|
+
them.
|
|
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
|
+
REFERENCES = ("near", "far", "mid")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@register
|
|
41
|
+
class Peg(ManagedOrder):
|
|
42
|
+
"""`params`: `reference`, `offset`, `min_stay_s`, `level_cap`, `min_price`, `max_price`."""
|
|
43
|
+
|
|
44
|
+
kind = "peg"
|
|
45
|
+
order_type = OrderType.PEG
|
|
46
|
+
|
|
47
|
+
def __init__(self, *args: Any, **kwargs: Any):
|
|
48
|
+
super().__init__(*args, **kwargs)
|
|
49
|
+
self.reference = str(self.params.get("reference") or "near")
|
|
50
|
+
if self.reference not in REFERENCES:
|
|
51
|
+
raise ValueError(f"a peg's reference is one of {REFERENCES}")
|
|
52
|
+
self.offset = D(self.params.get("offset"), ZERO)
|
|
53
|
+
self.min_stay_s = float(self.params.get("min_stay_s") or 0)
|
|
54
|
+
self.level_cap = int(self.params.get("level_cap") or 0)
|
|
55
|
+
self.min_price = D(self.params.get("min_price"))
|
|
56
|
+
self.max_price = D(self.params.get("max_price"))
|
|
57
|
+
self.moves = 0
|
|
58
|
+
self.placed_at: float = 0.0
|
|
59
|
+
self.resting_price: Decimal | None = None
|
|
60
|
+
|
|
61
|
+
# -- where the peg wants to be -------------------------------------------
|
|
62
|
+
|
|
63
|
+
def target(self, ctx: Context) -> Decimal | None:
|
|
64
|
+
book = ctx.book()
|
|
65
|
+
if book is None:
|
|
66
|
+
return None
|
|
67
|
+
if self.reference == "mid":
|
|
68
|
+
reference = ctx.mid()
|
|
69
|
+
elif self.reference == "near":
|
|
70
|
+
reference = book.best_bid if self.side == Side.BUY else book.best_ask
|
|
71
|
+
else:
|
|
72
|
+
reference = book.best_ask if self.side == Side.BUY else book.best_bid
|
|
73
|
+
if reference is None:
|
|
74
|
+
return None
|
|
75
|
+
price = reference - self.offset if self.side == Side.BUY else reference + self.offset
|
|
76
|
+
# The bounds cap the chase rather than stopping it: a buy peg whose
|
|
77
|
+
# reference runs past its maximum rests at the maximum, where it may
|
|
78
|
+
# still be hit, instead of leaving the market entirely.
|
|
79
|
+
if self.min_price is not None:
|
|
80
|
+
price = max(price, self.min_price)
|
|
81
|
+
if self.max_price is not None:
|
|
82
|
+
price = min(price, self.max_price)
|
|
83
|
+
return price
|
|
84
|
+
|
|
85
|
+
def may_move(self, ctx: Context) -> bool:
|
|
86
|
+
if self.level_cap and self.moves >= self.level_cap:
|
|
87
|
+
return False
|
|
88
|
+
return ctx.now - self.placed_at >= self.min_stay_s
|
|
89
|
+
|
|
90
|
+
# -- running --------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
async def start(self, ctx: Context) -> None:
|
|
93
|
+
self.state = "working"
|
|
94
|
+
await self.reprice(ctx, first=True)
|
|
95
|
+
|
|
96
|
+
async def on_book(self, ctx: Context, market_id: str) -> None:
|
|
97
|
+
if self.state != "working":
|
|
98
|
+
return
|
|
99
|
+
target = self.target(ctx)
|
|
100
|
+
if target is None or target == self.resting_price:
|
|
101
|
+
return
|
|
102
|
+
if not self.live_children:
|
|
103
|
+
await self.reprice(ctx)
|
|
104
|
+
return
|
|
105
|
+
if self.may_move(ctx):
|
|
106
|
+
await self.reprice(ctx)
|
|
107
|
+
|
|
108
|
+
async def on_timer(self, ctx: Context) -> None:
|
|
109
|
+
if self.state == "working" and not self.live_children and self.remaining > 0:
|
|
110
|
+
await self.reprice(ctx)
|
|
111
|
+
|
|
112
|
+
async def reprice(self, ctx: Context, *, first: bool = False) -> None:
|
|
113
|
+
target = self.target(ctx)
|
|
114
|
+
if target is None:
|
|
115
|
+
if self.live_children:
|
|
116
|
+
for child in self.live_children:
|
|
117
|
+
await ctx.cancel_child(child.order_id)
|
|
118
|
+
child.status = "canceled"
|
|
119
|
+
await ctx.publish("managed.peg_paused", {"reason": "the reference is outside the bounds"})
|
|
120
|
+
await ctx.save()
|
|
121
|
+
return
|
|
122
|
+
for child in self.live_children:
|
|
123
|
+
await ctx.cancel_child(child.order_id)
|
|
124
|
+
child.status = "canceled"
|
|
125
|
+
size = self.remaining
|
|
126
|
+
if size <= 0:
|
|
127
|
+
await self.complete_if_done(ctx)
|
|
128
|
+
return
|
|
129
|
+
request = self.child_request(amount=size, price=target, type=OrderType.LIMIT,
|
|
130
|
+
time_in_force=TimeInForce.GTC, post_only=self.request.post_only)
|
|
131
|
+
order = await ctx.submit_child(request)
|
|
132
|
+
self.track(order, size, target)
|
|
133
|
+
self.resting_price = target
|
|
134
|
+
self.placed_at = ctx.now
|
|
135
|
+
if not first:
|
|
136
|
+
self.moves += 1
|
|
137
|
+
await ctx.publish("managed.pegged", {"price": str(target), "moves": self.moves,
|
|
138
|
+
"capped": bool(self.level_cap and self.moves >= self.level_cap)})
|
|
139
|
+
await ctx.save()
|
|
140
|
+
|
|
141
|
+
async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None:
|
|
142
|
+
await self.complete_if_done(ctx)
|
|
143
|
+
|
|
144
|
+
async def on_child(self, ctx: Context, order: Order, child: Child) -> None:
|
|
145
|
+
if self.state != "working" or not order.is_terminal:
|
|
146
|
+
return
|
|
147
|
+
if self.remaining <= 0:
|
|
148
|
+
await self.finish(ctx, "done", "filled")
|
|
149
|
+
|
|
150
|
+
def extra(self) -> dict[str, Any]:
|
|
151
|
+
return {
|
|
152
|
+
"reference": self.reference, "offset": str(self.offset), "min_stay_s": self.min_stay_s,
|
|
153
|
+
"level_cap": self.level_cap, "moves": self.moves, "placed_at": self.placed_at,
|
|
154
|
+
"min_price": str(self.min_price) if self.min_price is not None else None,
|
|
155
|
+
"max_price": str(self.max_price) if self.max_price is not None else None,
|
|
156
|
+
"resting_price": str(self.resting_price) if self.resting_price is not None else None,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
def load_extra(self, extra: dict[str, Any]) -> None:
|
|
160
|
+
self.reference = extra.get("reference", self.reference)
|
|
161
|
+
self.offset = D(extra.get("offset"), ZERO)
|
|
162
|
+
self.min_stay_s = float(extra.get("min_stay_s") or 0)
|
|
163
|
+
self.level_cap = int(extra.get("level_cap") or 0)
|
|
164
|
+
self.moves = int(extra.get("moves") or 0)
|
|
165
|
+
self.placed_at = float(extra.get("placed_at") or 0)
|
|
166
|
+
self.min_price = D(extra.get("min_price"))
|
|
167
|
+
self.max_price = D(extra.get("max_price"))
|
|
168
|
+
self.resting_price = D(extra.get("resting_price"))
|