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/remote.py ADDED
@@ -0,0 +1,195 @@
1
+ """Order entry through your own `synpath serve`, with the `Client` method names.
2
+
3
+ `synpath.Client(server="http://127.0.0.1:8000")` sends every trading call to
4
+ that server's `/trading` routes instead of to the venues, which is what
5
+ makes the engine's order types reachable from Python: a stop, an iceberg,
6
+ an order on a bucket. Market data stays direct. The server holds the venue
7
+ credentials; this side holds only the server's access token, found in the local
8
+ registry for a loopback address (`synpath.server.local`) or given.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from decimal import Decimal
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from .errors import AuthenticationError, BadRequest, ExchangeError, NetworkError, NotSupported
18
+ from .server.local import resolve_key
19
+ from .trading.errors import InsufficientFunds, OrderNotFound, RiskRejected
20
+ from .bucket import BUCKET_PREFIX, Bucket, BucketMember, BucketOrderReport, BucketPosition
21
+ from .trading.types import Balance, EditRequest, FeeEstimate, Fill, Order, OrderRequest, Position, Settlement, Side
22
+ from .types import Page
23
+
24
+ TRADING_PATH = "/trading"
25
+
26
+
27
+ class RemoteTrading:
28
+ """The trading half of `Client`, over HTTP to a self-hosted server."""
29
+
30
+ def __init__(self, server: str, key: str | None = None, *, http_client: Any = None,
31
+ home_dir: str | None = None, timeout: float = 30.0):
32
+ base = server.rstrip("/")
33
+ self.base = base if base.endswith(TRADING_PATH) else base + TRADING_PATH
34
+ self.server = server
35
+ self.key = resolve_key(server, key, home_dir=home_dir)
36
+ self._http = http_client
37
+ self._owned = http_client is None
38
+ self.timeout = timeout
39
+
40
+ # -- transport ------------------------------------------------------------
41
+
42
+ def _headers(self) -> dict[str, str]:
43
+ if not self.key:
44
+ raise AuthenticationError(
45
+ f"no access token for {self.server}: pass access_token=, set SYNPATH_ACCESS_TOKEN, or, for a server on this "
46
+ "machine, start it with `synpath serve` so its token is in ~/.synpath/servers.json"
47
+ )
48
+ return {"Authorization": f"Bearer {self.key}"}
49
+
50
+ async def _call(self, method: str, path: str, *, params: dict[str, Any] | None = None,
51
+ json: Any = None) -> Any:
52
+ if self._http is None:
53
+ self._http = httpx.AsyncClient(timeout=self.timeout)
54
+ clean = {k: v for k, v in (params or {}).items() if v is not None}
55
+ try:
56
+ response = await self._http.request(method, self.base + path, params=clean, json=json, headers=self._headers())
57
+ except httpx.HTTPError as exc:
58
+ raise NetworkError(f"{method} {path}: {exc}") from exc
59
+ if response.status_code >= 400:
60
+ raise self._error(response, method, path)
61
+ return response.json() if response.content else None
62
+
63
+ @staticmethod
64
+ def _error(response: httpx.Response, method: str, path: str) -> Exception:
65
+ """The server's `{"error": {"code", "message", "details"}}` as the
66
+ exception an in-process caller would have caught."""
67
+ try:
68
+ error = (response.json() or {}).get("error") or {}
69
+ except ValueError:
70
+ error = {}
71
+ code = str(error.get("code") or "")
72
+ message = str(error.get("message") or response.text or response.reason_phrase)
73
+ details = error.get("details") or {}
74
+ status = response.status_code
75
+ if status in (401, 403):
76
+ return AuthenticationError(message)
77
+ if status == 404:
78
+ return OrderNotFound(message)
79
+ if code == "risk_rejected" or status == 409:
80
+ return RiskRejected(message, rule=str(details.get("rule") or code or "rejected"))
81
+ if code == "insufficient_funds":
82
+ return InsufficientFunds(message)
83
+ if status in (400, 422):
84
+ return BadRequest(message)
85
+ if details.get("retryable"):
86
+ return NetworkError(f"{method} {path} -> {status}: {message}")
87
+ return ExchangeError(f"{method} {path} -> {status}: {message}")
88
+
89
+ # -- orders ---------------------------------------------------------------
90
+
91
+ async def create_order(self, request: OrderRequest) -> Order:
92
+ return Order.model_validate(await self._call("POST", "/orders", json=request.model_dump(mode="json")))
93
+
94
+ async def create_bucket(self, *, book: str, name: str, members: list[BucketMember]) -> Bucket:
95
+ body = {"book": book, "name": name, "members": [m.model_dump(mode="json") for m in members]}
96
+ return Bucket.model_validate(await self._call("POST", "/buckets", json=body))
97
+
98
+ async def fetch_buckets(self, *, book: str | None = None, status: str = "active") -> list[Bucket]:
99
+ page = await self._call("GET", "/buckets", params={"book": book, "status": status})
100
+ return [Bucket.model_validate(row) for row in page.get("data", [])]
101
+
102
+ async def fetch_bucket(self, bucket_id: str) -> Bucket:
103
+ return Bucket.model_validate(await self._call("GET", f"/buckets/{_bare(bucket_id)}"))
104
+
105
+ async def archive_bucket(self, bucket_id: str) -> Bucket:
106
+ return Bucket.model_validate(await self._call("DELETE", f"/buckets/{_bare(bucket_id)}"))
107
+
108
+ async def fetch_bucket_position(self, bucket_id: str, *, book: str | None = None) -> BucketPosition:
109
+ return BucketPosition.model_validate(
110
+ await self._call("GET", f"/buckets/{_bare(bucket_id)}/position", params={"book": book}))
111
+
112
+ async def fetch_bucket_orders(self, bucket_id: str) -> list[BucketOrderReport]:
113
+ page = await self._call("GET", f"/buckets/{_bare(bucket_id)}/orders")
114
+ return [BucketOrderReport.model_validate(row) for row in page.get("data", [])]
115
+
116
+ async def fetch_bucket_order(self, bucket_id: str, order_id: str) -> BucketOrderReport:
117
+ return BucketOrderReport.model_validate(
118
+ await self._call("GET", f"/buckets/{_bare(bucket_id)}/orders/{order_id}"))
119
+
120
+ async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]:
121
+ out: list[Order | Exception] = []
122
+ for request in requests:
123
+ try:
124
+ out.append(await self.create_order(request))
125
+ except Exception as exc: # one refusal does not stop the rest, as the venue adapters behave
126
+ out.append(exc)
127
+ return out
128
+
129
+ async def cancel_order(self, order_id: str, *, market_id: str | None = None, venue: str | None = None) -> Order:
130
+ return Order.model_validate(await self._call("DELETE", f"/orders/{order_id}"))
131
+
132
+ async def cancel_all_orders(self, *, market_id: str | None = None, venue: str | None = None) -> int | None:
133
+ count = 0
134
+ for order in await self.fetch_open_orders(market_id=market_id, venue=venue):
135
+ await self.cancel_order(order.id)
136
+ count += 1
137
+ return count
138
+
139
+ async def edit_order(self, request: EditRequest, *, venue: str | None = None, current: Order | None = None) -> Order:
140
+ body = request.model_dump(mode="json", exclude_none=True)
141
+ return Order.model_validate(await self._call("PATCH", f"/orders/{request.order_id}", json=body))
142
+
143
+ async def fetch_order(self, order_id: str, *, market_id: str | None = None, venue: str | None = None) -> Order:
144
+ return Order.model_validate(await self._call("GET", f"/orders/{order_id}"))
145
+
146
+ async def fetch_open_orders(self, *, market_id: str | None = None, venue: str | None = None) -> list[Order]:
147
+ page = await self._call("GET", "/orders", params={"venue": venue})
148
+ orders = [Order.model_validate(row) for row in page.get("data", [])]
149
+ return [o for o in orders if market_id is None or o.market_id == market_id]
150
+
151
+ # -- portfolio ------------------------------------------------------------
152
+
153
+ async def fetch_my_trades(self, *, market_id: str | None = None, venue: str | None = None,
154
+ since: int | None = None, limit: int | None = None, cursor: str | None = None) -> Page[Fill]:
155
+ page = await self._call("GET", "/fills", params={"since": since, "venue": venue})
156
+ fills = [Fill.model_validate(row) for row in page.get("data", [])]
157
+ if market_id is not None:
158
+ fills = [f for f in fills if f.market_id == market_id]
159
+ if limit is not None:
160
+ fills = fills[:limit]
161
+ return Page(fills, next_cursor=None)
162
+
163
+ async def fetch_positions(self, *, market_id: str | None = None, venue: str | None = None) -> list[Position]:
164
+ page = await self._call("GET", "/positions")
165
+ positions = [Position.model_validate(row) for row in page.get("data", [])]
166
+ return [p for p in positions
167
+ if (market_id is None or p.market_id == market_id) and (venue is None or p.venue == venue)]
168
+
169
+ async def fetch_balance(self, venue: str) -> Balance:
170
+ page = await self._call("GET", "/balances")
171
+ for row in page.get("data", []):
172
+ balance = Balance.model_validate(row)
173
+ if balance.venue == venue:
174
+ return balance
175
+ raise NotSupported(f"{self.server} reports no balance for {venue}: is that venue configured there?")
176
+
177
+ async def fetch_settlements(self, **_: Any) -> Page[Settlement]:
178
+ raise NotSupported("settlements are read from the venue, not through a synpath server; "
179
+ "use synpath.Client(load_credentials()) for them")
180
+
181
+ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, amount: Decimal) -> FeeEstimate:
182
+ raise NotSupported("fee estimates are read from the venue, not through a synpath server; "
183
+ "use synpath.Client(load_credentials()) or fetch_fee_schedule() for them")
184
+
185
+ # -- housekeeping ---------------------------------------------------------
186
+
187
+ async def close(self) -> None:
188
+ if self._http is not None and self._owned:
189
+ await self._http.aclose()
190
+ self._http = None
191
+
192
+
193
+ def _bare(bucket_id: str) -> str:
194
+ """A bucket's id, whether given as `<id>` or as its market id `bucket:<id>`."""
195
+ return bucket_id[len(BUCKET_PREFIX):] if bucket_id.startswith(BUCKET_PREFIX) else bucket_id
@@ -0,0 +1,98 @@
1
+ """HTTP layer over the library. `create_app`, `create_trading_app`,
2
+ `VenueRegistry`, `ControlStore` and `Principal` are also exported from the
3
+ top-level package: `from synpath import create_app`.
4
+
5
+ ```bash
6
+ pip install synpath
7
+ python -m synpath.server # http://127.0.0.1:8000/docs
8
+ ```
9
+
10
+ Two ways to use it:
11
+
12
+ **Standalone**, as above, or with any ASGI server:
13
+
14
+ ```bash
15
+ uvicorn synpath.server:app --port 8000
16
+ ```
17
+
18
+ **Mounted**, when something else owns authentication, quotas and metering:
19
+
20
+ ```python
21
+ from fastapi import Depends, FastAPI
22
+ from synpath.server import create_app
23
+
24
+ outer = FastAPI()
25
+ outer.include_router(create_app(docs=False).router, dependencies=[Depends(my_auth)])
26
+ ```
27
+
28
+ The read app has no auth of its own and never will — a service that ships
29
+ authentication inside the open-source core forces every host to work around
30
+ it. Everything cross-venue (matching the same question across exchanges,
31
+ routing an order) belongs outside too.
32
+
33
+ **Trading is a second app**, because placing an order does need to know who
34
+ is asking:
35
+
36
+ ```python
37
+ from synpath.server import create_trading_app, ControlStore
38
+
39
+ store = await ControlStore("control.db").open()
40
+ user, key = await store.bootstrap("owner") # once; the key is shown once
41
+ app = create_trading_app(engine, store)
42
+ ```
43
+
44
+ It carries per-key grants scoped to a subaccount (`view`, `trade`,
45
+ `manage_credentials`, `manage_members`), an append-only audit log the
46
+ database itself enforces, and `/ws/events`, which replays the engine's event
47
+ stream from a cursor before following it live. See
48
+ [docs/server.md](../docs/server.md).
49
+
50
+ `GET /openapi.json` is the contract. Generate a typed client from it rather
51
+ than hand-writing one:
52
+
53
+ ```bash
54
+ npx @hey-api/openapi-ts -i http://127.0.0.1:8000/openapi.json -o ./src/synpath
55
+ ```
56
+ """
57
+ from __future__ import annotations
58
+
59
+ import importlib
60
+ from typing import Any
61
+
62
+ __all__ = ["create_app", "VenueRegistry", "app", "main", "create_trading_app", "ControlStore", "Principal"]
63
+
64
+ _app = None
65
+
66
+
67
+ def __getattr__(name: str) -> Any:
68
+ """Import FastAPI lazily.
69
+
70
+ `synpath` itself must not require it: someone using the library in a
71
+ notebook should not be made to install a web framework. Resolving the
72
+ import here also means a missing dependency fails with a sentence saying
73
+ what to install, rather than a bare ImportError three frames down.
74
+ """
75
+ if name not in __all__:
76
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
77
+ try:
78
+ # importlib rather than `from . import api`, which would look the name
79
+ # up on this package and re-enter this function forever.
80
+ api = importlib.import_module(f"{__name__}.api")
81
+ except ImportError as exc: # pragma: no cover - depends on env
82
+ raise ImportError(
83
+ "synpath.server needs FastAPI, which the base install carries. Reinstall with:\n"
84
+ " pip install synpath"
85
+ ) from exc
86
+ if name == "app":
87
+ # One module-level instance, so `uvicorn synpath.server:app` works.
88
+ global _app
89
+ if _app is None:
90
+ _app = api.create_app()
91
+ return _app
92
+ if name == "main":
93
+ return importlib.import_module(f"{__name__}.__main__").main
94
+ if name == "create_trading_app":
95
+ return importlib.import_module(f"{__name__}.trading").create_trading_app
96
+ if name in ("ControlStore", "Principal"):
97
+ return getattr(importlib.import_module(f"{__name__}.store"), name)
98
+ return getattr(api, name)
@@ -0,0 +1,118 @@
1
+ """`python -m synpath.server` — run the API locally, or write its schema out.
2
+
3
+ ```bash
4
+ python -m synpath.server # the read API on 127.0.0.1:8000
5
+ python -m synpath.server schema --out openapi.json # the read contract
6
+ python -m synpath.server schema --trading --out openapi.json # the trading one
7
+ python -m synpath.server bootstrap --control control.db # the first API key
8
+ ```
9
+
10
+ `schema` exists so a typed client can be generated in CI without starting a
11
+ server or holding credentials:
12
+
13
+ ```bash
14
+ python -m synpath.server schema --trading --out openapi.json
15
+ npx @hey-api/openapi-ts -i openapi.json -o ./src/synpath
16
+ ```
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import asyncio
22
+ import json
23
+
24
+
25
+ def schema(args: argparse.Namespace) -> None:
26
+ """Write the OpenAPI document without running anything."""
27
+ if args.trading:
28
+ from ..engine.engine import Engine, EngineConfig
29
+ from .store import ControlStore
30
+ from .trading import create_trading_app
31
+
32
+ # The document depends on the routes, not on the engine's state, so an
33
+ # engine that was never started describes the same contract.
34
+ engine = Engine({}, EngineConfig(journal_path=":memory:", require_lease=False))
35
+ app = create_trading_app(engine, ControlStore(":memory:"))
36
+ else:
37
+ from .api import create_app
38
+
39
+ app = create_app()
40
+ document = json.dumps(app.openapi(), indent=2, sort_keys=True)
41
+ if args.out:
42
+ with open(args.out, "w") as handle:
43
+ handle.write(document + "\n")
44
+ print(f"wrote {args.out}")
45
+ else:
46
+ print(document)
47
+
48
+
49
+ def bootstrap(args: argparse.Namespace) -> None:
50
+ """Create the first user and key for a control database."""
51
+ from .store import ControlStore
52
+
53
+ async def run() -> None:
54
+ store = await ControlStore(args.control).open()
55
+ try:
56
+ user, key = await store.bootstrap(args.name)
57
+ print(f"user {user.id} ({user.name})")
58
+ print(f"key {key.secret}")
59
+ print("This key is shown once. It has every permission on every account.")
60
+ finally:
61
+ await store.close()
62
+
63
+ asyncio.run(run())
64
+
65
+
66
+ def main(argv: list[str] | None = None) -> None:
67
+ parser = argparse.ArgumentParser(
68
+ prog="python -m synpath.server",
69
+ description="Serve the synpath API. The read app has no authentication: bind to localhost, "
70
+ "or mount it behind your own middleware for anything public.",
71
+ )
72
+ parser.add_argument("--host", default="127.0.0.1", help="default: 127.0.0.1")
73
+ parser.add_argument("--port", type=int, default=8000, help="default: 8000")
74
+ parser.add_argument("--reload", action="store_true", help="reload on code changes")
75
+ parser.add_argument("--log-level", default="info")
76
+ sub = parser.add_subparsers(dest="command")
77
+
78
+ schema_parser = sub.add_parser("schema", help="write the OpenAPI document and exit")
79
+ schema_parser.add_argument("--trading", action="store_true", help="the trading contract instead of the read one")
80
+ schema_parser.add_argument("--out", default=None, help="a file to write, or stdout")
81
+
82
+ boot = sub.add_parser("bootstrap", help="create the first user and API key")
83
+ boot.add_argument("--control", default="synpath-control.db", help="the control database")
84
+ boot.add_argument("--name", default="owner")
85
+
86
+ args = parser.parse_args(argv)
87
+ if args.command == "schema":
88
+ return schema(args)
89
+ if args.command == "bootstrap":
90
+ return bootstrap(args)
91
+
92
+ try:
93
+ import uvicorn
94
+ except ImportError: # pragma: no cover - depends on env
95
+ raise SystemExit(
96
+ 'synpath.server needs an ASGI server, which the base install carries. Reinstall with:\n'
97
+ ' pip install synpath'
98
+ ) from None
99
+
100
+ if args.host not in ("127.0.0.1", "localhost", "::1"):
101
+ print(
102
+ f"warning: binding to {args.host} exposes an API with no "
103
+ "authentication. Mount create_app() behind your own middleware "
104
+ "instead of serving this directly."
105
+ )
106
+
107
+ uvicorn.run(
108
+ "synpath.server:app",
109
+ host=args.host,
110
+ port=args.port,
111
+ reload=args.reload,
112
+ log_level=args.log_level,
113
+ factory=False,
114
+ )
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()