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,177 @@
|
|
|
1
|
+
"""A venue's request budget, read and write, with a fast lane and a hard edge.
|
|
2
|
+
|
|
3
|
+
The read API's `RateLimiter` is one bucket of requests per second. Trading
|
|
4
|
+
needs three things it does not have:
|
|
5
|
+
|
|
6
|
+
* **Two buckets.** Kalshi meters reads and writes separately, in tokens
|
|
7
|
+
per second by tier (Basic: 200 read / 100 write; an order costs 10, a
|
|
8
|
+
batch cancel 2 per item). A price loop must not starve an order.
|
|
9
|
+
* **A fast lane.** A cancel or a kill switch may not queue behind new
|
|
10
|
+
orders. A high-priority call draws from a small borrow bucket and goes
|
|
11
|
+
at once; the normal callers already waiting are pushed back by exactly
|
|
12
|
+
what it took, so the venue still sees one budget.
|
|
13
|
+
* **A hard edge.** A stop that fires late is worse than one that fails
|
|
14
|
+
loudly. A call whose wait would exceed its deadline raises
|
|
15
|
+
`RateBudgetExceeded` before anything is sent, instead of being delayed
|
|
16
|
+
and looking like it ran on time.
|
|
17
|
+
|
|
18
|
+
`configure` replaces the rates from the venue's own report (`GET
|
|
19
|
+
/account/limits` on Kalshi), so a strategy built on a Premier account does
|
|
20
|
+
not silently throttle on Basic.
|
|
21
|
+
|
|
22
|
+
How the ordering works without a queue: a normal call *reserves* its tokens
|
|
23
|
+
immediately, driving the bucket negative, and sleeps for exactly the refill
|
|
24
|
+
its own draw needs. Callers therefore go in reservation order. A fast-lane
|
|
25
|
+
draw deducts from the same bucket and adds its cost to a running `shift`;
|
|
26
|
+
each sleeping normal caller, on waking, sleeps again for whatever shift
|
|
27
|
+
accrued since it reserved. Nothing is ever dispatched out of order and the
|
|
28
|
+
sum of what goes out never exceeds the refill.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from enum import IntEnum
|
|
37
|
+
from typing import Literal
|
|
38
|
+
|
|
39
|
+
from .errors import RateBudgetExceeded
|
|
40
|
+
|
|
41
|
+
Kind = Literal["read", "write"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Priority(IntEnum):
|
|
45
|
+
HIGH = 0
|
|
46
|
+
"""Cancels, kill switch, the replace half of an edit."""
|
|
47
|
+
NORMAL = 1
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class _Bucket:
|
|
52
|
+
rate: float
|
|
53
|
+
capacity: float
|
|
54
|
+
tokens: float
|
|
55
|
+
updated: float
|
|
56
|
+
|
|
57
|
+
def refill(self, now: float) -> None:
|
|
58
|
+
self.tokens = min(self.capacity, self.tokens + (now - self.updated) * self.rate)
|
|
59
|
+
self.updated = now
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class _Lane:
|
|
64
|
+
main: _Bucket
|
|
65
|
+
"""The venue's budget, as reserved by every caller."""
|
|
66
|
+
fast: _Bucket
|
|
67
|
+
"""What a high-priority call may borrow ahead of the queue."""
|
|
68
|
+
shift: float = 0.0
|
|
69
|
+
"""Seconds of delay injected ahead of sleeping normal callers."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Reservation:
|
|
74
|
+
kind: str
|
|
75
|
+
cost: float
|
|
76
|
+
wait: float
|
|
77
|
+
shift_then: float
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class BudgetLimiter:
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
read_per_second: float,
|
|
85
|
+
write_per_second: float,
|
|
86
|
+
burst_seconds: float = 2.0,
|
|
87
|
+
borrow_seconds: float = 1.0,
|
|
88
|
+
max_wait_s: float = 5.0,
|
|
89
|
+
):
|
|
90
|
+
self.burst_seconds = burst_seconds
|
|
91
|
+
self.borrow_seconds = borrow_seconds
|
|
92
|
+
self.max_wait_s = max_wait_s
|
|
93
|
+
self._lock = threading.Lock()
|
|
94
|
+
now = time.monotonic()
|
|
95
|
+
self._lanes: dict[str, _Lane] = {
|
|
96
|
+
"read": self._lane(read_per_second, now),
|
|
97
|
+
"write": self._lane(write_per_second, now),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
def _lane(self, rate: float, now: float) -> _Lane:
|
|
101
|
+
return _Lane(
|
|
102
|
+
main=_Bucket(rate, rate * self.burst_seconds, rate * self.burst_seconds, now),
|
|
103
|
+
fast=_Bucket(rate, rate * self.borrow_seconds, rate * self.borrow_seconds, now),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def configure(self, *, read_per_second: float | None = None, write_per_second: float | None = None) -> None:
|
|
107
|
+
"""Adopt the venue's reported budget. Unspent tokens are kept, capped
|
|
108
|
+
at the new capacity."""
|
|
109
|
+
with self._lock:
|
|
110
|
+
now = time.monotonic()
|
|
111
|
+
for kind, rate in (("read", read_per_second), ("write", write_per_second)):
|
|
112
|
+
if rate is None:
|
|
113
|
+
continue
|
|
114
|
+
lane = self._lanes[kind]
|
|
115
|
+
for bucket, seconds in ((lane.main, self.burst_seconds), (lane.fast, self.borrow_seconds)):
|
|
116
|
+
bucket.refill(now)
|
|
117
|
+
bucket.rate = rate
|
|
118
|
+
bucket.capacity = rate * seconds
|
|
119
|
+
bucket.tokens = min(bucket.tokens, bucket.capacity)
|
|
120
|
+
|
|
121
|
+
def reserve(self, cost: float, kind: Kind, priority: Priority = Priority.NORMAL) -> Reservation:
|
|
122
|
+
"""Take `cost` tokens now and say how long to wait before using them."""
|
|
123
|
+
with self._lock:
|
|
124
|
+
lane = self._lanes[kind]
|
|
125
|
+
now = time.monotonic()
|
|
126
|
+
lane.main.refill(now)
|
|
127
|
+
lane.fast.refill(now)
|
|
128
|
+
if priority == Priority.HIGH and lane.fast.tokens >= cost:
|
|
129
|
+
lane.fast.tokens -= cost
|
|
130
|
+
lane.main.tokens -= cost
|
|
131
|
+
lane.shift += cost / lane.main.rate
|
|
132
|
+
return Reservation(kind, cost, 0.0, lane.shift)
|
|
133
|
+
lane.main.tokens -= cost
|
|
134
|
+
deficit = -lane.main.tokens
|
|
135
|
+
wait = deficit / lane.main.rate if deficit > 0 else 0.0
|
|
136
|
+
return Reservation(kind, cost, wait, lane.shift)
|
|
137
|
+
|
|
138
|
+
def release(self, reservation: Reservation) -> None:
|
|
139
|
+
"""Give back a reservation that will not be used."""
|
|
140
|
+
with self._lock:
|
|
141
|
+
bucket = self._lanes[reservation.kind].main
|
|
142
|
+
bucket.tokens = min(bucket.capacity, bucket.tokens + reservation.cost)
|
|
143
|
+
|
|
144
|
+
def shift_since(self, reservation: Reservation) -> float:
|
|
145
|
+
with self._lock:
|
|
146
|
+
return self._lanes[reservation.kind].shift - reservation.shift_then
|
|
147
|
+
|
|
148
|
+
async def acquire(
|
|
149
|
+
self, cost: float = 10.0, kind: Kind = "write", priority: Priority = Priority.NORMAL,
|
|
150
|
+
max_wait_s: float | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Wait for budget, or refuse if the wait would exceed the deadline."""
|
|
153
|
+
deadline = self.max_wait_s if max_wait_s is None else max_wait_s
|
|
154
|
+
reservation = self.reserve(cost, kind, priority)
|
|
155
|
+
if reservation.wait > deadline:
|
|
156
|
+
self.release(reservation)
|
|
157
|
+
raise RateBudgetExceeded(
|
|
158
|
+
f"{kind} budget: {reservation.wait:.2f}s of queue ahead, more than the "
|
|
159
|
+
f"{deadline:.2f}s this call allows; nothing was sent",
|
|
160
|
+
wait_s=reservation.wait,
|
|
161
|
+
)
|
|
162
|
+
wait = reservation.wait
|
|
163
|
+
while wait > 0:
|
|
164
|
+
await asyncio.sleep(wait)
|
|
165
|
+
# A fast-lane draw since the reservation pushed the queue back by
|
|
166
|
+
# its cost; sleep that much more, then check again.
|
|
167
|
+
wait = self.shift_since(reservation)
|
|
168
|
+
reservation = Reservation(kind, cost, 0.0, reservation.shift_then + wait)
|
|
169
|
+
|
|
170
|
+
def snapshot(self) -> dict[str, dict[str, float]]:
|
|
171
|
+
with self._lock:
|
|
172
|
+
now = time.monotonic()
|
|
173
|
+
out = {}
|
|
174
|
+
for kind, lane in self._lanes.items():
|
|
175
|
+
lane.main.refill(now)
|
|
176
|
+
out[kind] = {"rate": lane.main.rate, "capacity": lane.main.capacity, "tokens": lane.main.tokens}
|
|
177
|
+
return out
|
synpath/trading/money.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Money as `Decimal`, and the venues' wire formats for it.
|
|
2
|
+
|
|
3
|
+
The read API carries prices as `float`, the ccxt convention, and a float is
|
|
4
|
+
fine to look at. It is not fine to sign: `0.1 + 0.2` is not `0.3`, a tick of
|
|
5
|
+
`0.001` cannot be represented exactly, and a price that rounds to the wrong
|
|
6
|
+
side of a tick is an order the venue rejects at best and fills at the wrong
|
|
7
|
+
level at worst. Everything that will be signed goes through here as
|
|
8
|
+
`Decimal`, is checked against the instrument's precision, and is rendered
|
|
9
|
+
into the venue's own string format only at the boundary.
|
|
10
|
+
|
|
11
|
+
Wire formats seen on the three venues:
|
|
12
|
+
|
|
13
|
+
Kalshi fixed-point dollar strings, four decimals ("0.5600"), and
|
|
14
|
+
fixed-point contract counts, two decimals ("10.00")
|
|
15
|
+
Polymarket token and collateral amounts as integers scaled by 10^6;
|
|
16
|
+
prices as decimal strings on the instrument's tick
|
|
17
|
+
Polymarket US decimal strings, four decimals, whole contracts only
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from decimal import ROUND_DOWN, ROUND_HALF_EVEN, ROUND_HALF_UP, ROUND_UP, Decimal, InvalidOperation
|
|
22
|
+
from typing import Any, Literal
|
|
23
|
+
|
|
24
|
+
from .errors import InvalidOrder
|
|
25
|
+
from .types import Precision
|
|
26
|
+
|
|
27
|
+
Rounding = Literal["nearest", "down", "up", "bankers"]
|
|
28
|
+
_MODES = {
|
|
29
|
+
"nearest": ROUND_HALF_UP,
|
|
30
|
+
"down": ROUND_DOWN,
|
|
31
|
+
"up": ROUND_UP,
|
|
32
|
+
"bankers": ROUND_HALF_EVEN,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
ONE = Decimal("1")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def D(value: Any) -> Decimal:
|
|
39
|
+
"""A `Decimal` from anything a caller might reasonably hand over.
|
|
40
|
+
|
|
41
|
+
Floats go through `str` first, so `D(0.1)` is `0.1` and not
|
|
42
|
+
`0.1000000000000000055511151231257827`. `None` is refused rather than
|
|
43
|
+
read as zero: an absent price is not a free order.
|
|
44
|
+
"""
|
|
45
|
+
if isinstance(value, Decimal):
|
|
46
|
+
return value
|
|
47
|
+
if value is None or value == "":
|
|
48
|
+
raise InvalidOrder("a Decimal is required, got None")
|
|
49
|
+
if isinstance(value, bool):
|
|
50
|
+
raise InvalidOrder(f"a Decimal is required, got {value!r}")
|
|
51
|
+
if isinstance(value, float):
|
|
52
|
+
value = repr(value)
|
|
53
|
+
try:
|
|
54
|
+
return Decimal(str(value))
|
|
55
|
+
except InvalidOperation:
|
|
56
|
+
raise InvalidOrder(f"not a number: {value!r}") from None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def round_to(value: Decimal, step: Decimal, mode: Rounding = "nearest") -> Decimal:
|
|
60
|
+
"""`value` on the grid of `step`, rounded as asked.
|
|
61
|
+
|
|
62
|
+
`nearest` is what a human expects; `down` is what a buyer wants (never
|
|
63
|
+
pay more than typed) and `up` what a seller wants; `bankers` is how
|
|
64
|
+
Polymarket US rounds fees.
|
|
65
|
+
"""
|
|
66
|
+
if step <= 0:
|
|
67
|
+
raise InvalidOrder(f"step must be positive, got {step}")
|
|
68
|
+
quotient = (value / step).quantize(ONE, rounding=_MODES[mode])
|
|
69
|
+
return (quotient * step).quantize(step)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def complement(price: Decimal, *, face_value: Decimal = ONE) -> Decimal:
|
|
73
|
+
"""The same order seen from the other side: a YES bid at p is a NO ask at
|
|
74
|
+
`face_value - p`."""
|
|
75
|
+
return face_value - price
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def validate_price(price: Decimal, precision: Precision) -> Decimal:
|
|
79
|
+
"""`price`, or `InvalidOrder` saying exactly why the venue would refuse it.
|
|
80
|
+
|
|
81
|
+
A price is checked, never silently rounded: an order typed at 0.1065 on a
|
|
82
|
+
0.001 tick is a mistake to point out, not a 0.107 to place.
|
|
83
|
+
"""
|
|
84
|
+
tick = precision.tick
|
|
85
|
+
if price <= 0 or price >= precision.face_value:
|
|
86
|
+
raise InvalidOrder(
|
|
87
|
+
f"price {price} is outside (0, {precision.face_value}); the ends are "
|
|
88
|
+
f"placeholders, not prices"
|
|
89
|
+
)
|
|
90
|
+
if (price / tick) % 1 != 0:
|
|
91
|
+
raise InvalidOrder(f"price {price} is not on the {tick} tick; nearest are "
|
|
92
|
+
f"{round_to(price, tick, 'down')} and {round_to(price, tick, 'up')}")
|
|
93
|
+
return price
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def validate_amount(amount: Decimal, precision: Precision) -> Decimal:
|
|
97
|
+
"""`amount`, or `InvalidOrder` naming the rule it breaks."""
|
|
98
|
+
if amount <= 0:
|
|
99
|
+
raise InvalidOrder(f"amount must be positive, got {amount}")
|
|
100
|
+
if precision.whole_contracts and amount % 1 != 0:
|
|
101
|
+
raise InvalidOrder(f"amount {amount}: this venue trades whole contracts only")
|
|
102
|
+
if amount < precision.min_amount:
|
|
103
|
+
raise InvalidOrder(f"amount {amount} is below the minimum {precision.min_amount}")
|
|
104
|
+
step = precision.amount_step
|
|
105
|
+
if step and (amount / step) % 1 != 0:
|
|
106
|
+
raise InvalidOrder(f"amount {amount} is not a multiple of {step}")
|
|
107
|
+
return amount
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# -- Kalshi --------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
KALSHI_PRICE_PLACES = Decimal("0.0001")
|
|
113
|
+
KALSHI_COUNT_PLACES = Decimal("0.01")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def kalshi_dollars(price: Decimal) -> str:
|
|
117
|
+
"""A price as Kalshi V2's fixed-point dollar string: `Decimal("0.56")` -> `"0.5600"`."""
|
|
118
|
+
return str(price.quantize(KALSHI_PRICE_PLACES))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def kalshi_count(amount: Decimal) -> str:
|
|
122
|
+
"""A contract count as Kalshi's `count` string: `Decimal("10")` -> `"10.00"`."""
|
|
123
|
+
return str(amount.quantize(KALSHI_COUNT_PLACES))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def from_kalshi_cents(cents: Any) -> Decimal:
|
|
127
|
+
"""The legacy integer-cents price as a dollar `Decimal`: `56` -> `0.56`."""
|
|
128
|
+
return (D(cents) / 100).quantize(KALSHI_PRICE_PLACES)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def from_kalshi_dollars(text: Any) -> Decimal:
|
|
132
|
+
return D(text)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# -- Polymarket ----------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
POLY_SCALE = Decimal(10) ** 6
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def poly_raw_amount(amount: Decimal) -> int:
|
|
141
|
+
"""A share or USDC amount as the on-chain integer, six decimals: `1.5` -> `1500000`.
|
|
142
|
+
|
|
143
|
+
Rounded down: a maker amount rounded up is an order for more than the
|
|
144
|
+
caller has.
|
|
145
|
+
"""
|
|
146
|
+
return int((amount * POLY_SCALE).quantize(ONE, rounding=ROUND_DOWN))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def from_poly_raw_amount(raw: Any) -> Decimal:
|
|
150
|
+
return (D(raw) / POLY_SCALE).quantize(Decimal("0.000001"))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def poly_price(price: Decimal, tick: Decimal) -> str:
|
|
154
|
+
"""A price as the CLOB's decimal string, on the instrument's tick."""
|
|
155
|
+
return str(price.quantize(tick))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# -- Polymarket US -------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
POLYUS_PLACES = Decimal("0.0001")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def polyus_amount(value: Decimal) -> str:
|
|
164
|
+
"""A price or quantity as the gateway's four-place decimal string."""
|
|
165
|
+
return str(value.quantize(POLYUS_PLACES))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def from_polyus_amount(value: Any) -> Decimal:
|
|
169
|
+
"""The gateway's `{"value": "0.1060", "currency": "USD"}` or a bare string."""
|
|
170
|
+
if isinstance(value, dict):
|
|
171
|
+
value = value.get("value")
|
|
172
|
+
return D(value)
|