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/hosted_auth.py ADDED
@@ -0,0 +1,201 @@
1
+ """Browser-assisted login and API-key management for Synpath's hosted API."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import os
10
+ import re
11
+ import secrets
12
+ import sys
13
+ import time
14
+ import webbrowser
15
+ from http.server import BaseHTTPRequestHandler, HTTPServer
16
+ from pathlib import Path
17
+ from urllib.error import HTTPError, URLError
18
+ from urllib.parse import parse_qs, urlencode, urlparse
19
+ from urllib.request import Request, urlopen
20
+
21
+ DEFAULT_BASE_URL = "https://api2.synpath.dev"
22
+
23
+
24
+ def credentials_path() -> Path:
25
+ override = os.environ.get("SYNPATH_CREDENTIALS_FILE")
26
+ if override:
27
+ return Path(override).expanduser()
28
+ root = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
29
+ return root / "synpath" / "credentials.json"
30
+
31
+
32
+ def load_credentials() -> dict:
33
+ path = credentials_path()
34
+ if not path.exists():
35
+ return {}
36
+ if os.name != "nt" and path.stat().st_mode & 0o077:
37
+ raise RuntimeError(f"Credential file must be private (chmod 600): {path}")
38
+ return json.loads(path.read_text())
39
+
40
+
41
+ def save_credentials(credentials: dict) -> None:
42
+ path = credentials_path()
43
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
44
+ temporary = path.with_name(path.name + ".tmp." + secrets.token_hex(6))
45
+ try:
46
+ fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
47
+ with os.fdopen(fd, "w") as stream:
48
+ json.dump(credentials, stream, indent=2)
49
+ stream.write("\n")
50
+ os.replace(temporary, path)
51
+ finally:
52
+ temporary.unlink(missing_ok=True)
53
+
54
+
55
+ def stored_api_key() -> str | None:
56
+ return load_credentials().get("api_key")
57
+
58
+
59
+ def _random() -> str:
60
+ return base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
61
+
62
+
63
+ def _challenge(verifier: str) -> str:
64
+ return base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
65
+
66
+
67
+ def _request(base: str, path: str, *, method: str = "GET", token: str | None = None,
68
+ body: dict | None = None) -> dict:
69
+ headers = {"Accept": "application/json"}
70
+ if token:
71
+ headers["Authorization"] = f"Bearer {token}"
72
+ data = None
73
+ if body is not None:
74
+ headers["Content-Type"] = "application/json"
75
+ data = json.dumps(body).encode()
76
+ request = Request(base.rstrip("/") + path, data=data, headers=headers, method=method)
77
+ try:
78
+ with urlopen(request, timeout=15) as response:
79
+ return json.load(response) if response.status != 204 else {}
80
+ except HTTPError as exc:
81
+ try:
82
+ detail = json.load(exc).get("error", {}).get("code", "request_failed")
83
+ except (ValueError, AttributeError):
84
+ detail = "request_failed"
85
+ raise RuntimeError(f"Synpath API returned {exc.code}: {detail}") from None
86
+ except URLError as exc:
87
+ raise RuntimeError(f"Could not reach Synpath API: {exc.reason}") from None
88
+
89
+
90
+ def _base_url(explicit: str | None = None) -> str:
91
+ base = explicit or os.environ.get("SYNPATH_HISTORY_URL") or DEFAULT_BASE_URL
92
+ parsed = urlparse(base)
93
+ if parsed.scheme != "https" and not (parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1")):
94
+ raise RuntimeError("Hosted API URL must use HTTPS")
95
+ if parsed.path not in ("", "/") or parsed.query or parsed.fragment:
96
+ raise RuntimeError("Hosted API URL must be an origin without a path")
97
+ return base.rstrip("/")
98
+
99
+
100
+ def login(argv: list[str]) -> int:
101
+ parser = argparse.ArgumentParser(prog="synpath login")
102
+ parser.add_argument("--base-url")
103
+ parser.add_argument("--no-browser", action="store_true")
104
+ args = parser.parse_args(argv)
105
+ base = _base_url(args.base_url)
106
+ state = _random()
107
+ verifier = _random()
108
+ result: dict[str, str] = {}
109
+
110
+ class Callback(BaseHTTPRequestHandler):
111
+ def do_GET(self):
112
+ parsed = urlparse(self.path)
113
+ query = parse_qs(parsed.query)
114
+ code = query.get("code", [""])[0]
115
+ incoming_state = query.get("state", [""])[0]
116
+ valid = (parsed.path == "/callback" and hmac.compare_digest(incoming_state, state)
117
+ and re.fullmatch(r"[A-Za-z0-9_-]{43}", code))
118
+ if valid:
119
+ result["code"] = code
120
+ self.send_response(200 if valid else 400)
121
+ self.send_header("Content-Type", "text/html; charset=utf-8")
122
+ self.send_header("Cache-Control", "no-store")
123
+ self.send_header("Referrer-Policy", "no-referrer")
124
+ self.end_headers()
125
+ self.wfile.write(b"<h1>Synpath sign-in complete</h1><p>Return to your terminal.</p>"
126
+ if valid else b"<h1>Invalid sign-in response</h1>")
127
+
128
+ def log_message(self, _format, *_args):
129
+ pass # The URL contains a one-time authorization code.
130
+
131
+ with HTTPServer(("127.0.0.1", 0), Callback) as listener:
132
+ port = listener.server_port
133
+ query = urlencode({"cli_port": port, "cli_state": state, "cli_challenge": _challenge(verifier)})
134
+ url = f"{base}/oauth/google/start?{query}"
135
+ print("Open this URL to sign in with Google:", url, flush=True)
136
+ if not args.no_browser:
137
+ webbrowser.open(url)
138
+ deadline = time.monotonic() + 300
139
+ listener.timeout = 1
140
+ while "code" not in result and time.monotonic() < deadline:
141
+ listener.handle_request()
142
+ if "code" not in result:
143
+ raise RuntimeError("Google sign-in timed out")
144
+ data = _request(base, "/v1/auth/cli/exchange", method="POST",
145
+ body={"code": result["code"], "code_verifier": verifier})
146
+ credentials = load_credentials()
147
+ credentials.update({"base_url": base, "session_token": data["session_token"],
148
+ "session_expires_at": int(time.time()) + data["expires_in"]})
149
+ save_credentials(credentials)
150
+ print("Signed in. Management session saved with restricted permissions.")
151
+ return 0
152
+
153
+
154
+ def keys(argv: list[str]) -> int:
155
+ parser = argparse.ArgumentParser(prog="synpath keys")
156
+ sub = parser.add_subparsers(dest="action", required=True)
157
+ create = sub.add_parser("create")
158
+ create.add_argument("label")
159
+ create.add_argument("--no-print-key", action="store_true")
160
+ sub.add_parser("list")
161
+ revoke = sub.add_parser("revoke")
162
+ revoke.add_argument("id")
163
+ args = parser.parse_args(argv)
164
+ credentials = load_credentials()
165
+ token = credentials.get("session_token")
166
+ if not token or time.time() >= credentials.get("session_expires_at", 0):
167
+ raise RuntimeError("Sign in first with `synpath login`")
168
+ base = _base_url(credentials.get("base_url"))
169
+ if args.action == "create":
170
+ data = _request(base, "/v1/auth/keys", method="POST", token=token, body={"label": args.label})
171
+ credentials["api_key"] = data["secret"]
172
+ save_credentials(credentials)
173
+ print(f"Created key {data['id']} and saved it to {credentials_path()}")
174
+ if not args.no_print_key:
175
+ print(f"API key (shown once): {data['secret']}")
176
+ elif args.action == "list":
177
+ data = _request(base, "/v1/auth/keys", token=token)
178
+ for key in data["keys"]:
179
+ print(f"{key['id']}\t{key['label']}\t{'revoked' if key['revoked_at'] else 'active'}")
180
+ else:
181
+ if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", args.id):
182
+ raise RuntimeError("Invalid key ID")
183
+ _request(base, "/v1/auth/keys/" + args.id, method="DELETE", token=token)
184
+ if credentials.get("api_key", "").startswith(f"spk_{args.id}."):
185
+ credentials.pop("api_key", None)
186
+ save_credentials(credentials)
187
+ print(f"Revoked key {args.id}")
188
+ return 0
189
+
190
+
191
+ def logout(argv: list[str]) -> int:
192
+ parser = argparse.ArgumentParser(prog="synpath logout")
193
+ parser.parse_args(argv)
194
+ credentials = load_credentials()
195
+ token = credentials.pop("session_token", None)
196
+ credentials.pop("session_expires_at", None)
197
+ if token:
198
+ _request(_base_url(credentials.get("base_url")), "/v1/auth/logout", method="POST", token=token)
199
+ save_credentials(credentials)
200
+ print("Signed out of key management. Existing API keys remain valid.")
201
+ return 0
synpath/ids.py ADDED
@@ -0,0 +1,61 @@
1
+ """Synpath ids: `venue:native`, one per market and one per event.
2
+
3
+ A Synpath id names one listing on one venue and carries the venue in front of
4
+ the venue's own id, separated by a colon: `kalshi:KXFEDDECISION-26SEP-C25`,
5
+ `polymarket:2252244`, `polymarket_us:tec-mlb-nlchamp-2026-09-27-atl`. Nothing
6
+ is hashed or re-keyed, so the part after the colon is what the venue's own
7
+ website and API call the thing.
8
+
9
+ The venue in the id is what lets one field route a call: `synpath.Client`
10
+ reads the prefix and hands the call to that venue's adapter. Every venue
11
+ adapter also accepts the bare native id, because a caller holding a ticker
12
+ already knows which adapter it is talking to.
13
+
14
+ The colon is safe as a separator: no venue's native id contains one.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ VENUES: tuple[str, ...] = ("kalshi", "polymarket", "polymarket_us")
19
+ """Every venue this library speaks, by id. `synpath.exchanges` is the same
20
+ list with the adapter classes attached; this one exists so the id helpers do
21
+ not import the adapters."""
22
+
23
+
24
+ def qualify(venue: str, native_id: str) -> str:
25
+ """`("kalshi", "KXFOO-25")` -> `"kalshi:KXFOO-25"`. Idempotent: an id
26
+ that already carries this venue's prefix is returned as is."""
27
+ if native_id.startswith(f"{venue}:"):
28
+ return native_id
29
+ return f"{venue}:{native_id}"
30
+
31
+
32
+ def split(synpath_id: str) -> tuple[str | None, str]:
33
+ """`"kalshi:KXFOO-25"` -> `("kalshi", "KXFOO-25")`; a bare native id ->
34
+ `(None, native_id)`. Only a known venue counts as a prefix, so a native
35
+ id that happens to contain a colon is never mis-split."""
36
+ head, sep, tail = synpath_id.partition(":")
37
+ if sep and head in VENUES and tail:
38
+ return head, tail
39
+ return None, synpath_id
40
+
41
+
42
+ def native(venue: str, synpath_id: str) -> str:
43
+ """The venue's own id, from a Synpath id or a bare native id. An id that
44
+ names a different venue is refused rather than sent to the wrong one."""
45
+ found, native_id = split(synpath_id)
46
+ if found is not None and found != venue:
47
+ from .errors import BadRequest
48
+ raise BadRequest(f"{venue}: {synpath_id!r} belongs to {found}, not to this venue")
49
+ return native_id
50
+
51
+
52
+ def venue_of(synpath_id: str) -> str:
53
+ """The venue named by a Synpath id. A bare native id has none and is
54
+ refused: the caller wanted routing, and there is nothing to route on."""
55
+ found, _ = split(synpath_id)
56
+ if found is None:
57
+ from .errors import BadRequest
58
+ raise BadRequest(
59
+ f"{synpath_id!r} is not a Synpath id: expected 'venue:native_id' with venue one of {', '.join(VENUES)}"
60
+ )
61
+ return found