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,353 @@
1
+ """The ledger: what the fills add up to.
2
+
3
+ A venue tells you your position. It does not tell you what each strategy
4
+ paid for it, what is realized, or what the same exposure looks like across
5
+ three venues. That is this file's job, and it works only from fills the
6
+ journal already holds, so it can be rebuilt from the log at any time.
7
+
8
+ Three decisions shape it:
9
+
10
+ **Everything nets on the YES leg.** Buying NO at 0.40 is selling YES at
11
+ 0.60, and a book holding both is holding neither. Fills on a `:no`
12
+ instrument are converted before they are applied, so a position is one
13
+ number per market instead of two that quietly cancel. What was actually
14
+ bought is kept as well, per instrument, because on Polymarket those are real
15
+ tokens that must be redeemed or merged.
16
+
17
+ **Average cost, and realized profit taken on the way out.** A reducing fill
18
+ realizes against the average cost of what is being closed; a fill that
19
+ crosses through zero closes the old side first and opens the new one at the
20
+ fill price. Fees are realized when they are charged, never amortized into
21
+ cost, so a flat book's realized profit is what the venue actually paid.
22
+
23
+ **Unrealized profit needs a mark, and a mark is a choice.** It comes from
24
+ fair values the engine stores per account and instrument (`fair_values.py`),
25
+ not from a book this module cannot see. Without a mark a position reports
26
+ its cost and no unrealized number, rather than a made-up one.
27
+
28
+ Settlement closes a position at 1 or 0 and moves the whole remaining amount
29
+ into realized.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ from dataclasses import dataclass, field
34
+ from decimal import Decimal
35
+ from typing import Any, Iterable, Literal
36
+
37
+ from ..trading.types import Account, Fill, Position, PositionSide, Settlement, SettlementState, Side
38
+
39
+ ZERO = Decimal("0")
40
+ ONE = Decimal("1")
41
+
42
+ Level = Literal["market", "book", "account"]
43
+
44
+
45
+ @dataclass(slots=True)
46
+ class PositionState:
47
+ """One book's position in one market, netted on the YES leg."""
48
+
49
+ account_key: str
50
+ book: str
51
+ market_id: str
52
+ venue: str
53
+ contracts: Decimal = ZERO
54
+ """Signed: positive is long YES, negative is short YES (long NO)."""
55
+ average_cost: Decimal = ZERO
56
+ """Per contract, of what is currently open; for a short, what was received."""
57
+ realized: Decimal = ZERO
58
+ fees: Decimal = ZERO
59
+ bought: Decimal = ZERO
60
+ sold: Decimal = ZERO
61
+ volume: Decimal = ZERO
62
+ """Contracts traded, both directions."""
63
+ inventory: dict[str, Decimal] = field(default_factory=dict)
64
+ """Contracts held per side (`"yes"`, `"no"`) before netting, as the
65
+ engine's own fills imply: a sell adds to `no`."""
66
+ resolved: bool = False
67
+ last_ts: int | None = None
68
+
69
+ def average_price_in_bucket(self, flip: bool, face_value: Decimal = ONE) -> Decimal:
70
+ """The average cost as a price of the bucket's YES: unchanged for a
71
+ member that is the bucket's YES, reflected through the face value
72
+ for one that is its NO."""
73
+ return face_value - self.average_cost if flip else self.average_cost
74
+ fills: int = 0
75
+
76
+ @property
77
+ def side(self) -> PositionSide:
78
+ if self.contracts > 0:
79
+ return PositionSide.LONG
80
+ if self.contracts < 0:
81
+ return PositionSide.SHORT
82
+ return PositionSide.FLAT
83
+
84
+ @property
85
+ def cost(self) -> Decimal:
86
+ """What the open position tied up, positive either way."""
87
+ return abs(self.contracts) * self.average_cost
88
+
89
+ def unrealized(self, mark: Decimal | None) -> Decimal | None:
90
+ """Profit if the position were closed at `mark`. `None` without a mark."""
91
+ if mark is None or self.contracts == 0:
92
+ return None
93
+ if self.contracts > 0:
94
+ return (mark - self.average_cost) * self.contracts
95
+ # Short YES: sold at average_cost, bought back at mark.
96
+ return (self.average_cost - mark) * abs(self.contracts)
97
+
98
+ def net(self, mark: Decimal | None = None) -> Decimal:
99
+ unreal = self.unrealized(mark) or ZERO
100
+ return self.realized + unreal
101
+
102
+ def apply(self, *, side: Side, price: Decimal, amount: Decimal, fee: Decimal | None, ts: int | None) -> Decimal:
103
+ """Apply one fill already converted to the YES leg. Returns the profit
104
+ realized by this fill, fee included."""
105
+ signed = amount if side == Side.BUY else -amount
106
+ realized = ZERO
107
+ if self.contracts == 0 or (self.contracts > 0) == (signed > 0):
108
+ # Opening or adding: weighted average of what it cost.
109
+ total = abs(self.contracts) + amount
110
+ self.average_cost = ((self.average_cost * abs(self.contracts)) + (price * amount)) / total if total else ZERO
111
+ self.contracts += signed
112
+ else:
113
+ closing = min(amount, abs(self.contracts))
114
+ direction = ONE if self.contracts > 0 else -ONE
115
+ realized += (price - self.average_cost) * closing * direction
116
+ self.contracts += signed
117
+ if self.contracts == 0:
118
+ self.average_cost = ZERO
119
+ elif (self.contracts > 0) != (direction > 0):
120
+ # Crossed through zero: the remainder opens the other side.
121
+ self.average_cost = price
122
+ if fee:
123
+ self.fees += fee
124
+ realized -= fee
125
+ self.realized += realized
126
+ self.volume += amount
127
+ if side == Side.BUY:
128
+ self.bought += amount
129
+ else:
130
+ self.sold += amount
131
+ held = "yes" if side == Side.BUY else "no"
132
+ self.inventory[held] = self.inventory.get(held, ZERO) + amount
133
+ self.fills += 1
134
+ if ts is not None:
135
+ self.last_ts = max(ts, self.last_ts or 0)
136
+ return realized
137
+
138
+ def settle(self, price: Decimal) -> Decimal:
139
+ """Resolution: everything still open pays out at `price` (1 or 0)."""
140
+ if self.contracts == 0:
141
+ self.resolved = True
142
+ return ZERO
143
+ realized = (price - self.average_cost) * self.contracts
144
+ self.realized += realized
145
+ self.contracts = ZERO
146
+ self.average_cost = ZERO
147
+ self.resolved = True
148
+ return realized
149
+
150
+ def to_position(self, account: Account | None = None, mark: Decimal | None = None) -> Position:
151
+ """The unified `Position` a caller sees."""
152
+ return Position(
153
+ venue=self.venue, account=account, market_id=self.market_id,
154
+ side=self.side, contracts=abs(self.contracts),
155
+ inventory_yes=self.inventory.get("yes"), inventory_no=self.inventory.get("no"),
156
+ entry_price=self.average_cost if self.contracts else None, mark_price=mark,
157
+ unrealized_pnl=self.unrealized(mark), realized_pnl=self.realized, resolved=self.resolved,
158
+ timestamp=self.last_ts,
159
+ info={"book": self.book, "fees": str(self.fees), "volume": str(self.volume), "fills": self.fills},
160
+ )
161
+
162
+
163
+ @dataclass(slots=True)
164
+ class Rollup:
165
+ """A level of the tree: one instrument, market, book or account."""
166
+
167
+ level: Level
168
+ key: str
169
+ contracts: Decimal = ZERO
170
+ """Net signed contracts; for a roll-up above one market it is the sum,
171
+ which nets long one market against short another only if they are the
172
+ same market."""
173
+ cost: Decimal = ZERO
174
+ realized: Decimal = ZERO
175
+ unrealized: Decimal | None = None
176
+ fees: Decimal = ZERO
177
+ volume: Decimal = ZERO
178
+ positions: int = 0
179
+ marked: int = 0
180
+ """How many of the open positions had a mark; the rest are cost only."""
181
+
182
+ @property
183
+ def total(self) -> Decimal:
184
+ return self.realized + (self.unrealized or ZERO)
185
+
186
+
187
+ class Ledger:
188
+ """Positions and profit, per account, book and market.
189
+
190
+ Built by replaying fills in order. A fill already applied is ignored, so
191
+ replaying the journal twice cannot double a position.
192
+ """
193
+
194
+ def __init__(self, *, face_value: Decimal = ONE):
195
+ self.face_value = face_value
196
+ self.positions: dict[tuple[str, str, str], PositionState] = {}
197
+ self.seen: set[tuple[str, str]] = set()
198
+ self.settled: set[tuple[str, str, str]] = set()
199
+
200
+ # -- applying -------------------------------------------------------------
201
+
202
+ def apply_fill(self, fill: Fill, *, book: str | None = None) -> Decimal:
203
+ """Book one fill. Returns the realized profit it produced."""
204
+ if (fill.venue, fill.id) in self.seen:
205
+ return ZERO
206
+ self.seen.add((fill.venue, fill.id))
207
+ if fill.settlement == SettlementState.FAILED:
208
+ # Matched and then lost on chain: it never happened.
209
+ return ZERO
210
+ state = self.state_for(fill, book=book)
211
+ # Fills arrive on the YES leg already: `sell` is the NO side at the YES price.
212
+ return state.apply(side=fill.side, price=fill.price, amount=fill.amount, fee=fill.fee, ts=fill.timestamp)
213
+
214
+ def apply_fills(self, fills: Iterable[Fill], *, book: str | None = None) -> Decimal:
215
+ return sum((self.apply_fill(f, book=book) for f in fills), ZERO)
216
+
217
+ def apply_settlement(self, settlement: Settlement, *, book: str | None = None) -> Decimal:
218
+ """A market resolved. Closes every book's position in it at the payout."""
219
+ price = _settlement_price(settlement, self.face_value)
220
+ realized = ZERO
221
+ for key, state in self.positions.items():
222
+ if state.venue == settlement.venue and state.market_id == settlement.market_id:
223
+ if key in self.settled:
224
+ continue
225
+ if book is not None and state.book != book:
226
+ continue
227
+ realized += state.settle(price)
228
+ self.settled.add(key)
229
+ return realized
230
+
231
+ def state_for(self, fill: Fill, *, book: str | None = None) -> PositionState:
232
+ account_key = fill.account.key if fill.account else f"{fill.venue}:default"
233
+ name = book or (fill.info.get("book") if isinstance(fill.info, dict) else None) or "default"
234
+ key = (account_key, name, fill.market_id)
235
+ state = self.positions.get(key)
236
+ if state is None:
237
+ state = self.positions[key] = PositionState(
238
+ account_key=account_key, book=name, market_id=fill.market_id, venue=fill.venue,
239
+ )
240
+ return state
241
+
242
+ # -- reading --------------------------------------------------------------
243
+
244
+ def open_positions(self) -> list[PositionState]:
245
+ return [p for p in self.positions.values() if p.contracts != 0]
246
+
247
+ def position(self, account_key: str, book: str, venue: str, market_id: str) -> PositionState | None:
248
+ return self.positions.get((account_key, book, market_id))
249
+
250
+ def rollup(self, level: Level, marks: dict[tuple[str, str], Decimal] | None = None) -> dict[str, Rollup]:
251
+ """Aggregate every position to one of the four levels.
252
+
253
+ `marks` is keyed by `(account_key, market_id)`, as the journal
254
+ stores fair values.
255
+ """
256
+ out: dict[str, Rollup] = {}
257
+ for state in self.positions.values():
258
+ if level in ("market", "instrument"):
259
+ key = state.market_id
260
+ elif level == "market":
261
+ key = f"{state.venue}:{state.market_id}"
262
+ elif level == "book":
263
+ key = state.book
264
+ else:
265
+ key = state.account_key
266
+ row = out.get(key)
267
+ if row is None:
268
+ row = out[key] = Rollup(level=level, key=key)
269
+ mark = (marks or {}).get((state.account_key, state.market_id))
270
+ unreal = state.unrealized(mark)
271
+ row.contracts += state.contracts
272
+ row.cost += state.cost
273
+ row.realized += state.realized
274
+ row.fees += state.fees
275
+ row.volume += state.volume
276
+ row.positions += 1 if state.contracts else 0
277
+ if unreal is not None:
278
+ row.unrealized = (row.unrealized or ZERO) + unreal
279
+ row.marked += 1
280
+ return out
281
+
282
+ def total(self, marks: dict[tuple[str, str], Decimal] | None = None) -> Rollup:
283
+ """The firm's line: everything, every account, every book."""
284
+ row = Rollup(level="account", key="*")
285
+ for part in self.rollup("account", marks).values():
286
+ row.contracts += part.contracts
287
+ row.cost += part.cost
288
+ row.realized += part.realized
289
+ row.fees += part.fees
290
+ row.volume += part.volume
291
+ row.positions += part.positions
292
+ row.marked += part.marked
293
+ if part.unrealized is not None:
294
+ row.unrealized = (row.unrealized or ZERO) + part.unrealized
295
+ return row
296
+
297
+ def merged(self, venue_positions: Iterable[Position]) -> list[dict[str, Any]]:
298
+ """The ledger and the venue side by side, read only.
299
+
300
+ One row per account and market: what this engine's books add up to,
301
+ what the venue says, and the difference. A non-zero difference is a
302
+ position somebody else in the account opened, or a fill this engine
303
+ has not seen; `reconcile.py` turns it into an event.
304
+ """
305
+ mine: dict[tuple[str, str], Decimal] = {}
306
+ books: dict[tuple[str, str], dict[str, Decimal]] = {}
307
+ for state in self.positions.values():
308
+ key = (state.account_key, state.market_id)
309
+ mine[key] = mine.get(key, ZERO) + state.contracts
310
+ books.setdefault(key, {})[state.book] = state.contracts
311
+ rows: list[dict[str, Any]] = []
312
+ seen: set[tuple[str, str]] = set()
313
+ for position in venue_positions:
314
+ account_key = position.account.key if position.account else f"{position.venue}:default"
315
+ signed = position.contracts if position.side != PositionSide.SHORT else -position.contracts
316
+ key = (account_key, position.market_id)
317
+ seen.add(key)
318
+ engine = mine.get(key, ZERO)
319
+ rows.append({
320
+ "account": account_key, "venue": position.venue,
321
+ "market_id": position.market_id, "engine": engine, "venue_contracts": signed,
322
+ "difference": engine - signed, "books": books.get(key, {}),
323
+ })
324
+ for key, engine in mine.items():
325
+ if key in seen or engine == 0:
326
+ continue
327
+ account_key, market_id = key
328
+ venue, _, _ = market_id.partition(":")
329
+ rows.append({
330
+ "account": account_key, "venue": venue,
331
+ "market_id": market_id, "engine": engine, "venue_contracts": ZERO,
332
+ "difference": engine, "books": books.get(key, {}),
333
+ })
334
+ return sorted(rows, key=lambda r: (r["account"], r["venue"], r["market_id"]))
335
+
336
+
337
+ def _settlement_price(settlement: Settlement, face_value: Decimal) -> Decimal:
338
+ """What one YES contract paid at resolution.
339
+
340
+ The venues say this three different ways, so take them in order of how
341
+ directly they answer the question: the outcome word, then the flag, then
342
+ the payout divided by what it was paid on.
343
+ """
344
+ result = (settlement.result or "").strip().lower()
345
+ if result in ("yes", "true", "1"):
346
+ return face_value
347
+ if result in ("no", "false", "0"):
348
+ return ZERO
349
+ if settlement.won is not None:
350
+ return face_value if settlement.won else ZERO
351
+ if settlement.payout is not None and settlement.amount:
352
+ return Decimal(settlement.payout) / Decimal(settlement.amount)
353
+ return ZERO
@@ -0,0 +1,42 @@
1
+ """synpath.engine.orders -- the order types the venues do not hold.
2
+
3
+ Stops (market, limit, trailing), icebergs, one-cancels-the-other and
4
+ brackets, TWAP, pegs, and the two taking types: an engine-held market order
5
+ that walks the book inside a price bound, and a smart taker that does it in
6
+ clips over time. Day is not here: it is rewritten to a venue-held GTD at the
7
+ session end, because an expiry must outlive the process (`day.py`).
8
+
9
+ Each type is a `ManagedOrder`: a state machine whose state is written to the
10
+ journal on every change and restored on start, submitting ordinary venue
11
+ orders as children so the risk rules, the ledger and reconciliation see them
12
+ like anything else.
13
+
14
+ ```python
15
+ from decimal import Decimal
16
+ from synpath.trading.types import OrderRequest, OrderType, Side
17
+
18
+ await engine.submit(OrderRequest(
19
+ market_id="kalshi:KXX", side=Side.SELL, amount=Decimal("20"),
20
+ type=OrderType.TRAILING_STOP, stop_price=Decimal("0.40"),
21
+ params={"trail": "0.03", "trigger_source": "touch"},
22
+ ))
23
+ ```
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from .base import Child, Context, ManagedOrder, State
28
+ from .day import as_gtd, session_expiry
29
+ from .iceberg import Iceberg
30
+ from .manager import REGISTRY, ManagedOrders, register
31
+ from .oco import OCO, Bracket
32
+ from .peg import Peg
33
+ from .routed import RoutedLimit
34
+ from .stop import StopLimit, StopMarket, TrailingStop
35
+ from .taker import MarketOrder, SmartTaker
36
+ from .twap import TWAP
37
+
38
+ __all__ = [
39
+ "ManagedOrder", "ManagedOrders", "Context", "Child", "State", "register", "REGISTRY",
40
+ "StopMarket", "StopLimit", "TrailingStop", "Iceberg", "OCO", "Bracket", "TWAP", "Peg",
41
+ "MarketOrder", "SmartTaker", "as_gtd", "session_expiry",
42
+ ]