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,279 @@
|
|
|
1
|
+
"""Reconciliation: what the venue says, against what the engine believes.
|
|
2
|
+
|
|
3
|
+
The engine's journal is authoritative about its own decisions and about
|
|
4
|
+
nothing else. Someone can trade the same account from a phone, a fill can
|
|
5
|
+
arrive while the process is down, a venue can expire an order without
|
|
6
|
+
telling anyone. So the engine asks, on a timer and at startup, and names
|
|
7
|
+
every difference:
|
|
8
|
+
|
|
9
|
+
| Finding | Meaning |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `orphan` | resting at the venue, unknown to the journal |
|
|
12
|
+
| `ghost` | open in the journal, gone at the venue |
|
|
13
|
+
| `drift` | known to both, different status, filled amount or price |
|
|
14
|
+
| `position` | the ledger and the venue disagree about a position |
|
|
15
|
+
| `fill` | a fill the venue has and the journal does not |
|
|
16
|
+
| `balance` | cash moved without a fill explaining it |
|
|
17
|
+
|
|
18
|
+
Findings are reported, not silently fixed. The policy decides what happens
|
|
19
|
+
to each: `report` only says so, `adopt` takes an orphan into the journal so
|
|
20
|
+
the engine manages it from now on, `cancel` pulls it. A ghost is always
|
|
21
|
+
closed in the journal, because the venue is the authority on whether an
|
|
22
|
+
order exists.
|
|
23
|
+
|
|
24
|
+
Balance reconciliation is the one that catches what nothing else does: a
|
|
25
|
+
deposit, a withdrawal, a settlement, or a fee charged outside any fill. It
|
|
26
|
+
compares the venue's cash against the cash the journal can explain, and
|
|
27
|
+
reports the difference rather than adjusting anything.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import logging
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from decimal import Decimal
|
|
34
|
+
from typing import Any, Literal
|
|
35
|
+
|
|
36
|
+
from ..trading.base import TradingExchange
|
|
37
|
+
from ..trading.types import Balance, Fill, HeldBy, Order, OrderStatus, Position, PositionSide
|
|
38
|
+
from .engine import Engine
|
|
39
|
+
|
|
40
|
+
log = logging.getLogger("synpath.engine")
|
|
41
|
+
|
|
42
|
+
ZERO = Decimal("0")
|
|
43
|
+
|
|
44
|
+
OrphanPolicy = Literal["report", "adopt", "cancel"]
|
|
45
|
+
Finding = Literal["orphan", "ghost", "drift", "position", "fill", "balance"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(slots=True)
|
|
49
|
+
class Difference:
|
|
50
|
+
kind: Finding
|
|
51
|
+
venue: str
|
|
52
|
+
key: str
|
|
53
|
+
detail: dict[str, Any] = field(default_factory=dict)
|
|
54
|
+
action: str = "reported"
|
|
55
|
+
|
|
56
|
+
def as_event(self) -> dict[str, Any]:
|
|
57
|
+
return {"kind": self.kind, "venue": self.venue, "key": self.key, "action": self.action, **self.detail}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(slots=True)
|
|
61
|
+
class ReconcileReport:
|
|
62
|
+
venue: str
|
|
63
|
+
orphans: list[Difference] = field(default_factory=list)
|
|
64
|
+
ghosts: list[Difference] = field(default_factory=list)
|
|
65
|
+
drifts: list[Difference] = field(default_factory=list)
|
|
66
|
+
positions: list[Difference] = field(default_factory=list)
|
|
67
|
+
fills: list[Difference] = field(default_factory=list)
|
|
68
|
+
balances: list[Difference] = field(default_factory=list)
|
|
69
|
+
checked_orders: int = 0
|
|
70
|
+
checked_positions: int = 0
|
|
71
|
+
new_fills: int = 0
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def differences(self) -> list[Difference]:
|
|
75
|
+
return [*self.orphans, *self.ghosts, *self.drifts, *self.positions, *self.fills, *self.balances]
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def clean(self) -> bool:
|
|
79
|
+
return not self.differences
|
|
80
|
+
|
|
81
|
+
def summary(self) -> dict[str, Any]:
|
|
82
|
+
return {
|
|
83
|
+
"venue": self.venue, "clean": self.clean, "orders_checked": self.checked_orders,
|
|
84
|
+
"positions_checked": self.checked_positions, "new_fills": self.new_fills,
|
|
85
|
+
"orphans": len(self.orphans), "ghosts": len(self.ghosts), "drifts": len(self.drifts),
|
|
86
|
+
"position_differences": len(self.positions), "balance_differences": len(self.balances),
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Reconciler:
|
|
91
|
+
"""Compares one engine against the venues it trades."""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
engine: Engine,
|
|
96
|
+
*,
|
|
97
|
+
orphan_policy: OrphanPolicy = "report",
|
|
98
|
+
position_tolerance: Decimal = ZERO,
|
|
99
|
+
balance_tolerance: Decimal = Decimal("0.01"),
|
|
100
|
+
):
|
|
101
|
+
self.engine = engine
|
|
102
|
+
self.orphan_policy = orphan_policy
|
|
103
|
+
self.position_tolerance = position_tolerance
|
|
104
|
+
self.balance_tolerance = balance_tolerance
|
|
105
|
+
|
|
106
|
+
async def run(self, venue: str | None = None) -> list[ReconcileReport]:
|
|
107
|
+
"""Reconcile one venue or all of them."""
|
|
108
|
+
names = [venue] if venue else list(self.engine.adapters)
|
|
109
|
+
reports = []
|
|
110
|
+
for name in names:
|
|
111
|
+
report = await self.venue(name, self.engine.adapters[name])
|
|
112
|
+
reports.append(report)
|
|
113
|
+
return reports
|
|
114
|
+
|
|
115
|
+
async def venue(self, venue: str, adapter: TradingExchange) -> ReconcileReport:
|
|
116
|
+
report = ReconcileReport(venue=venue)
|
|
117
|
+
await self._orders(venue, adapter, report)
|
|
118
|
+
await self._fills(venue, adapter, report)
|
|
119
|
+
await self._positions(venue, adapter, report)
|
|
120
|
+
await self._balance(venue, adapter, report)
|
|
121
|
+
await self.engine.bus.publish("reconcile.done", report.summary(), key=venue)
|
|
122
|
+
for difference in report.differences:
|
|
123
|
+
await self.engine.bus.publish(f"reconcile.{difference.kind}", difference.as_event(), key=f"{venue}:{difference.key}")
|
|
124
|
+
return report
|
|
125
|
+
|
|
126
|
+
# -- orders ---------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
async def _orders(self, venue: str, adapter: TradingExchange, report: ReconcileReport) -> None:
|
|
129
|
+
live = {o.id: o for o in await adapter.fetch_open_orders()}
|
|
130
|
+
# Engine-held parents are not orders at the venue: asking about one
|
|
131
|
+
# would report it missing every minute.
|
|
132
|
+
mine = {o.id: o for o in await self.engine.journal.open_orders(venue=venue) if o.held_by != HeldBy.ENGINE}
|
|
133
|
+
report.checked_orders = len(live) + len(mine)
|
|
134
|
+
|
|
135
|
+
for order_id, order in live.items():
|
|
136
|
+
known = mine.get(order_id)
|
|
137
|
+
if known is None:
|
|
138
|
+
# An order this engine never wrote down. Adopting it means
|
|
139
|
+
# managing it; cancelling it assumes nobody else should be
|
|
140
|
+
# trading this account. Reporting is the only safe default.
|
|
141
|
+
difference = Difference("orphan", venue, order_id, {
|
|
142
|
+
"market_id": order.market_id, "side": order.side.value,
|
|
143
|
+
"amount": str(order.amount), "price": str(order.price) if order.price else None,
|
|
144
|
+
"client_order_id": order.client_order_id,
|
|
145
|
+
})
|
|
146
|
+
if self.orphan_policy == "adopt":
|
|
147
|
+
await self.engine.on_order(order)
|
|
148
|
+
difference.action = "adopted"
|
|
149
|
+
elif self.orphan_policy == "cancel":
|
|
150
|
+
try:
|
|
151
|
+
await adapter.cancel_order(order_id, market_id=order.market_id)
|
|
152
|
+
difference.action = "canceled"
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
difference.action = f"cancel failed: {type(exc).__name__}"
|
|
155
|
+
report.orphans.append(difference)
|
|
156
|
+
continue
|
|
157
|
+
if _drifted(known, order):
|
|
158
|
+
await self.engine.on_order(order)
|
|
159
|
+
report.drifts.append(Difference("drift", venue, order_id, {
|
|
160
|
+
"journal_status": known.status.value, "venue_status": order.status.value,
|
|
161
|
+
"journal_filled": str(known.filled), "venue_filled": str(order.filled),
|
|
162
|
+
"journal_price": str(known.price) if known.price else None,
|
|
163
|
+
"venue_price": str(order.price) if order.price else None,
|
|
164
|
+
}, action="updated"))
|
|
165
|
+
|
|
166
|
+
for order_id, order in mine.items():
|
|
167
|
+
if order_id in live:
|
|
168
|
+
continue
|
|
169
|
+
# Open here, gone there. The venue is the authority: close it, but
|
|
170
|
+
# read it first, because it may have filled rather than vanished.
|
|
171
|
+
final = None
|
|
172
|
+
try:
|
|
173
|
+
final = await adapter.fetch_order(order_id)
|
|
174
|
+
except Exception:
|
|
175
|
+
final = None
|
|
176
|
+
closed = final or order.model_copy(update={"status": OrderStatus.CANCELED})
|
|
177
|
+
await self.engine.on_order(closed)
|
|
178
|
+
report.ghosts.append(Difference("ghost", venue, order_id, {
|
|
179
|
+
"market_id": order.market_id, "journal_status": order.status.value,
|
|
180
|
+
"final_status": closed.status.value, "filled": str(closed.filled),
|
|
181
|
+
}, action="closed"))
|
|
182
|
+
|
|
183
|
+
# -- fills ----------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
async def _fills(self, venue: str, adapter: TradingExchange, report: ReconcileReport) -> None:
|
|
186
|
+
if not adapter.has.get("fetch_my_trades"):
|
|
187
|
+
return
|
|
188
|
+
since = int(await self.engine.journal.cursor(f"reconcile_fills:{venue}", 0) or 0)
|
|
189
|
+
try:
|
|
190
|
+
fills = await adapter.fetch_my_trades(since=since or None, limit=500)
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
log.warning("synpath.engine: reading fills from %s failed: %s", venue, exc)
|
|
193
|
+
return
|
|
194
|
+
newest = since
|
|
195
|
+
for fill in fills:
|
|
196
|
+
newest = max(newest, fill.timestamp or 0)
|
|
197
|
+
if await self.engine.journal.has_fill(venue, fill.id):
|
|
198
|
+
continue
|
|
199
|
+
await self.engine.on_fill(fill)
|
|
200
|
+
report.new_fills += 1
|
|
201
|
+
report.fills.append(Difference("fill", venue, fill.id, {
|
|
202
|
+
"order_id": fill.order_id, "market_id": fill.market_id,
|
|
203
|
+
"amount": str(fill.amount), "price": str(fill.price),
|
|
204
|
+
}, action="booked"))
|
|
205
|
+
if newest > since:
|
|
206
|
+
await self.engine.journal.set_cursor(f"reconcile_fills:{venue}", newest)
|
|
207
|
+
|
|
208
|
+
# -- positions ------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
async def _positions(self, venue: str, adapter: TradingExchange, report: ReconcileReport) -> None:
|
|
211
|
+
if not adapter.has.get("fetch_positions"):
|
|
212
|
+
return
|
|
213
|
+
try:
|
|
214
|
+
live = await adapter.fetch_positions()
|
|
215
|
+
except Exception as exc:
|
|
216
|
+
log.warning("synpath.engine: reading positions from %s failed: %s", venue, exc)
|
|
217
|
+
return
|
|
218
|
+
rows = self.engine.ledger.merged(live)
|
|
219
|
+
report.checked_positions = len(rows)
|
|
220
|
+
for row in rows:
|
|
221
|
+
if row["venue"] != venue:
|
|
222
|
+
continue
|
|
223
|
+
difference = row["difference"]
|
|
224
|
+
if abs(difference) > self.position_tolerance:
|
|
225
|
+
report.positions.append(Difference("position", venue, row["market_id"], {
|
|
226
|
+
"account": row["account"], "engine": str(row["engine"]),
|
|
227
|
+
"venue_contracts": str(row["venue_contracts"]), "difference": str(difference),
|
|
228
|
+
"books": {k: str(v) for k, v in row["books"].items()},
|
|
229
|
+
}))
|
|
230
|
+
|
|
231
|
+
# -- balance --------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
async def _balance(self, venue: str, adapter: TradingExchange, report: ReconcileReport) -> None:
|
|
234
|
+
if not adapter.has.get("fetch_balance"):
|
|
235
|
+
return
|
|
236
|
+
try:
|
|
237
|
+
balance = await adapter.fetch_balance()
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
log.warning("synpath.engine: reading the balance from %s failed: %s", venue, exc)
|
|
240
|
+
return
|
|
241
|
+
key = f"balance:{venue}"
|
|
242
|
+
previous = await self.engine.journal.cursor(key)
|
|
243
|
+
await self.engine.journal.set_cursor(key, str(balance.total))
|
|
244
|
+
if previous is None:
|
|
245
|
+
return
|
|
246
|
+
moved = balance.total - Decimal(previous)
|
|
247
|
+
if moved == ZERO:
|
|
248
|
+
return
|
|
249
|
+
explained = await self._explained_cash(venue)
|
|
250
|
+
unexplained = moved - explained
|
|
251
|
+
if abs(unexplained) > self.balance_tolerance:
|
|
252
|
+
report.balances.append(Difference("balance", venue, balance.currency, {
|
|
253
|
+
"moved": str(moved), "explained_by_fills": str(explained), "unexplained": str(unexplained),
|
|
254
|
+
"total": str(balance.total), "available": str(balance.available),
|
|
255
|
+
"note": "a deposit, a withdrawal, a settlement or a fee charged outside a fill",
|
|
256
|
+
}))
|
|
257
|
+
|
|
258
|
+
async def _explained_cash(self, venue: str) -> Decimal:
|
|
259
|
+
"""Cash the fills since the last check account for."""
|
|
260
|
+
key = f"balance_cursor:{venue}"
|
|
261
|
+
since = int(await self.engine.journal.cursor(key, 0) or 0)
|
|
262
|
+
newest = since
|
|
263
|
+
total = ZERO
|
|
264
|
+
for fill in await self.engine.journal.fills(since_ts=since, venue=venue):
|
|
265
|
+
newest = max(newest, fill.timestamp or 0)
|
|
266
|
+
signed = -1 if fill.side.value == "buy" else 1
|
|
267
|
+
total += Decimal(signed) * fill.price * fill.amount - (fill.fee or ZERO)
|
|
268
|
+
if newest > since:
|
|
269
|
+
await self.engine.journal.set_cursor(key, newest)
|
|
270
|
+
return total
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _drifted(mine: Order, theirs: Order) -> bool:
|
|
274
|
+
return (
|
|
275
|
+
mine.status != theirs.status
|
|
276
|
+
or mine.filled != theirs.filled
|
|
277
|
+
or (mine.price or ZERO) != (theirs.price or ZERO)
|
|
278
|
+
or (mine.remaining or ZERO) != (theirs.remaining or ZERO)
|
|
279
|
+
)
|
synpath/engine/risk.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Pre-trade risk: the rules that refuse an order before it is signed.
|
|
2
|
+
|
|
3
|
+
Every rule here answers one question and names itself when it says no, so a
|
|
4
|
+
rejection is a sentence an operator can act on rather than a generic error.
|
|
5
|
+
The configuration is versioned in the journal: a rejection records which
|
|
6
|
+
version refused it, and a later argument about why is settled by reading
|
|
7
|
+
that version instead of guessing what the file said at the time.
|
|
8
|
+
|
|
9
|
+
Rules fall into three groups:
|
|
10
|
+
|
|
11
|
+
**About this order.** Price collar against the mark, order size and notional,
|
|
12
|
+
whole-contract and tick rules the venue would reject anyway, a restricted
|
|
13
|
+
list, and a guard against sending anything into a market that is about to
|
|
14
|
+
close. Also the duplicate window: the same order twice within a few seconds
|
|
15
|
+
is almost always a retry loop, not a decision.
|
|
16
|
+
|
|
17
|
+
**About the book it joins.** Open-order count, position caps per instrument,
|
|
18
|
+
per event and per venue, the exchange's own position limit where the venue
|
|
19
|
+
publishes one, and the daily loss limit, which reads the ledger rather than
|
|
20
|
+
a number someone maintains by hand.
|
|
21
|
+
|
|
22
|
+
**About the firm.** Self-trade prevention: an order that would cross this
|
|
23
|
+
firm's own resting order is refused (or the resting one is cancelled first,
|
|
24
|
+
where the caller asks for that), because a wash trade is a compliance
|
|
25
|
+
problem, not a fill. It looks at every book in the account, not just the one
|
|
26
|
+
placing the order, which is why it lives here and not in a strategy.
|
|
27
|
+
|
|
28
|
+
The kill switch sits alongside the rules. It can be engaged globally or for
|
|
29
|
+
one venue, and it decides what happens to orders already resting: `cancel`
|
|
30
|
+
pulls them, `hold` leaves them and blocks new ones, `rearm` blocks new
|
|
31
|
+
orders and lifts itself after a timer. The engine maps it onto the venues'
|
|
32
|
+
own mechanisms where they exist, which is what makes it fast: a Kalshi order
|
|
33
|
+
group cancels server-side in one round trip, and a Polymarket heartbeat that
|
|
34
|
+
stops beating cancels everything within ten seconds even if this process is
|
|
35
|
+
gone.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import time
|
|
40
|
+
from collections import deque
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from decimal import Decimal
|
|
43
|
+
from typing import Any, Iterable, Literal
|
|
44
|
+
|
|
45
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
46
|
+
|
|
47
|
+
from .. import ids
|
|
48
|
+
from ..trading.types import Account, Order, OrderRequest, Side
|
|
49
|
+
from .ledger import Ledger
|
|
50
|
+
|
|
51
|
+
ZERO = Decimal("0")
|
|
52
|
+
ONE = Decimal("1")
|
|
53
|
+
|
|
54
|
+
HaltPolicy = Literal["cancel", "hold", "rearm"]
|
|
55
|
+
SelfTradeScope = Literal["off", "account", "firm"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RiskConfig(BaseModel):
|
|
59
|
+
"""What the engine will and will not do. Stored as a version in the journal."""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(extra="forbid")
|
|
62
|
+
|
|
63
|
+
enabled: bool = True
|
|
64
|
+
|
|
65
|
+
# -- this order --
|
|
66
|
+
price_collar: Decimal | None = Decimal("0.10")
|
|
67
|
+
"""How far from the mark a limit may sit, in price units (contracts are
|
|
68
|
+
priced 0 to 1, so 0.10 is ten cents). `None` disables the check."""
|
|
69
|
+
max_order_contracts: Decimal | None = None
|
|
70
|
+
max_order_notional: Decimal | None = None
|
|
71
|
+
"""Price times contracts, in account currency."""
|
|
72
|
+
min_price: Decimal = Decimal("0")
|
|
73
|
+
max_price: Decimal = Decimal("1")
|
|
74
|
+
duplicate_window_ms: int = 2_000
|
|
75
|
+
"""The same instrument, side, price and size within this window is a retry."""
|
|
76
|
+
closing_soon_s: int | None = 60
|
|
77
|
+
"""Refuse opening orders this close to a market's resolution time."""
|
|
78
|
+
restricted: list[str] = Field(default_factory=list)
|
|
79
|
+
"""Instrument or market ids, or prefixes ending in `*`, nobody may trade."""
|
|
80
|
+
|
|
81
|
+
# -- the book --
|
|
82
|
+
max_open_orders: int | None = 200
|
|
83
|
+
max_position_contracts: Decimal | None = None
|
|
84
|
+
"""Per instrument, per account, netted across books."""
|
|
85
|
+
max_event_contracts: Decimal | None = None
|
|
86
|
+
max_venue_notional: Decimal | None = None
|
|
87
|
+
daily_loss_limit: Decimal | None = None
|
|
88
|
+
"""Realized loss since the day's start, as a positive number."""
|
|
89
|
+
max_orders_per_minute: int | None = 120
|
|
90
|
+
|
|
91
|
+
# -- the firm --
|
|
92
|
+
self_trade_prevention: SelfTradeScope = "firm"
|
|
93
|
+
exchange_limits: dict[str, Decimal] = Field(default_factory=dict)
|
|
94
|
+
"""Venue-published position limits, per market id."""
|
|
95
|
+
paused_books: list[str] = Field(default_factory=list)
|
|
96
|
+
"""Strategies that may cancel but not open."""
|
|
97
|
+
|
|
98
|
+
def paused(self, book: str | None) -> bool:
|
|
99
|
+
return bool(book and book in self.paused_books)
|
|
100
|
+
|
|
101
|
+
def restricts(self, market_id: str, _unused: str | None = None) -> str | None:
|
|
102
|
+
for pattern in self.restricted:
|
|
103
|
+
for candidate in (market_id, ids.split(market_id)[1]):
|
|
104
|
+
if not candidate:
|
|
105
|
+
continue
|
|
106
|
+
if pattern.endswith("*") and candidate.startswith(pattern[:-1]):
|
|
107
|
+
return pattern
|
|
108
|
+
if candidate == pattern:
|
|
109
|
+
return pattern
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class Decision:
|
|
115
|
+
"""The answer, and which rule gave it."""
|
|
116
|
+
|
|
117
|
+
ok: bool
|
|
118
|
+
rule: str = ""
|
|
119
|
+
message: str = ""
|
|
120
|
+
config_version: int | None = None
|
|
121
|
+
detail: dict[str, Any] = field(default_factory=dict)
|
|
122
|
+
|
|
123
|
+
def __bool__(self) -> bool:
|
|
124
|
+
return self.ok
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
ALLOWED = Decision(ok=True)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(slots=True)
|
|
131
|
+
class KillSwitch:
|
|
132
|
+
"""Stop trading now, and say what happens to what is already resting."""
|
|
133
|
+
|
|
134
|
+
engaged: bool = False
|
|
135
|
+
scope: str = "*"
|
|
136
|
+
"""`*` for everything, or a venue id."""
|
|
137
|
+
policy: HaltPolicy = "cancel"
|
|
138
|
+
reason: str = ""
|
|
139
|
+
engaged_ts: int | None = None
|
|
140
|
+
rearm_after_s: float | None = None
|
|
141
|
+
venues: set[str] = field(default_factory=set)
|
|
142
|
+
|
|
143
|
+
def engage(self, *, reason: str, scope: str = "*", policy: HaltPolicy = "cancel", rearm_after_s: float | None = None) -> None:
|
|
144
|
+
self.engaged = True
|
|
145
|
+
self.scope = scope
|
|
146
|
+
self.policy = policy
|
|
147
|
+
self.reason = reason
|
|
148
|
+
self.engaged_ts = int(time.time() * 1000)
|
|
149
|
+
self.rearm_after_s = rearm_after_s
|
|
150
|
+
if scope != "*":
|
|
151
|
+
self.venues.add(scope)
|
|
152
|
+
|
|
153
|
+
def release(self, *, scope: str | None = None) -> None:
|
|
154
|
+
if scope and scope != "*" and scope in self.venues:
|
|
155
|
+
self.venues.discard(scope)
|
|
156
|
+
if not self.venues and self.scope == scope:
|
|
157
|
+
self.engaged = False
|
|
158
|
+
return
|
|
159
|
+
self.engaged = False
|
|
160
|
+
self.venues.clear()
|
|
161
|
+
self.reason = ""
|
|
162
|
+
self.rearm_after_s = None
|
|
163
|
+
|
|
164
|
+
def blocks(self, venue: str, *, now: float | None = None) -> bool:
|
|
165
|
+
if not self.engaged:
|
|
166
|
+
return False
|
|
167
|
+
if self.policy == "rearm" and self.rearm_after_s is not None and self.engaged_ts is not None:
|
|
168
|
+
if ((now or time.time()) * 1000 - self.engaged_ts) / 1000 >= self.rearm_after_s:
|
|
169
|
+
self.release()
|
|
170
|
+
return False
|
|
171
|
+
return self.scope == "*" or venue in self.venues or venue == self.scope
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class RiskEngine:
|
|
175
|
+
"""Runs the rules. Holds no venue state: everything it needs is passed in."""
|
|
176
|
+
|
|
177
|
+
def __init__(
|
|
178
|
+
self,
|
|
179
|
+
config: RiskConfig | None = None,
|
|
180
|
+
*,
|
|
181
|
+
ledger: Ledger | None = None,
|
|
182
|
+
config_version: int | None = None,
|
|
183
|
+
clock: Any = time.time,
|
|
184
|
+
):
|
|
185
|
+
self.config = config or RiskConfig()
|
|
186
|
+
self.config_version = config_version
|
|
187
|
+
self.ledger = ledger
|
|
188
|
+
self.clock = clock
|
|
189
|
+
self.kill = KillSwitch()
|
|
190
|
+
self._recent: deque[tuple[float, tuple[str, str, str, str]]] = deque(maxlen=512)
|
|
191
|
+
self._sent: deque[float] = deque(maxlen=4096)
|
|
192
|
+
self.day_start_ms: int = _day_start_ms(self.clock())
|
|
193
|
+
self.realized_at_day_start: Decimal = ZERO
|
|
194
|
+
|
|
195
|
+
# -- configuration --------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def configure(self, config: RiskConfig, *, version: int | None = None) -> None:
|
|
198
|
+
self.config, self.config_version = config, version
|
|
199
|
+
|
|
200
|
+
def roll_day(self, *, realized_now: Decimal | None = None) -> None:
|
|
201
|
+
"""Start a new trading day: the loss limit counts from here."""
|
|
202
|
+
self.day_start_ms = _day_start_ms(self.clock())
|
|
203
|
+
if realized_now is not None:
|
|
204
|
+
self.realized_at_day_start = realized_now
|
|
205
|
+
elif self.ledger is not None:
|
|
206
|
+
self.realized_at_day_start = self.ledger.total().realized
|
|
207
|
+
|
|
208
|
+
def realized_today(self) -> Decimal:
|
|
209
|
+
if self.ledger is None:
|
|
210
|
+
return ZERO
|
|
211
|
+
return self.ledger.total().realized - self.realized_at_day_start
|
|
212
|
+
|
|
213
|
+
# -- the check ------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
def check(
|
|
216
|
+
self,
|
|
217
|
+
request: OrderRequest,
|
|
218
|
+
*,
|
|
219
|
+
venue: str,
|
|
220
|
+
account: Account,
|
|
221
|
+
open_orders: Iterable[Order] = (),
|
|
222
|
+
mark: Decimal | None = None,
|
|
223
|
+
market_close_ts: int | None = None,
|
|
224
|
+
event_id: str | None = None,
|
|
225
|
+
venue_notional: Decimal | None = None,
|
|
226
|
+
deliberate: bool = False,
|
|
227
|
+
) -> Decision:
|
|
228
|
+
"""Everything that must be true before this order is signed.
|
|
229
|
+
|
|
230
|
+
`deliberate` is true when the caller named the order's
|
|
231
|
+
`client_order_id` itself: the duplicate window is for an accidental
|
|
232
|
+
second click, and a caller that names each order is not clicking."""
|
|
233
|
+
config = self.config
|
|
234
|
+
if not config.enabled:
|
|
235
|
+
return self._ok()
|
|
236
|
+
if self.kill.blocks(venue, now=self.clock()):
|
|
237
|
+
return self._no("kill_switch", f"trading is halted: {self.kill.reason or 'kill switch engaged'}")
|
|
238
|
+
if config.paused(request.book):
|
|
239
|
+
return self._no("book_paused", f"strategy {request.book!r} is paused; it may cancel but not open")
|
|
240
|
+
|
|
241
|
+
market_id = request.market_id
|
|
242
|
+
if (pattern := config.restricts(market_id)):
|
|
243
|
+
return self._no("restricted", f"{market_id} matches the restricted entry {pattern!r}")
|
|
244
|
+
|
|
245
|
+
price = request.price
|
|
246
|
+
if price is not None:
|
|
247
|
+
if not (config.min_price <= price <= config.max_price):
|
|
248
|
+
return self._no("price_bounds", f"price {price} is outside {config.min_price}-{config.max_price}")
|
|
249
|
+
if config.price_collar is not None and mark is not None:
|
|
250
|
+
away = abs(price - mark)
|
|
251
|
+
if away > config.price_collar:
|
|
252
|
+
return self._no(
|
|
253
|
+
"price_collar", f"price {price} is {away} from the mark {mark}, over the collar {config.price_collar}",
|
|
254
|
+
detail={"mark": str(mark), "away": str(away)},
|
|
255
|
+
)
|
|
256
|
+
if config.max_order_contracts is not None and request.amount > config.max_order_contracts:
|
|
257
|
+
return self._no("max_order_contracts", f"{request.amount} contracts is over the {config.max_order_contracts} limit")
|
|
258
|
+
notional = (price or ONE) * request.amount
|
|
259
|
+
if config.max_order_notional is not None and notional > config.max_order_notional:
|
|
260
|
+
return self._no("max_order_notional", f"{notional} is over the {config.max_order_notional} order limit")
|
|
261
|
+
|
|
262
|
+
now = self.clock()
|
|
263
|
+
# An engine-held order's children are sliced on purpose and often look
|
|
264
|
+
# alike (an iceberg's reloads, a TWAP's slices, two parents on one
|
|
265
|
+
# market), so the window applies to orders sent from outside only.
|
|
266
|
+
if config.duplicate_window_ms and not deliberate and not request.tags.get("parent"):
|
|
267
|
+
signature = (market_id, request.side.value, str(price), str(request.amount))
|
|
268
|
+
cutoff = now - config.duplicate_window_ms / 1000
|
|
269
|
+
for stamp, seen in reversed(self._recent):
|
|
270
|
+
if stamp < cutoff:
|
|
271
|
+
break
|
|
272
|
+
if seen == signature:
|
|
273
|
+
return self._no(
|
|
274
|
+
"duplicate", f"the same order was sent {round((now - stamp) * 1000)}ms ago; "
|
|
275
|
+
f"pass a different client_order_id if this is deliberate",
|
|
276
|
+
)
|
|
277
|
+
if config.max_orders_per_minute is not None:
|
|
278
|
+
while self._sent and self._sent[0] < now - 60:
|
|
279
|
+
self._sent.popleft()
|
|
280
|
+
if len(self._sent) >= config.max_orders_per_minute:
|
|
281
|
+
return self._no("order_rate", f"{len(self._sent)} orders in the last minute, at the {config.max_orders_per_minute} limit")
|
|
282
|
+
|
|
283
|
+
if config.closing_soon_s is not None and market_close_ts is not None and not request.reduce_only:
|
|
284
|
+
left = (market_close_ts - now * 1000) / 1000
|
|
285
|
+
if left <= config.closing_soon_s:
|
|
286
|
+
return self._no(
|
|
287
|
+
"closing_soon", f"{market_id} resolves in {round(left)}s, inside the {config.closing_soon_s}s guard",
|
|
288
|
+
detail={"seconds_left": round(left)},
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
resting = [o for o in open_orders if not o.is_terminal]
|
|
292
|
+
if config.max_open_orders is not None and len(resting) >= config.max_open_orders:
|
|
293
|
+
return self._no("max_open_orders", f"{len(resting)} orders already resting, at the {config.max_open_orders} limit")
|
|
294
|
+
|
|
295
|
+
if config.self_trade_prevention != "off" and price is not None:
|
|
296
|
+
crossing = self._self_cross(request, resting, account, price)
|
|
297
|
+
if crossing is not None:
|
|
298
|
+
return self._no(
|
|
299
|
+
"self_trade", f"this would trade against the firm's own order {crossing.id} "
|
|
300
|
+
f"({crossing.side.value} {crossing.remaining} at {crossing.price} on {crossing.market_id})",
|
|
301
|
+
detail={"order_id": crossing.id, "book": crossing.book or ""},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
signed = request.amount if request.side == Side.BUY else -request.amount
|
|
305
|
+
if self.ledger is not None:
|
|
306
|
+
current = self._net_contracts(account.key, venue, market_id)
|
|
307
|
+
after = abs(current + signed)
|
|
308
|
+
if config.max_position_contracts is not None and after > config.max_position_contracts:
|
|
309
|
+
return self._no(
|
|
310
|
+
"max_position", f"{market_id} would reach {after} contracts, over the {config.max_position_contracts} limit",
|
|
311
|
+
detail={"current": str(current), "after": str(after)},
|
|
312
|
+
)
|
|
313
|
+
limit = config.exchange_limits.get(market_id)
|
|
314
|
+
if limit is not None and after > limit:
|
|
315
|
+
return self._no(
|
|
316
|
+
"exchange_limit", f"{market_id} would reach {after} contracts, over the exchange limit {limit}",
|
|
317
|
+
detail={"limit": str(limit)},
|
|
318
|
+
)
|
|
319
|
+
if config.max_event_contracts is not None and event_id:
|
|
320
|
+
total = self._event_contracts(account.key, venue, event_id) + abs(signed)
|
|
321
|
+
if total > config.max_event_contracts:
|
|
322
|
+
return self._no("max_event", f"event {event_id} would reach {total} contracts, over {config.max_event_contracts}")
|
|
323
|
+
if config.daily_loss_limit is not None:
|
|
324
|
+
loss = -self.realized_today()
|
|
325
|
+
if loss >= config.daily_loss_limit:
|
|
326
|
+
return self._no(
|
|
327
|
+
"daily_loss", f"today's realized loss {loss} has reached the {config.daily_loss_limit} limit",
|
|
328
|
+
detail={"loss": str(loss)},
|
|
329
|
+
)
|
|
330
|
+
if config.max_venue_notional is not None and venue_notional is not None:
|
|
331
|
+
if venue_notional + notional > config.max_venue_notional:
|
|
332
|
+
return self._no(
|
|
333
|
+
"max_venue_notional",
|
|
334
|
+
f"{venue} would hold {venue_notional + notional} of notional, over {config.max_venue_notional}",
|
|
335
|
+
)
|
|
336
|
+
return self._ok()
|
|
337
|
+
|
|
338
|
+
# -- bookkeeping ----------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
def record_sent(self, request: OrderRequest) -> None:
|
|
341
|
+
"""Count an order that actually went out, for the rate and duplicate windows."""
|
|
342
|
+
now = self.clock()
|
|
343
|
+
self._sent.append(now)
|
|
344
|
+
self._recent.append((now, (request.market_id, request.side.value, str(request.price), str(request.amount))))
|
|
345
|
+
|
|
346
|
+
def _self_cross(self, request: OrderRequest, resting: list[Order], account: Account, price: Decimal) -> Order | None:
|
|
347
|
+
scope = self.config.self_trade_prevention
|
|
348
|
+
side = request.side
|
|
349
|
+
limit = price
|
|
350
|
+
for order in resting:
|
|
351
|
+
if order.price is None:
|
|
352
|
+
continue
|
|
353
|
+
if order.market_id != request.market_id:
|
|
354
|
+
continue
|
|
355
|
+
if scope == "account":
|
|
356
|
+
order_account = order.account.key if order.account else None
|
|
357
|
+
if order_account != account.key:
|
|
358
|
+
continue
|
|
359
|
+
other_side = order.side
|
|
360
|
+
other_price = order.price
|
|
361
|
+
if other_side == side:
|
|
362
|
+
continue
|
|
363
|
+
if side == Side.BUY and limit >= other_price:
|
|
364
|
+
return order
|
|
365
|
+
if side == Side.SELL and limit <= other_price:
|
|
366
|
+
return order
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
def _net_contracts(self, account_key: str, venue: str, market_id: str) -> Decimal:
|
|
370
|
+
total = ZERO
|
|
371
|
+
for state in self.ledger.positions.values() if self.ledger else ():
|
|
372
|
+
if state.account_key == account_key and state.venue == venue and state.market_id == market_id:
|
|
373
|
+
total += state.contracts
|
|
374
|
+
return total
|
|
375
|
+
|
|
376
|
+
def _event_contracts(self, account_key: str, venue: str, event_id: str) -> Decimal:
|
|
377
|
+
total = ZERO
|
|
378
|
+
for state in self.ledger.positions.values() if self.ledger else ():
|
|
379
|
+
if state.account_key == account_key and state.venue == venue and state.market_id.startswith(event_id):
|
|
380
|
+
total += abs(state.contracts)
|
|
381
|
+
return total
|
|
382
|
+
|
|
383
|
+
def _ok(self) -> Decision:
|
|
384
|
+
return Decision(ok=True, config_version=self.config_version)
|
|
385
|
+
|
|
386
|
+
def _no(self, rule: str, message: str, *, detail: dict[str, Any] | None = None) -> Decision:
|
|
387
|
+
return Decision(ok=False, rule=rule, message=message, config_version=self.config_version, detail=detail or {})
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _day_start_ms(now: float) -> int:
|
|
391
|
+
"""Midnight UTC before `now`, in milliseconds."""
|
|
392
|
+
day = int(now) // 86_400 * 86_400
|
|
393
|
+
return day * 1000
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def market_of(market_id: str) -> str:
|
|
397
|
+
"""Kept for callers written against the instrument era; the id is the market."""
|
|
398
|
+
return market_id
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
__all__ = [
|
|
402
|
+
"RiskConfig", "RiskEngine", "Decision", "KillSwitch", "HaltPolicy", "SelfTradeScope", "ALLOWED", "market_of",
|
|
403
|
+
]
|