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.
- synpath/__init__.py +183 -0
- synpath/__main__.py +66 -0
- synpath/base.py +723 -0
- synpath/bucket.py +154 -0
- synpath/client.py +356 -0
- synpath/engine/__init__.py +37 -0
- synpath/engine/__main__.py +354 -0
- synpath/engine/alerts.py +170 -0
- synpath/engine/engine.py +888 -0
- synpath/engine/eod.py +154 -0
- synpath/engine/events.py +140 -0
- synpath/engine/fair_values.py +117 -0
- synpath/engine/feeds.py +220 -0
- synpath/engine/journal.py +907 -0
- synpath/engine/ledger.py +353 -0
- synpath/engine/orders/__init__.py +42 -0
- synpath/engine/orders/base.py +441 -0
- synpath/engine/orders/day.py +72 -0
- synpath/engine/orders/iceberg.py +121 -0
- synpath/engine/orders/manager.py +223 -0
- synpath/engine/orders/oco.py +255 -0
- synpath/engine/orders/peg.py +168 -0
- synpath/engine/orders/routed.py +496 -0
- synpath/engine/orders/stop.py +240 -0
- synpath/engine/orders/taker.py +187 -0
- synpath/engine/orders/twap.py +190 -0
- synpath/engine/paper.py +532 -0
- synpath/engine/reconcile.py +279 -0
- synpath/engine/risk.py +403 -0
- synpath/engine/router.py +261 -0
- synpath/errors.py +98 -0
- synpath/history.py +71 -0
- synpath/hosted.py +86 -0
- synpath/hosted_auth.py +201 -0
- synpath/ids.py +61 -0
- synpath/kalshi.py +1378 -0
- synpath/matching.py +86 -0
- synpath/polymarket.py +1004 -0
- synpath/polymarket_us.py +989 -0
- synpath/remote.py +195 -0
- synpath/server/__init__.py +98 -0
- synpath/server/__main__.py +118 -0
- synpath/server/api.py +439 -0
- synpath/server/errors.py +87 -0
- synpath/server/local.py +96 -0
- synpath/server/models.py +75 -0
- synpath/server/serve.py +236 -0
- synpath/server/store.py +363 -0
- synpath/server/trading.py +764 -0
- synpath/trading/__init__.py +79 -0
- synpath/trading/__main__.py +69 -0
- synpath/trading/base.py +126 -0
- synpath/trading/credentials.py +400 -0
- synpath/trading/errors.py +94 -0
- synpath/trading/init.py +233 -0
- synpath/trading/instruments.py +162 -0
- synpath/trading/kalshi.py +957 -0
- synpath/trading/limiter.py +177 -0
- synpath/trading/money.py +172 -0
- synpath/trading/polymarket.py +1362 -0
- synpath/trading/polymarket_signing.py +478 -0
- synpath/trading/polymarket_us.py +705 -0
- synpath/trading/polymarket_us_exchange.py +825 -0
- synpath/trading/types.py +414 -0
- synpath/types.py +608 -0
- synpath/ws/__init__.py +55 -0
- synpath/ws/base.py +544 -0
- synpath/ws/grpc.py +578 -0
- synpath/ws/kalshi.py +418 -0
- synpath/ws/polymarket.py +430 -0
- synpath/ws/polymarket_us.py +299 -0
- synpath/ws/polymarket_us_exchange.py +754 -0
- synpath-0.1.0.dist-info/METADATA +224 -0
- synpath-0.1.0.dist-info/RECORD +77 -0
- synpath-0.1.0.dist-info/WHEEL +4 -0
- synpath-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|