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
synpath/bucket.py ADDED
@@ -0,0 +1,154 @@
1
+ """Buckets: one tradable thing made of several venues' listings.
2
+
3
+ A bucket sits where a single market does. You place an order on it the way
4
+ you place one on `kalshi:KXFOO-25`; the engine's router turns that into legs
5
+ on the member markets. It is the user's own definition, a list of member
6
+ markets and, for each, whether that member's YES is the bucket's YES or the
7
+ opposite (`flip`). Nothing here judges whether the members really are the
8
+ same question; that is the caller's call, or a matching service's, later.
9
+
10
+ Ids are UUIDs made locally, so a bucket created offline stays unique when
11
+ its definition later moves to a hosted account. Ownership is the `book`, the
12
+ same cross-venue strategy label orders and P&L already roll up by.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import uuid
17
+ from decimal import Decimal
18
+ from typing import Literal
19
+
20
+ from pydantic import Field
21
+
22
+ from . import ids
23
+ from .errors import BadRequest
24
+ from .trading.types import Side
25
+ from .types import BookSide, _Base
26
+
27
+ ONE = Decimal("1")
28
+ BUCKET_PREFIX = "bucket:"
29
+
30
+
31
+ class BucketMember(_Base):
32
+ market_id: str
33
+ """A Synpath id, `venue:native`."""
34
+ flip: bool = False
35
+ """True when this member's YES is the opposite of the bucket's proposition."""
36
+
37
+ def book_side(self) -> BookSide:
38
+ """Which of the member's two books is the bucket's YES book. Reading
39
+ the NO book of a flipped member gives prices already in bucket terms,
40
+ so nothing downstream converts."""
41
+ return "no" if self.flip else "yes"
42
+
43
+ def to_member(self, side: Side, price: Decimal) -> tuple[Side, Decimal]:
44
+ """A bucket-side order as the member venue must receive it. Prices in
45
+ this library are always the YES price and `sell` is the NO side, so
46
+ buying a flipped member's NO at p is `sell` at 1 - p."""
47
+ if not self.flip:
48
+ return side, price
49
+ return (Side.SELL if side == Side.BUY else Side.BUY), ONE - price
50
+
51
+ def to_bucket(self, side: Side, price: Decimal) -> tuple[Side, Decimal]:
52
+ """A member fill back in bucket terms. The same map; it is its own inverse."""
53
+ return self.to_member(side, price)
54
+
55
+
56
+ class Bucket(_Base):
57
+ id: str = Field(default_factory=lambda: uuid.uuid4().hex)
58
+ book: str
59
+ name: str
60
+ members: list[BucketMember]
61
+ status: Literal["active", "archived"] = "active"
62
+ created_ms: int | None = None
63
+ updated_ms: int | None = None
64
+
65
+ @property
66
+ def market_id(self) -> str:
67
+ """What an order names to target this bucket: `bucket:<id>`."""
68
+ return BUCKET_PREFIX + self.id
69
+
70
+ def market_ids(self) -> list[str]:
71
+ return [m.market_id for m in self.members]
72
+
73
+ def venues(self) -> set[str]:
74
+ return {ids.venue_of(m.market_id) for m in self.members}
75
+
76
+ def member(self, market_id: str) -> BucketMember:
77
+ for m in self.members:
78
+ if m.market_id == market_id:
79
+ return m
80
+ raise KeyError(market_id)
81
+
82
+ def check(self) -> None:
83
+ """The shape checks a definition must pass before it is stored or
84
+ traded: well-formed ids on known venues, no duplicates, at least two
85
+ members. Whether the members mean the same thing is not checked."""
86
+ if len(self.members) < 2:
87
+ raise BadRequest("a bucket needs at least two members")
88
+ seen: set[str] = set()
89
+ for m in self.members:
90
+ ids.venue_of(m.market_id) # raises BadRequest on a bare or unknown id
91
+ if m.market_id in seen:
92
+ raise BadRequest(f"{m.market_id} is listed twice")
93
+ seen.add(m.market_id)
94
+
95
+
96
+ class BucketVenueFill(_Base):
97
+ venue: str
98
+ filled: Decimal
99
+ average_price: Decimal | None = Field(description="In bucket terms")
100
+
101
+
102
+ class BucketOrderReport(_Base):
103
+ """What an order on a bucket has done, in bucket terms, and where."""
104
+
105
+ order_id: str
106
+ bucket_id: str
107
+ status: str = Field(description="As on the order: `open`, `closed`, `canceled`, ...")
108
+ side: str
109
+ amount: Decimal
110
+ worst_price: Decimal = Field(description="The worst price the order accepts, in bucket terms")
111
+ filled: Decimal
112
+ unfilled: Decimal
113
+ average_price: Decimal | None = Field(description="Weighted across every venue, in bucket terms")
114
+ fees_paid: Decimal
115
+ rounds: int = Field(description="Re-allocations that moved a leg")
116
+ stop_reason: str | None = Field(description="`worst_price` (nothing left at or better than it), `liquidity`, `min_amount`, `max_rounds`, `max_age`, "
117
+ "or null while working or once filled")
118
+ per_venue: list[BucketVenueFill]
119
+ detail: str | None = Field(default=None, description="Why the order was rejected or stopped, in words; "
120
+ "null while it works normally")
121
+
122
+
123
+ class BucketMemberPosition(_Base):
124
+ market_id: str
125
+ venue: str
126
+ book: str
127
+ account: str
128
+ flip: bool
129
+ contracts: Decimal = Field(description="On the member's own YES leg")
130
+ bucket_contracts: Decimal = Field(description="The same, in bucket terms: negated when `flip`")
131
+ entry_price: Decimal | None
132
+ mark: Decimal | None
133
+
134
+
135
+ class BucketPosition(_Base):
136
+ bucket_id: str
137
+ name: str
138
+ book: str
139
+ contracts: Decimal = Field(description="Net across members, in bucket terms; negative is short")
140
+ side: Literal["long", "short", "flat"]
141
+ entry_price: Decimal | None
142
+ realized: Decimal
143
+ fees: Decimal
144
+ members: list[BucketMemberPosition]
145
+
146
+
147
+ def is_bucket_id(market_id: str) -> bool:
148
+ return market_id.startswith(BUCKET_PREFIX)
149
+
150
+
151
+ def bucket_id_of(market_id: str) -> str:
152
+ if not is_bucket_id(market_id):
153
+ raise BadRequest(f"{market_id!r} is not a bucket id")
154
+ return market_id[len(BUCKET_PREFIX):]
synpath/client.py ADDED
@@ -0,0 +1,356 @@
1
+ """One client for every venue, routed by the venue in each Synpath id.
2
+
3
+ ```python
4
+ import synpath
5
+
6
+ client = synpath.Client() # market data needs no credentials
7
+ market = client.fetch_market("kalshi:KXFEDDECISION-26SEP-C25")
8
+ book = client.fetch_order_book("polymarket:2252244", side="no")
9
+
10
+ client = synpath.Client(synpath.load_credentials()) # KALSHI_*, POLYMARKET_* from .env
11
+ async with client:
12
+ order = await client.create_order(OrderRequest(
13
+ market_id=market.id, side="buy", type="limit", price="0.05", amount=5,
14
+ ))
15
+ await client.cancel_order(order.id, market_id=order.market_id)
16
+ ```
17
+
18
+ ```python
19
+ client = synpath.Client(server="http://127.0.0.1:8000") # your own `synpath serve`
20
+ order = await client.create_order(OrderRequest(..., type="stop_market", stop_price="0.40"))
21
+ ```
22
+
23
+ With `server=`, every trading call goes to that server's engine instead of to
24
+ the venues, which is how the engine's order types (stops, icebergs, TWAP,
25
+ orders on a bucket) are reached from Python. Market data stays direct. The
26
+ server holds the venue credentials; this side holds only the server's access token,
27
+ found in `~/.synpath/servers.json` for a server on this machine, or given as
28
+ `access_token=` / `SYNPATH_ACCESS_TOKEN` for one across the network.
29
+
30
+ Every id in this library starts with its venue (`kalshi:...`, `polymarket:...`),
31
+ so a call that names a market knows where to go. The per-venue adapters
32
+ (`synpath.Kalshi`, `synpath.KalshiTrading`, ...) are what this routes to,
33
+ built on first use and shared after; a caller that wants one venue only can
34
+ keep using them directly.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ from decimal import Decimal
39
+ from typing import Any, Iterable, Mapping
40
+
41
+ from . import ids
42
+ from .base import Exchange
43
+ from .bucket import Bucket, BucketMember, BucketOrderReport, BucketPosition
44
+ from .errors import BadRequest
45
+ from .trading.base import TradingExchange
46
+ from .trading.credentials import Credentials, require
47
+ from .trading.types import (
48
+ Balance, EditRequest, FeeEstimate, Fill, Order, OrderRequest, Position, Settlement, Side,
49
+ )
50
+ from .types import BookSide, Candle, FeeSchedule, Market, OrderBook, Page, Trade
51
+
52
+ __all__ = ["Client"]
53
+
54
+
55
+ class Client:
56
+ """Reads on any venue, and order entry on the venues you hold credentials for."""
57
+
58
+ def __init__(
59
+ self,
60
+ credentials: Mapping[str, Credentials | None] | None = None,
61
+ *,
62
+ exchanges: Mapping[str, Exchange] | None = None,
63
+ trading: Mapping[str, TradingExchange] | None = None,
64
+ server: str | None = None,
65
+ access_token: str | None = None,
66
+ server_http: Any = None,
67
+ ):
68
+ """`credentials` is what `synpath.load_credentials()` returns. `exchanges`
69
+ and `trading` let a caller hand in adapters it already built (a paper
70
+ venue, a test double); anything not given is built on first use.
71
+ `server` is the root URL of your own `synpath serve`; with it, trading
72
+ goes through that server's engine. `server_http` injects an httpx
73
+ client for tests."""
74
+ self.credentials: dict[str, Credentials | None] = dict(credentials or {})
75
+ self._exchanges: dict[str, Exchange] = dict(exchanges or {})
76
+ self._trading: dict[str, TradingExchange] = dict(trading or {})
77
+ self.server = server
78
+ self._remote: Any = None
79
+ if server:
80
+ from .remote import RemoteTrading
81
+ self._remote = RemoteTrading(server, access_token, http_client=server_http)
82
+
83
+ # -- adapters -------------------------------------------------------------
84
+
85
+ def exchange(self, venue: str) -> Exchange:
86
+ """The read adapter for a venue, built once."""
87
+ if venue not in self._exchanges:
88
+ from . import exchange
89
+ self._exchanges[venue] = exchange(venue)
90
+ return self._exchanges[venue]
91
+
92
+ def trading(self, venue: str) -> TradingExchange:
93
+ """The trading adapter for a venue, built once from its credentials.
94
+ `CredentialsMissing` names the variables to set when there are none."""
95
+ if venue not in self._trading:
96
+ creds = require(venue, self.credentials)
97
+ self._trading[venue] = _TRADING[venue](creds) # type: ignore[index]
98
+ return self._trading[venue]
99
+
100
+ def _venue(self, market_id: str) -> str:
101
+ return ids.venue_of(market_id)
102
+
103
+ # -- market data (sync, like the read adapters) ---------------------------
104
+
105
+ def fetch_market(self, market_id: str) -> Market:
106
+ return self.exchange(self._venue(market_id)).fetch_market(market_id)
107
+
108
+ def fetch_markets_by_ids(self, market_ids: list[str]) -> list[Market]:
109
+ """Many markets, across venues, in the order asked for."""
110
+ found: dict[str, Market] = {}
111
+ for venue, batch in _by_venue(market_ids).items():
112
+ for market in self.exchange(venue).fetch_markets_by_ids(batch):
113
+ found[market.id] = market
114
+ return [found[m] for m in (ids.qualify(self._venue(m), ids.split(m)[1]) for m in market_ids) if m in found]
115
+
116
+ def fetch_markets(
117
+ self, *, venue: str | None = None, query: str | None = None, limit: int | None = None,
118
+ cursor: str | None = None, status: str = "open", sort: str | None = None,
119
+ ) -> Page[Market]:
120
+ """One venue's page, or without `venue` the first page of every
121
+ venue concatenated (no cursor across venues)."""
122
+ if venue is not None:
123
+ return self.exchange(venue).fetch_markets(query=query, limit=limit, cursor=cursor, status=status, sort=sort)
124
+ if cursor is not None:
125
+ raise BadRequest("a cursor belongs to one venue; pass venue= to continue paging")
126
+ markets: list[Market] = []
127
+ for name in ids.VENUES:
128
+ markets.extend(self.exchange(name).fetch_markets(query=query, limit=limit, status=status, sort=sort))
129
+ return Page(markets, next_cursor=None)
130
+
131
+ def fetch_order_book(self, market_id: str, *, side: BookSide = "yes", depth: int | None = None) -> OrderBook:
132
+ return self.exchange(self._venue(market_id)).fetch_order_book(market_id, side=side, depth=depth)
133
+
134
+ def fetch_order_books(
135
+ self, market_ids: list[str], *, side: BookSide = "yes", depth: int | None = None,
136
+ ) -> dict[str, OrderBook]:
137
+ books: dict[str, OrderBook] = {}
138
+ for venue, batch in _by_venue(market_ids).items():
139
+ books.update(self.exchange(venue).fetch_order_books(batch, side=side, depth=depth))
140
+ return books
141
+
142
+ def fetch_trades(
143
+ self, market_id: str, *, since: int | None = None, limit: int | None = None, cursor: str | None = None,
144
+ ) -> Page[Trade]:
145
+ return self.exchange(self._venue(market_id)).fetch_trades(market_id, since=since, limit=limit, cursor=cursor)
146
+
147
+ def fetch_ohlcv(
148
+ self, market_id: str, *, timeframe: str = "1h", since: int | None = None,
149
+ until: int | None = None, limit: int | None = None,
150
+ ) -> list[Candle]:
151
+ return self.exchange(self._venue(market_id)).fetch_ohlcv(
152
+ market_id, timeframe=timeframe, since=since, until=until, limit=limit,
153
+ )
154
+
155
+ def fetch_fee_schedule(self, market_id: str) -> FeeSchedule:
156
+ return self.exchange(self._venue(market_id)).fetch_fee_schedule(market_id)
157
+
158
+ # -- order entry (async, like the trading adapters) -----------------------
159
+
160
+ async def create_order(self, request: OrderRequest) -> Order:
161
+ if self._remote:
162
+ return await self._remote.create_order(request)
163
+ return await self.trading(self._venue(request.market_id)).create_order(request)
164
+
165
+ async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]:
166
+ """Many orders, each sent to its own venue, results in the order given."""
167
+ if self._remote:
168
+ return await self._remote.create_orders(requests)
169
+ results: list[Order | Exception | None] = [None] * len(requests)
170
+ groups: dict[str, list[int]] = {}
171
+ for i, request in enumerate(requests):
172
+ try:
173
+ groups.setdefault(self._venue(request.market_id), []).append(i)
174
+ except BadRequest as exc:
175
+ results[i] = exc
176
+ for venue, indexes in groups.items():
177
+ answers = await self.trading(venue).create_orders([requests[i] for i in indexes])
178
+ for i, answer in zip(indexes, answers):
179
+ results[i] = answer
180
+ return results # type: ignore[return-value]
181
+
182
+ async def create_bucket(self, *, book: str, name: str, members: list[BucketMember]) -> Bucket:
183
+ """Define a bucket on your server; place orders on it by `bucket.market_id`.
184
+
185
+ A bucket lives in the server's journal, beside the engine that routes
186
+ its orders, so it needs `Client(server=...)`."""
187
+ return await self._server().create_bucket(book=book, name=name, members=members)
188
+
189
+ def _server(self) -> Any:
190
+ if not self._remote:
191
+ raise BadRequest("buckets are kept by your synpath serve: create the client with server=")
192
+ return self._remote
193
+
194
+ async def fetch_buckets(self, *, book: str | None = None, status: str = "active") -> list[Bucket]:
195
+ """Your server's buckets: `active` (default), `archived` or `all`."""
196
+ return await self._server().fetch_buckets(book=book, status=status)
197
+
198
+ async def fetch_bucket(self, bucket_id: str) -> Bucket:
199
+ """One bucket, by `<id>` or by its market id `bucket:<id>`."""
200
+ return await self._server().fetch_bucket(bucket_id)
201
+
202
+ async def archive_bucket(self, bucket_id: str) -> Bucket:
203
+ """Retire a bucket: no new orders on it. Orders already on it keep working."""
204
+ return await self._server().archive_bucket(bucket_id)
205
+
206
+ async def fetch_bucket_position(self, bucket_id: str, *, book: str | None = None) -> BucketPosition:
207
+ """The position across a bucket's members, netted in bucket terms."""
208
+ return await self._server().fetch_bucket_position(bucket_id, book=book)
209
+
210
+ async def fetch_bucket_orders(self, bucket_id: str) -> list[BucketOrderReport]:
211
+ """Every order placed on a bucket, live or finished, with where it filled."""
212
+ return await self._server().fetch_bucket_orders(bucket_id)
213
+
214
+ async def fetch_bucket_order(self, bucket_id: str, order_id: str) -> BucketOrderReport:
215
+ """One order on a bucket: filled, weighted average, and the split by venue."""
216
+ return await self._server().fetch_bucket_order(bucket_id, order_id)
217
+
218
+ async def cancel_order(self, order_id: str, *, market_id: str | None = None, venue: str | None = None) -> Order:
219
+ """Cancel by id. The venue comes from `market_id` or `venue`; an order
220
+ id alone does not say where it lives, except to a server, which knows."""
221
+ if self._remote:
222
+ return await self._remote.cancel_order(order_id, market_id=market_id, venue=venue)
223
+ return await self.trading(_venue_of(market_id, venue)).cancel_order(order_id, market_id=market_id)
224
+
225
+ async def cancel_all_orders(self, *, market_id: str | None = None, venue: str | None = None) -> int | None:
226
+ """One market's, one venue's, or every venue's resting orders."""
227
+ if self._remote:
228
+ return await self._remote.cancel_all_orders(market_id=market_id, venue=venue)
229
+ if market_id is None and venue is None:
230
+ total: int | None = 0
231
+ for name in list(self._trading):
232
+ count = await self._trading[name].cancel_all_orders()
233
+ total = None if count is None or total is None else total + count
234
+ return total
235
+ return await self.trading(_venue_of(market_id, venue)).cancel_all_orders(market_id=market_id)
236
+
237
+ async def edit_order(self, request: EditRequest, *, venue: str | None = None, current: Order | None = None) -> Order:
238
+ if self._remote:
239
+ return await self._remote.edit_order(request, venue=venue, current=current)
240
+ if venue is None:
241
+ raise BadRequest("edit_order needs venue= when trading the venues directly")
242
+ return await self.trading(venue).edit_order(request, current=current)
243
+
244
+ async def fetch_order(self, order_id: str, *, market_id: str | None = None, venue: str | None = None) -> Order:
245
+ if self._remote:
246
+ return await self._remote.fetch_order(order_id, market_id=market_id, venue=venue)
247
+ return await self.trading(_venue_of(market_id, venue)).fetch_order(order_id)
248
+
249
+ async def fetch_open_orders(self, *, market_id: str | None = None, venue: str | None = None) -> list[Order]:
250
+ """One market's, one venue's, or every connected venue's open orders."""
251
+ if self._remote:
252
+ return await self._remote.fetch_open_orders(market_id=market_id, venue=venue)
253
+ if market_id is None and venue is None:
254
+ orders: list[Order] = []
255
+ for name in list(self._trading):
256
+ orders.extend(await self._trading[name].fetch_open_orders())
257
+ return orders
258
+ return await self.trading(_venue_of(market_id, venue)).fetch_open_orders(market_id=market_id)
259
+
260
+ async def fetch_my_trades(
261
+ self, *, market_id: str | None = None, venue: str | None = None, since: int | None = None,
262
+ limit: int | None = None, cursor: str | None = None,
263
+ ) -> Page[Fill]:
264
+ if self._remote:
265
+ return await self._remote.fetch_my_trades(market_id=market_id, venue=venue, since=since,
266
+ limit=limit, cursor=cursor)
267
+ return await self.trading(_venue_of(market_id, venue)).fetch_my_trades(
268
+ market_id=market_id, since=since, limit=limit, cursor=cursor,
269
+ )
270
+
271
+ async def fetch_positions(self, *, market_id: str | None = None, venue: str | None = None) -> list[Position]:
272
+ """One market's, one venue's, or every connected venue's positions."""
273
+ if self._remote:
274
+ return await self._remote.fetch_positions(market_id=market_id, venue=venue)
275
+ if market_id is None and venue is None:
276
+ positions: list[Position] = []
277
+ for name in list(self._trading):
278
+ positions.extend(await self._trading[name].fetch_positions())
279
+ return positions
280
+ return await self.trading(_venue_of(market_id, venue)).fetch_positions(market_id=market_id)
281
+
282
+ async def fetch_balance(self, venue: str) -> Balance:
283
+ if self._remote:
284
+ return await self._remote.fetch_balance(venue)
285
+ return await self.trading(venue).fetch_balance()
286
+
287
+ async def fetch_settlements(
288
+ self, *, market_id: str | None = None, venue: str | None = None, since: int | None = None,
289
+ limit: int | None = None, cursor: str | None = None,
290
+ ) -> Page[Settlement]:
291
+ if self._remote and not self.credentials:
292
+ return await self._remote.fetch_settlements()
293
+ return await self.trading(_venue_of(market_id, venue)).fetch_settlements(
294
+ market_id=market_id, since=since, limit=limit, cursor=cursor,
295
+ )
296
+
297
+ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, amount: Decimal) -> FeeEstimate:
298
+ if self._remote and not self.credentials:
299
+ return await self._remote.fetch_fee_estimate(market_id, side, price, amount)
300
+ return await self.trading(self._venue(market_id)).fetch_fee_estimate(market_id, side, price, amount)
301
+
302
+ # -- housekeeping ---------------------------------------------------------
303
+
304
+ async def close(self) -> None:
305
+ if self._remote is not None:
306
+ await self._remote.close()
307
+ for adapter in self._trading.values():
308
+ await adapter.close()
309
+ for exchange in self._exchanges.values():
310
+ exchange.close()
311
+
312
+ async def __aenter__(self) -> "Client":
313
+ return self
314
+
315
+ async def __aexit__(self, *exc: Any) -> None:
316
+ await self.close()
317
+
318
+ def __repr__(self) -> str:
319
+ via = f" server={self.server}" if self.server else ""
320
+ return f"<synpath.Client reads={sorted(self._exchanges)} trades={sorted(self._trading)}{via}>"
321
+
322
+
323
+ def _by_venue(market_ids: Iterable[str]) -> dict[str, list[str]]:
324
+ groups: dict[str, list[str]] = {}
325
+ for market_id in market_ids:
326
+ groups.setdefault(ids.venue_of(market_id), []).append(market_id)
327
+ return groups
328
+
329
+
330
+ def _venue_of(market_id: str | None, venue: str | None) -> str:
331
+ if market_id is not None:
332
+ found = ids.venue_of(market_id)
333
+ if venue is not None and venue != found:
334
+ raise BadRequest(f"{market_id!r} belongs to {found}, not {venue}")
335
+ return found
336
+ if venue is None:
337
+ raise BadRequest("pass market_id= or venue= so the call can be routed")
338
+ return venue
339
+
340
+
341
+ def _kalshi(creds: Credentials) -> TradingExchange:
342
+ from .trading.kalshi import KalshiTrading
343
+ return KalshiTrading(creds) # type: ignore[arg-type]
344
+
345
+
346
+ def _polymarket(creds: Credentials) -> TradingExchange:
347
+ from .trading.polymarket import PolymarketTrading
348
+ return PolymarketTrading(creds) # type: ignore[arg-type]
349
+
350
+
351
+ def _polymarket_us(creds: Credentials) -> TradingExchange:
352
+ from .trading.polymarket_us import PolymarketUSTrading
353
+ return PolymarketUSTrading(creds) # type: ignore[arg-type]
354
+
355
+
356
+ _TRADING = {"kalshi": _kalshi, "polymarket": _polymarket, "polymarket_us": _polymarket_us}
@@ -0,0 +1,37 @@
1
+ """synpath.engine -- order entry with a journal, a ledger and risk rules.
2
+
3
+ ```python
4
+ from synpath.engine import Engine, EngineConfig, RiskConfig
5
+
6
+ async with Engine({"kalshi": kalshi}, EngineConfig(journal_path="trading.db"),
7
+ risk=RiskConfig(max_order_contracts=Decimal("100"))) as engine:
8
+ order = await engine.submit(request)
9
+ ```
10
+
11
+ The engine writes every decision to a journal before it acts, so a process
12
+ killed mid-submit restarts knowing what was in doubt; runs pre-trade risk
13
+ rules that name themselves when they refuse; books fills into a ledger that
14
+ nets on the YES leg and rolls up by instrument, market, strategy and
15
+ account; and refuses to trade at all if another engine holds the journal.
16
+
17
+ Part of the base install: `pip install synpath`.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from .engine import Engine, EngineConfig, Recovery
22
+ from .events import EngineEvent, EventBus, Subscription
23
+ from .fair_values import FairValue, FairValues
24
+ from .journal import Intent, IntentState, Journal, JournalEvent, LeaseLost
25
+ from .ledger import Ledger, PositionState, Rollup
26
+ from .orders import ManagedOrder, ManagedOrders
27
+ from .risk import Decision, KillSwitch, RiskConfig, RiskEngine
28
+
29
+ __all__ = [
30
+ "Engine", "EngineConfig", "Recovery",
31
+ "Journal", "JournalEvent", "Intent", "IntentState", "LeaseLost",
32
+ "EventBus", "EngineEvent", "Subscription",
33
+ "Ledger", "PositionState", "Rollup",
34
+ "RiskConfig", "RiskEngine", "Decision", "KillSwitch",
35
+ "FairValues", "FairValue",
36
+ "ManagedOrder", "ManagedOrders",
37
+ ]