synpath 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. synpath/__init__.py +183 -0
  2. synpath/__main__.py +66 -0
  3. synpath/base.py +723 -0
  4. synpath/bucket.py +154 -0
  5. synpath/client.py +356 -0
  6. synpath/engine/__init__.py +37 -0
  7. synpath/engine/__main__.py +354 -0
  8. synpath/engine/alerts.py +170 -0
  9. synpath/engine/engine.py +888 -0
  10. synpath/engine/eod.py +154 -0
  11. synpath/engine/events.py +140 -0
  12. synpath/engine/fair_values.py +117 -0
  13. synpath/engine/feeds.py +220 -0
  14. synpath/engine/journal.py +907 -0
  15. synpath/engine/ledger.py +353 -0
  16. synpath/engine/orders/__init__.py +42 -0
  17. synpath/engine/orders/base.py +441 -0
  18. synpath/engine/orders/day.py +72 -0
  19. synpath/engine/orders/iceberg.py +121 -0
  20. synpath/engine/orders/manager.py +223 -0
  21. synpath/engine/orders/oco.py +255 -0
  22. synpath/engine/orders/peg.py +168 -0
  23. synpath/engine/orders/routed.py +496 -0
  24. synpath/engine/orders/stop.py +240 -0
  25. synpath/engine/orders/taker.py +187 -0
  26. synpath/engine/orders/twap.py +190 -0
  27. synpath/engine/paper.py +532 -0
  28. synpath/engine/reconcile.py +279 -0
  29. synpath/engine/risk.py +403 -0
  30. synpath/engine/router.py +261 -0
  31. synpath/errors.py +98 -0
  32. synpath/history.py +71 -0
  33. synpath/hosted.py +86 -0
  34. synpath/hosted_auth.py +201 -0
  35. synpath/ids.py +61 -0
  36. synpath/kalshi.py +1378 -0
  37. synpath/matching.py +86 -0
  38. synpath/polymarket.py +1004 -0
  39. synpath/polymarket_us.py +989 -0
  40. synpath/remote.py +195 -0
  41. synpath/server/__init__.py +98 -0
  42. synpath/server/__main__.py +118 -0
  43. synpath/server/api.py +439 -0
  44. synpath/server/errors.py +87 -0
  45. synpath/server/local.py +96 -0
  46. synpath/server/models.py +75 -0
  47. synpath/server/serve.py +236 -0
  48. synpath/server/store.py +363 -0
  49. synpath/server/trading.py +764 -0
  50. synpath/trading/__init__.py +79 -0
  51. synpath/trading/__main__.py +69 -0
  52. synpath/trading/base.py +126 -0
  53. synpath/trading/credentials.py +400 -0
  54. synpath/trading/errors.py +94 -0
  55. synpath/trading/init.py +233 -0
  56. synpath/trading/instruments.py +162 -0
  57. synpath/trading/kalshi.py +957 -0
  58. synpath/trading/limiter.py +177 -0
  59. synpath/trading/money.py +172 -0
  60. synpath/trading/polymarket.py +1362 -0
  61. synpath/trading/polymarket_signing.py +478 -0
  62. synpath/trading/polymarket_us.py +705 -0
  63. synpath/trading/polymarket_us_exchange.py +825 -0
  64. synpath/trading/types.py +414 -0
  65. synpath/types.py +608 -0
  66. synpath/ws/__init__.py +55 -0
  67. synpath/ws/base.py +544 -0
  68. synpath/ws/grpc.py +578 -0
  69. synpath/ws/kalshi.py +418 -0
  70. synpath/ws/polymarket.py +430 -0
  71. synpath/ws/polymarket_us.py +299 -0
  72. synpath/ws/polymarket_us_exchange.py +754 -0
  73. synpath-0.1.0.dist-info/METADATA +224 -0
  74. synpath-0.1.0.dist-info/RECORD +77 -0
  75. synpath-0.1.0.dist-info/WHEEL +4 -0
  76. synpath-0.1.0.dist-info/entry_points.txt +2 -0
  77. synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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"))