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
@@ -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
@@ -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."""