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