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
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Trading errors, under the same two branches as the read API's.
|
|
2
|
+
|
|
3
|
+
`ExchangeError` still means "the venue answered and said no" and
|
|
4
|
+
`NetworkError` still means "no verdict". Two things are new. `RiskRejected`
|
|
5
|
+
is neither: the request never reached a venue because this library's own
|
|
6
|
+
pre-trade checks refused it, and retrying unchanged will be refused again.
|
|
7
|
+
`RateBudgetExceeded` is a refusal from the local budget limiter -- the queue
|
|
8
|
+
ahead of the request was already too long for it to be honoured in time --
|
|
9
|
+
which is deliberately not `RateLimitExceeded`: nothing was sent, and waiting
|
|
10
|
+
longer is the wrong answer for an order that had a deadline.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ..errors import AuthenticationError, BadRequest, ExchangeError, SynpathError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InvalidOrder(BadRequest):
|
|
20
|
+
"""The order cannot be placed as written: off-tick price, size below the
|
|
21
|
+
minimum, a market order without a protection price, a sell with no
|
|
22
|
+
inventory on a venue that does not net. Caught before signing."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DuplicateClientOrderId(InvalidOrder):
|
|
26
|
+
"""A `client_order_id` this account has already used.
|
|
27
|
+
|
|
28
|
+
Raised rather than resubmitted: the id is the idempotency key, and a
|
|
29
|
+
second order under the same id is exactly the duplicate it exists to
|
|
30
|
+
prevent.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class InsufficientFunds(ExchangeError):
|
|
35
|
+
"""The venue refused for lack of balance or buying power."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OrderNotFound(ExchangeError):
|
|
39
|
+
"""No order under that id at the venue -- already gone, or never there."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class OrderRejected(ExchangeError):
|
|
43
|
+
"""The venue took the order and rejected it: post-only would have crossed,
|
|
44
|
+
the market is closed, self-trade prevention fired. `info` says which."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self, message: str, *, reason: str | None = None, info: dict | None = None,
|
|
48
|
+
body: Any = None, status: int | None = None,
|
|
49
|
+
):
|
|
50
|
+
super().__init__(message, body=body, status=status)
|
|
51
|
+
self.reason = reason
|
|
52
|
+
self.info = info or {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MarketHalted(ExchangeError):
|
|
56
|
+
"""The market is listed but not accepting orders right now."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PermissionDenied(AuthenticationError):
|
|
60
|
+
"""Credentials are valid but not allowed to do this: a read-only key, a
|
|
61
|
+
subaccount the key was not granted, a venue the account has not enabled."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CredentialsMissing(AuthenticationError):
|
|
65
|
+
"""No credentials configured for this venue.
|
|
66
|
+
|
|
67
|
+
Says which environment variables to set. Distinct from `PermissionDenied`
|
|
68
|
+
on purpose: one is a setup gap, the other is a decision someone made.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RiskRejected(SynpathError):
|
|
73
|
+
"""Refused by this library's pre-trade checks before anything was sent.
|
|
74
|
+
|
|
75
|
+
`rule` names the check that fired. Not an `ExchangeError`: no venue was
|
|
76
|
+
involved, and retrying unchanged will be refused again.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, message: str, *, rule: str):
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
self.rule = rule
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class RateBudgetExceeded(SynpathError):
|
|
85
|
+
"""The local budget limiter could not honour the request in time.
|
|
86
|
+
|
|
87
|
+
Nothing was sent. The queue ahead of the request was already longer than
|
|
88
|
+
its deadline allowed, and quietly delaying it would have let a stop fire
|
|
89
|
+
late while looking like it fired on time.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, message: str, *, wait_s: float):
|
|
93
|
+
super().__init__(message)
|
|
94
|
+
self.wait_s = wait_s
|
synpath/trading/init.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""`synpath init`: ask for each venue's keys and write them to a `.env` file.
|
|
2
|
+
|
|
3
|
+
For someone who has never made a `.env` file: it asks venue by venue, checks
|
|
4
|
+
each value as it is typed (a Kalshi key file that exists and is PEM, a
|
|
5
|
+
Polymarket key and wallet address of the right shape), types secrets without
|
|
6
|
+
echoing them, and writes the file readable by its owner only. An existing
|
|
7
|
+
`.env` is updated in place: the settings asked about are replaced, every other
|
|
8
|
+
line is kept. Nothing leaves the machine; at the end it runs `synpath doctor`
|
|
9
|
+
on the file it wrote.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import getpass
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Callable
|
|
23
|
+
|
|
24
|
+
from .credentials import read_dotenv
|
|
25
|
+
|
|
26
|
+
PRIVATE_KEY = re.compile(r"^(0x)?[0-9a-fA-F]{64}$")
|
|
27
|
+
ADDRESS = re.compile(r"^0x[0-9a-fA-F]{40}$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Prompter:
|
|
32
|
+
"""How the command talks to a person; tests pass scripted answers."""
|
|
33
|
+
|
|
34
|
+
ask: Callable[[str], str] = input
|
|
35
|
+
ask_secret: Callable[[str], str] = getpass.getpass
|
|
36
|
+
say: Callable[[str], None] = print
|
|
37
|
+
|
|
38
|
+
def confirm(self, question: str, *, default: bool) -> bool:
|
|
39
|
+
hint = "[Y/n]" if default else "[y/N]"
|
|
40
|
+
while True:
|
|
41
|
+
answer = self.ask(f"{question} {hint} ").strip().lower()
|
|
42
|
+
if not answer:
|
|
43
|
+
return default
|
|
44
|
+
if answer in ("y", "yes"):
|
|
45
|
+
return True
|
|
46
|
+
if answer in ("n", "no"):
|
|
47
|
+
return False
|
|
48
|
+
self.say(" Please answer y or n.")
|
|
49
|
+
|
|
50
|
+
def value(self, question: str, *, check: Callable[[str], str | None], secret: bool = False,
|
|
51
|
+
default: str | None = None) -> str:
|
|
52
|
+
"""Ask until `check` returns no complaint. `check` gets the stripped
|
|
53
|
+
answer and returns an error message, or None when it is fine."""
|
|
54
|
+
suffix = f" [{default}]" if default else ""
|
|
55
|
+
while True:
|
|
56
|
+
raw = (self.ask_secret if secret else self.ask)(f" {question}{suffix}: ").strip()
|
|
57
|
+
if not raw and default is not None:
|
|
58
|
+
raw = default
|
|
59
|
+
problem = check(raw)
|
|
60
|
+
if problem is None:
|
|
61
|
+
return raw
|
|
62
|
+
self.say(f" {problem}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _required(what: str) -> Callable[[str], str | None]:
|
|
66
|
+
return lambda raw: None if raw else f"{what} is required."
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _pem_file(raw: str) -> str | None:
|
|
70
|
+
if not raw:
|
|
71
|
+
return "The path to the private key file is required."
|
|
72
|
+
path = Path(raw).expanduser()
|
|
73
|
+
if not path.is_file():
|
|
74
|
+
return f"No file at {path}. Give the full path to the key file Kalshi downloaded."
|
|
75
|
+
try:
|
|
76
|
+
head = path.read_bytes()[:4096]
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
return f"{path} cannot be read: {exc}"
|
|
79
|
+
if b"-----BEGIN" not in head:
|
|
80
|
+
return f"{path} is not a PEM key (no '-----BEGIN' line). Keep only the BEGIN...END block."
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _choice(*options: str) -> Callable[[str], str | None]:
|
|
85
|
+
return lambda raw: None if raw in options else f"Enter one of: {', '.join(options)}."
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def ask_kalshi(p: Prompter) -> dict[str, str]:
|
|
89
|
+
p.say("\nKalshi: Profile Settings -> API Keys -> Create New API Key on kalshi.com (or demo.kalshi.co).")
|
|
90
|
+
key_id = p.value("Key ID", check=_required("The Key ID"))
|
|
91
|
+
path = p.value("Path to the private key file it downloaded", check=_pem_file)
|
|
92
|
+
env = p.value("Environment, prod (real money) or demo", check=_choice("prod", "demo"), default="prod")
|
|
93
|
+
if env == "prod":
|
|
94
|
+
p.say(" prod trades real money.")
|
|
95
|
+
return {"KALSHI_KEY_ID": key_id, "KALSHI_PRIVATE_KEY_PATH": str(Path(path).expanduser().resolve()),
|
|
96
|
+
"KALSHI_ENV": env}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def ask_polymarket(p: Prompter) -> dict[str, str]:
|
|
100
|
+
p.say("\nPolymarket: export your key on polymarket.com under Settings -> Private Key -> Start Export,")
|
|
101
|
+
p.say("and copy your trading wallet address from the profile menu. The key is typed hidden.")
|
|
102
|
+
key = p.value("Private key (0x...)", secret=True,
|
|
103
|
+
check=lambda raw: None if PRIVATE_KEY.match(raw) else "That is not a private key: 64 hex characters, "
|
|
104
|
+
"optionally starting with 0x.")
|
|
105
|
+
funder = p.value("Trading wallet address (0x...)",
|
|
106
|
+
check=lambda raw: None if ADDRESS.match(raw) else "That is not a wallet address: 0x and 40 hex "
|
|
107
|
+
"characters.")
|
|
108
|
+
signature = p.value("Signature type: 3 for accounts made since May 2026, 1 or 2 for older ones",
|
|
109
|
+
check=_choice("0", "1", "2", "3"), default="3")
|
|
110
|
+
return {"POLYMARKET_PRIVATE_KEY": key if key.startswith("0x") else "0x" + key,
|
|
111
|
+
"POLYMARKET_FUNDER": funder, "POLYMARKET_SIGNATURE_TYPE": signature}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ask_polymarket_us(p: Prompter) -> dict[str, str]:
|
|
115
|
+
p.say("\nPolymarket US: create an API key at polymarket.us/developer after identity verification.")
|
|
116
|
+
key_id = p.value("Key ID", check=_required("The key ID"))
|
|
117
|
+
secret = p.value("Secret key (typed hidden)", secret=True, check=_required("The secret key"))
|
|
118
|
+
return {"POLYMARKET_US_KEY_ID": key_id, "POLYMARKET_US_SECRET_KEY": secret}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
VENUES: list[tuple[str, str, Callable[[Prompter], dict[str, str]]]] = [
|
|
122
|
+
("Kalshi", "KALSHI_KEY_ID", ask_kalshi),
|
|
123
|
+
("Polymarket", "POLYMARKET_PRIVATE_KEY", ask_polymarket),
|
|
124
|
+
("Polymarket US", "POLYMARKET_US_KEY_ID", ask_polymarket_us),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _quote(value: str) -> str:
|
|
129
|
+
return f'"{value}"' if any(ch.isspace() for ch in value) or "#" in value else value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def merge(existing_text: str, values: dict[str, str]) -> str:
|
|
133
|
+
"""The file with `values` set: a line already naming a key is rewritten in
|
|
134
|
+
place, new keys are appended, every other line is kept as it was."""
|
|
135
|
+
lines = existing_text.splitlines()
|
|
136
|
+
done: set[str] = set()
|
|
137
|
+
out: list[str] = []
|
|
138
|
+
for line in lines:
|
|
139
|
+
name = line.split("=", 1)[0].strip().removeprefix("export ").strip() if "=" in line else ""
|
|
140
|
+
if name in values and not line.lstrip().startswith("#"):
|
|
141
|
+
if name not in done:
|
|
142
|
+
out.append(f"{name}={_quote(values[name])}")
|
|
143
|
+
done.add(name)
|
|
144
|
+
continue
|
|
145
|
+
out.append(line)
|
|
146
|
+
new = [name for name in values if name not in done]
|
|
147
|
+
if new:
|
|
148
|
+
if out and out[-1].strip():
|
|
149
|
+
out.append("")
|
|
150
|
+
out.append("# written by synpath init")
|
|
151
|
+
out.extend(f"{name}={_quote(values[name])}" for name in new)
|
|
152
|
+
return "\n".join(out) + "\n"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def write_private(path: Path, text: str) -> None:
|
|
156
|
+
"""Write the file readable and writable by its owner only (0600), also
|
|
157
|
+
when it already existed with looser permissions."""
|
|
158
|
+
if path.exists():
|
|
159
|
+
os.chmod(path, 0o600) # tighten before the secrets go in, not after
|
|
160
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
161
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
162
|
+
handle.write(text)
|
|
163
|
+
os.chmod(path, 0o600)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _git_ignores(path: Path) -> bool | None:
|
|
167
|
+
"""True or False inside a git work tree; None when there is no git or no repository."""
|
|
168
|
+
if shutil.which("git") is None:
|
|
169
|
+
return None
|
|
170
|
+
folder = path.parent
|
|
171
|
+
inside = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], cwd=folder,
|
|
172
|
+
capture_output=True, text=True)
|
|
173
|
+
if inside.returncode != 0:
|
|
174
|
+
return None
|
|
175
|
+
ignored = subprocess.run(["git", "check-ignore", "-q", path.name], cwd=folder)
|
|
176
|
+
return ignored.returncode == 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def run(path: Path, p: Prompter) -> int:
|
|
180
|
+
existing = read_dotenv(path) if path.exists() else {}
|
|
181
|
+
p.say(f"This sets up your venue keys in {path}. They stay on this machine; secrets are typed hidden.")
|
|
182
|
+
if path.exists():
|
|
183
|
+
p.say("That file exists: the settings you enter replace the ones there, everything else is kept.")
|
|
184
|
+
values: dict[str, str] = {}
|
|
185
|
+
for name, marker, ask in VENUES:
|
|
186
|
+
already = " (already set)" if existing.get(marker) else ""
|
|
187
|
+
if p.confirm(f"\nSet up {name}{already}?", default=False):
|
|
188
|
+
values.update(ask(p))
|
|
189
|
+
if not values:
|
|
190
|
+
p.say("\nNothing changed.")
|
|
191
|
+
return 0
|
|
192
|
+
text = merge(path.read_text(encoding="utf-8") if path.exists() else "", values)
|
|
193
|
+
write_private(path, text)
|
|
194
|
+
p.say(f"\nWrote {path}, readable by you only.")
|
|
195
|
+
try:
|
|
196
|
+
add = _git_ignores(path) is False and p.confirm(f"{path.name} is not ignored by git. Add it to .gitignore?",
|
|
197
|
+
default=True)
|
|
198
|
+
except (KeyboardInterrupt, EOFError):
|
|
199
|
+
p.say("")
|
|
200
|
+
add = False
|
|
201
|
+
if add:
|
|
202
|
+
ignore = path.parent / ".gitignore"
|
|
203
|
+
current = ignore.read_text(encoding="utf-8") if ignore.exists() else ""
|
|
204
|
+
ignore.write_text(current + ("" if not current or current.endswith("\n") else "\n") + f"{path.name}\n",
|
|
205
|
+
encoding="utf-8")
|
|
206
|
+
p.say(f"Added {path.name} to {ignore}.")
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main(argv: list[str] | None = None) -> int:
|
|
211
|
+
parser = argparse.ArgumentParser(prog="synpath init", description=__doc__.split("\n\n")[0])
|
|
212
|
+
parser.add_argument("--path", default=".env", help="the file to write (default: .env in this folder)")
|
|
213
|
+
args = parser.parse_args(argv)
|
|
214
|
+
if not sys.stdin.isatty():
|
|
215
|
+
print("synpath init asks questions: run it in a terminal.", file=sys.stderr)
|
|
216
|
+
return 2
|
|
217
|
+
path = Path(args.path).expanduser().resolve()
|
|
218
|
+
try:
|
|
219
|
+
result = run(path, Prompter())
|
|
220
|
+
except (KeyboardInterrupt, EOFError):
|
|
221
|
+
print("\nStopped; nothing was written.")
|
|
222
|
+
return 130
|
|
223
|
+
if result == 0 and path.exists():
|
|
224
|
+
from .__main__ import doctor
|
|
225
|
+
|
|
226
|
+
print("\nChecking what loads:\n")
|
|
227
|
+
doctor(str(path))
|
|
228
|
+
print("\nNext: synpath serve")
|
|
229
|
+
return result
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__": # pragma: no cover
|
|
233
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""The market master: what an order must satisfy, cached and dated.
|
|
2
|
+
|
|
3
|
+
Every order is checked against reference data before it is signed -- the
|
|
4
|
+
tick, the minimum size, whether the market is taking orders right now, when
|
|
5
|
+
it closes. The read API has all of it on a `Market`; this turns one into a
|
|
6
|
+
compact spec per market, applies the rules each venue does not write into
|
|
7
|
+
its payload, and keeps the result with the time it was read so a stale spec
|
|
8
|
+
is visibly stale rather than silently trusted.
|
|
9
|
+
|
|
10
|
+
Venue rules that are not in any payload:
|
|
11
|
+
|
|
12
|
+
Kalshi contracts are fixed-point with two decimals; tick from the
|
|
13
|
+
market's price ladder, 0.01 when it publishes none
|
|
14
|
+
Polymarket five shares minimum; tick per market, 0.01 by default
|
|
15
|
+
Polymarket US whole contracts only; tick per market
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import time
|
|
20
|
+
from decimal import Decimal
|
|
21
|
+
from typing import Any, Awaitable, Callable
|
|
22
|
+
|
|
23
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
24
|
+
|
|
25
|
+
from .. import ids
|
|
26
|
+
from ..types import Market
|
|
27
|
+
from .money import D
|
|
28
|
+
from .types import Precision
|
|
29
|
+
|
|
30
|
+
VENUE_RULES: dict[str, dict[str, Any]] = {
|
|
31
|
+
"kalshi": {"default_tick": Decimal("0.01"), "min_amount": Decimal("0.01"), "amount_step": Decimal("0.01"), "whole": False},
|
|
32
|
+
# Shares carry two decimals; the minimum (usually 5) is per market, read
|
|
33
|
+
# from its book by the trading adapter.
|
|
34
|
+
"polymarket": {"default_tick": Decimal("0.01"), "min_amount": Decimal("5"), "amount_step": Decimal("0.01"), "whole": False},
|
|
35
|
+
# Every live market trades whole contracts today, but a market may set a
|
|
36
|
+
# fractional `minimumTradeQty`; the trading adapters read it per market.
|
|
37
|
+
"polymarket_us": {"default_tick": Decimal("0.001"), "min_amount": Decimal("1"), "amount_step": Decimal("1"), "whole": True},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MarketSpec(BaseModel):
|
|
42
|
+
model_config = ConfigDict(extra="forbid")
|
|
43
|
+
|
|
44
|
+
market_id: str
|
|
45
|
+
"""Synpath id, `venue:native`."""
|
|
46
|
+
venue: str
|
|
47
|
+
precision: Precision
|
|
48
|
+
tradable: bool
|
|
49
|
+
"""Accepting orders right now, as far as the catalog knew."""
|
|
50
|
+
tradable_yes: bool = True
|
|
51
|
+
tradable_no: bool = True
|
|
52
|
+
"""Per side, where a venue says (Polymarket US). Both true elsewhere."""
|
|
53
|
+
status: str
|
|
54
|
+
close_at: int | None = None
|
|
55
|
+
resolution_at: int | None = None
|
|
56
|
+
position_limit: Decimal | None = None
|
|
57
|
+
"""A per-market cap the venue imposes, where it publishes one."""
|
|
58
|
+
yes_token: str | None = None
|
|
59
|
+
no_token: str | None = None
|
|
60
|
+
"""The venue's own ids for the two sides, where it has them (Polymarket)."""
|
|
61
|
+
read_at: int = Field(default_factory=lambda: int(time.time() * 1000))
|
|
62
|
+
info: dict[str, Any] = Field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
def age_s(self, now_ms: int | None = None) -> float:
|
|
65
|
+
now = now_ms if now_ms is not None else int(time.time() * 1000)
|
|
66
|
+
return max(0.0, (now - self.read_at) / 1000)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
InstrumentSpec = MarketSpec
|
|
70
|
+
"""The old name, kept so an import written against it still resolves."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def precision_for(market: Market) -> Precision:
|
|
74
|
+
rules = VENUE_RULES.get(market.venue, VENUE_RULES["polymarket"])
|
|
75
|
+
tick = D(market.tick_size) if market.tick_size else rules["default_tick"]
|
|
76
|
+
return Precision(
|
|
77
|
+
tick=tick,
|
|
78
|
+
min_amount=rules["min_amount"],
|
|
79
|
+
amount_step=rules["amount_step"],
|
|
80
|
+
whole_contracts=rules["whole"],
|
|
81
|
+
face_value=D(market.face_value),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def spec_from_market(market: Market) -> MarketSpec:
|
|
86
|
+
yes_info, no_info = market.yes.info or {}, market.no.info or {}
|
|
87
|
+
tradable_yes = bool(yes_info.get("tradable", True)) # Polymarket US publishes it per side
|
|
88
|
+
tradable_no = bool(no_info.get("tradable", True))
|
|
89
|
+
return MarketSpec(
|
|
90
|
+
market_id=market.id,
|
|
91
|
+
venue=market.venue,
|
|
92
|
+
precision=precision_for(market),
|
|
93
|
+
tradable=bool(market.active) and tradable_yes and tradable_no,
|
|
94
|
+
tradable_yes=tradable_yes,
|
|
95
|
+
tradable_no=tradable_no,
|
|
96
|
+
status=market.status,
|
|
97
|
+
close_at=market.close_timestamp,
|
|
98
|
+
resolution_at=market.resolution_timestamp,
|
|
99
|
+
position_limit=None,
|
|
100
|
+
yes_token=market.yes.venue_token_id,
|
|
101
|
+
no_token=market.no.venue_token_id,
|
|
102
|
+
info={"native_status": market.native_status},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
Loader = Callable[[str, list[str]], Awaitable[list[Market]]]
|
|
107
|
+
"""`(venue, market_ids) -> markets`, however the caller reaches the venue."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class MarketMaster:
|
|
111
|
+
"""Specs by Synpath market id, with a freshness rule.
|
|
112
|
+
|
|
113
|
+
Reads never block on the network: `get` returns what is held or raises
|
|
114
|
+
`KeyError`, and `stale` says which held specs are past `ttl_s` so the
|
|
115
|
+
caller can refresh them through a loader of its choosing. Loading is the
|
|
116
|
+
caller's concern because the read adapters are synchronous today and the
|
|
117
|
+
engine will not run them on its loop thread.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(self, *, ttl_s: float = 300.0):
|
|
121
|
+
self.ttl_s = ttl_s
|
|
122
|
+
self._specs: dict[str, MarketSpec] = {}
|
|
123
|
+
|
|
124
|
+
def put(self, specs: list[MarketSpec]) -> None:
|
|
125
|
+
for spec in specs:
|
|
126
|
+
self._specs[spec.market_id] = spec
|
|
127
|
+
|
|
128
|
+
def put_market(self, market: Market) -> None:
|
|
129
|
+
self.put([spec_from_market(market)])
|
|
130
|
+
|
|
131
|
+
def get(self, market_id: str) -> MarketSpec:
|
|
132
|
+
return self._specs[market_id]
|
|
133
|
+
|
|
134
|
+
def find(self, market_id: str) -> MarketSpec | None:
|
|
135
|
+
return self._specs.get(market_id)
|
|
136
|
+
|
|
137
|
+
def stale(self, market_ids: list[str] | None = None, *, now_ms: int | None = None) -> list[str]:
|
|
138
|
+
"""Held specs older than `ttl_s`, plus ids not held at all."""
|
|
139
|
+
wanted = market_ids if market_ids is not None else list(self._specs)
|
|
140
|
+
out = []
|
|
141
|
+
for market_id in wanted:
|
|
142
|
+
spec = self._specs.get(market_id)
|
|
143
|
+
if spec is None or spec.age_s(now_ms) > self.ttl_s:
|
|
144
|
+
out.append(market_id)
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
async def refresh(self, loader: Loader, market_ids: list[str]) -> None:
|
|
148
|
+
"""Reload `market_ids` through `loader`, grouped by venue. The venue
|
|
149
|
+
is read off the id itself, which is what the prefix is for."""
|
|
150
|
+
wanted: dict[str, list[str]] = {}
|
|
151
|
+
for market_id in market_ids:
|
|
152
|
+
wanted.setdefault(ids.venue_of(market_id), []).append(market_id)
|
|
153
|
+
for venue, batch in wanted.items():
|
|
154
|
+
for market in await loader(venue, list(dict.fromkeys(batch))):
|
|
155
|
+
self.put_market(market)
|
|
156
|
+
|
|
157
|
+
def __len__(self) -> int:
|
|
158
|
+
return len(self._specs)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
InstrumentMaster = MarketMaster
|
|
162
|
+
"""The old name, kept so an import written against it still resolves."""
|