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,79 @@
1
+ """synpath.trading — order entry, the foundations.
2
+
3
+ Everything in this package is async-native and money is `Decimal`. Every
4
+ public name here is re-exported from the top-level package, so
5
+ `from synpath import OrderRequest` and `from synpath.trading import
6
+ OrderRequest` are the same thing.
7
+
8
+ ```python
9
+ from synpath import KalshiTrading, OrderRequest, Side, OrderType, TimeInForce, load_credentials
10
+ ```
11
+
12
+ The venue signing stacks are part of the base install (`pip install
13
+ synpath`). Should one be missing from an unusual environment, importing this
14
+ package still works -- the types, money helpers and credential loading need
15
+ nothing extra -- and the first thing that needs a signer says what to install.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from .base import TradingExchange
20
+ from .errors import (
21
+ CredentialsMissing,
22
+ DuplicateClientOrderId,
23
+ InsufficientFunds,
24
+ InvalidOrder,
25
+ MarketHalted,
26
+ OrderNotFound,
27
+ OrderRejected,
28
+ PermissionDenied,
29
+ RateBudgetExceeded,
30
+ RiskRejected,
31
+ )
32
+ from .types import (
33
+ Account,
34
+ Balance,
35
+ EditRequest,
36
+ FeeEstimate,
37
+ Fill,
38
+ HeldBy,
39
+ Liquidity,
40
+ Order,
41
+ OrderRequest,
42
+ OrderStatus,
43
+ OrderType,
44
+ Position,
45
+ PositionSide,
46
+ Precision,
47
+ Settlement,
48
+ SettlementState,
49
+ Side,
50
+ TimeInForce,
51
+ )
52
+
53
+ from .kalshi import KalshiTrading
54
+
55
+
56
+ def __getattr__(name: str):
57
+ # The Polymarket adapters pull in eth-account and PyJWT; importing them
58
+ # lazily keeps `import synpath.trading` working for a Kalshi-only install.
59
+ if name == "PolymarketTrading":
60
+ from .polymarket import PolymarketTrading
61
+ return PolymarketTrading
62
+ if name == "PolymarketUSTrading":
63
+ from .polymarket_us import PolymarketUSTrading
64
+ return PolymarketUSTrading
65
+ if name == "PolymarketUSExchangeTrading":
66
+ from .polymarket_us_exchange import PolymarketUSExchangeTrading
67
+ return PolymarketUSExchangeTrading
68
+ raise AttributeError(name)
69
+
70
+
71
+ __all__ = [
72
+ "TradingExchange", "KalshiTrading", "PolymarketTrading", "PolymarketUSTrading", "PolymarketUSExchangeTrading",
73
+ "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity",
74
+ "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide",
75
+ "Precision", "Settlement", "SettlementState", "Side", "TimeInForce",
76
+ "CredentialsMissing", "DuplicateClientOrderId", "InsufficientFunds", "InvalidOrder",
77
+ "MarketHalted", "OrderNotFound", "OrderRejected", "PermissionDenied",
78
+ "RateBudgetExceeded", "RiskRejected",
79
+ ]
@@ -0,0 +1,69 @@
1
+ """`synpath doctor`: what credentials loaded, without printing one.
2
+
3
+ Reads the environment and a `.env` in the working directory, loads each
4
+ venue's credentials the way the adapters will, and reports per venue whether
5
+ anything is configured, which environment (demo, preprod, prod) it points
6
+ at, and the public half of the identity (a key id, an API key prefix, a
7
+ funder address). Secrets are never printed; the logging filter that scrubs
8
+ them is installed first.
9
+
10
+ The venue round trip -- calling one harmless authenticated endpoint per
11
+ venue -- arrives with each trading adapter.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import logging
17
+ import sys
18
+
19
+ from .credentials import ENV_NAMES, load_credentials
20
+ from .errors import CredentialsMissing
21
+
22
+
23
+ def doctor(dotenv: str | None) -> int:
24
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
25
+ try:
26
+ loaded = load_credentials(dotenv=dotenv)
27
+ except CredentialsMissing as exc:
28
+ print(f"credentials: {exc}")
29
+ return 2
30
+ rows = []
31
+ for venue, creds in loaded.items():
32
+ if creds is None:
33
+ names = ENV_NAMES[venue]
34
+ hint = ", or run synpath init" if venue in ("kalshi", "polymarket", "polymarket_us") else ""
35
+ rows.append((venue, "not configured", f"set {names[0]}{hint}"))
36
+ continue
37
+ stage = getattr(creds, "env", "-")
38
+ if getattr(creds, "key_id", None):
39
+ identity = f"key id {creds.key_id}"
40
+ elif getattr(creds, "participant_id", None):
41
+ identity = f"participant {creds.participant_id}"
42
+ elif getattr(creds, "funder", None):
43
+ identity = f"funder {creds.funder} (signature type {creds.signature_type})"
44
+ elif getattr(creds, "api_key", None):
45
+ identity = f"api key {creds.api_key[:8]}…"
46
+ else:
47
+ identity = "wallet key loaded"
48
+ rows.append((venue, f"configured ({stage})", identity))
49
+ width = max(len(r[0]) for r in rows)
50
+ for venue, state, detail in rows:
51
+ print(f"{venue:<{width}} {state:<22} {detail}")
52
+ configured = sum(1 for _, state, _ in rows if state.startswith("configured"))
53
+ print(f"\n{configured} of {len(rows)} venues configured. No secret was printed.")
54
+ return 0
55
+
56
+
57
+ def main(argv: list[str] | None = None) -> int:
58
+ parser = argparse.ArgumentParser(prog="synpath")
59
+ sub = parser.add_subparsers(dest="command", required=True)
60
+ doc = sub.add_parser("doctor", help="report which venue credentials load")
61
+ doc.add_argument("--dotenv", default=None, help="path to a .env file (default: ./.env if present)")
62
+ args = parser.parse_args(argv)
63
+ if args.command == "doctor":
64
+ return doctor(args.dotenv)
65
+ return 1
66
+
67
+
68
+ if __name__ == "__main__": # pragma: no cover
69
+ sys.exit(main())
@@ -0,0 +1,126 @@
1
+ """What every venue's order entry offers, in one async interface.
2
+
3
+ A trading adapter answers the trading keys in `has` the same way a read
4
+ adapter answers the read keys: `True` for what the venue itself holds and
5
+ does, `False` for what it does not, filled in completely at class creation
6
+ so `has[key]` never raises. It claims nothing the venue does not do; a stop
7
+ or an iceberg is the execution engine's capability, reported by the engine.
8
+
9
+ Every method is a coroutine. The engine that will drive these holds
10
+ WebSockets on the same event loop, and a blocking call inside it would stall
11
+ every socket for one venue round trip -- the exact moment a stop must fire.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from abc import ABC, abstractmethod
16
+ from decimal import Decimal
17
+ from typing import Any
18
+
19
+ from ..base import Capability, complete_capabilities
20
+ from ..errors import NotSupported
21
+ from ..types import Page
22
+ from .types import (
23
+ Side,
24
+ Account, Balance, EditRequest, FeeEstimate, Fill, Order, OrderRequest, Position, Settlement,
25
+ )
26
+
27
+
28
+ class TradingExchange(ABC):
29
+ id: str
30
+ name: str
31
+ has: dict[str, Capability] = {}
32
+
33
+ def __init_subclass__(cls, **kwargs: Any) -> None:
34
+ super().__init_subclass__(**kwargs)
35
+ complete_capabilities(cls)
36
+
37
+ # -- orders ---------------------------------------------------------------
38
+
39
+ @abstractmethod
40
+ async def create_order(self, request: OrderRequest) -> Order:
41
+ """Place one order the venue holds: a limit, or a market where the
42
+ venue has them. Anything else belongs to the engine."""
43
+
44
+ async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]:
45
+ """Many orders in one call, one result per request in the order asked
46
+ for. A request the venue refused comes back as its exception rather
47
+ than failing the batch: partial success is normal."""
48
+ raise NotSupported(f"{self.id}: create_orders")
49
+
50
+ @abstractmethod
51
+ async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order:
52
+ """Cancel one order. Returns the order as the venue reports it after
53
+ the cancel, with `filled` carrying whatever matched first."""
54
+
55
+ async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]:
56
+ raise NotSupported(f"{self.id}: cancel_orders")
57
+
58
+ async def cancel_all_orders(self, *, market_id: str | None = None) -> int | None:
59
+ """Cancel every resting order, or every one on a market. Returns how
60
+ many the venue reports cancelling, or `None` where it acknowledges
61
+ without a count."""
62
+ raise NotSupported(f"{self.id}: cancel_all_orders")
63
+
64
+ async def edit_order(self, request: EditRequest, *, current: Order | None = None) -> Order:
65
+ """Change a resting order in place where the venue allows it, or by
66
+ cancel and replace where it does not. `Order.queue_priority_preserved`
67
+ says which happened."""
68
+ raise NotSupported(f"{self.id}: edit_order")
69
+
70
+ @abstractmethod
71
+ async def fetch_order(self, order_id: str) -> Order:
72
+ """One order by the venue's id."""
73
+
74
+ @abstractmethod
75
+ async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]:
76
+ """Every resting order, or every one on a market."""
77
+
78
+ async def fetch_orders(
79
+ self, *, status: str | None = None, market_id: str | None = None,
80
+ since: int | None = None, limit: int | None = None, cursor: str | None = None,
81
+ ) -> Page[Order]:
82
+ raise NotSupported(f"{self.id}: fetch_orders")
83
+
84
+ async def fetch_my_trades(
85
+ self, *, market_id: str | None = None, order_id: str | None = None,
86
+ since: int | None = None, limit: int | None = None, cursor: str | None = None,
87
+ ) -> Page[Fill]:
88
+ raise NotSupported(f"{self.id}: fetch_my_trades")
89
+
90
+ async def fetch_queue_position(self, order_id: str) -> Decimal:
91
+ """Contracts ahead of this order at its price level."""
92
+ raise NotSupported(f"{self.id}: fetch_queue_position")
93
+
94
+ # -- account --------------------------------------------------------------
95
+
96
+ @abstractmethod
97
+ async def fetch_balance(self, *, account: Account | None = None) -> Balance:
98
+ """This account's balance at this venue. Never pooled with another."""
99
+
100
+ @abstractmethod
101
+ async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]:
102
+ """Open positions, in this venue's own netting model."""
103
+
104
+ async def fetch_settlements(
105
+ self, *, market_id: str | None = None, since: int | None = None,
106
+ limit: int | None = None, cursor: str | None = None,
107
+ ) -> Page[Settlement]:
108
+ raise NotSupported(f"{self.id}: fetch_settlements")
109
+
110
+ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, amount: Decimal) -> FeeEstimate:
111
+ """What an order on this market would cost, in the YES price."""
112
+ raise NotSupported(f"{self.id}: fetch_fee_estimate")
113
+
114
+ # -- housekeeping ---------------------------------------------------------
115
+
116
+ async def close(self) -> None:
117
+ pass
118
+
119
+ async def __aenter__(self):
120
+ return self
121
+
122
+ async def __aexit__(self, *exc: Any) -> None:
123
+ await self.close()
124
+
125
+ def __repr__(self) -> str:
126
+ return f"<{type(self).__name__} {self.id}>"
@@ -0,0 +1,400 @@
1
+ """Venue credentials: where they come from, and where they never go.
2
+
3
+ They come from the environment, or from a `.env` file next to the caller
4
+ that is git-ignored; the environment wins. They are
5
+ loaded once into frozen objects whose `repr` shows nothing secret, and every
6
+ secret value is registered with a logging filter so that a stray `%r` in a
7
+ log line prints `***` rather than a private key.
8
+
9
+ Nothing here talks to a venue. `synpath doctor` reports what
10
+ loaded; the adapters are what use it.
11
+
12
+ Kalshi KALSHI_KEY_ID, KALSHI_PRIVATE_KEY_PATH, KALSHI_ENV (prod, the default, or demo)
13
+ Polymarket POLYMARKET_PRIVATE_KEY, POLYMARKET_SIGNATURE_TYPE (0-3),
14
+ POLYMARKET_FUNDER, and optionally POLYMARKET_API_KEY /
15
+ _API_SECRET / _API_PASSPHRASE, POLYMARKET_BUILDER_CODE,
16
+ POLYMARKET_RELAYER_API_KEY / _RELAYER_API_KEY_ADDRESS,
17
+ POLYMARKET_RPC_URL
18
+ Polymarket US POLYMARKET_US_KEY_ID, POLYMARKET_US_SECRET_KEY
19
+ (retail API)
20
+ Polymarket US POLYMARKET_US_CLIENT_ID, POLYMARKET_US_PRIVATE_KEY_PATH,
21
+ (exchange API) POLYMARKET_US_PARTICIPANT_ID, POLYMARKET_US_ACCOUNT,
22
+ POLYMARKET_US_ENV (preprod|prod)
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import os
28
+ import re
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Any, Literal, Mapping
32
+
33
+ from .errors import CredentialsMissing
34
+
35
+ REDACTED = "***"
36
+
37
+
38
+ def _redacted_repr(self: Any) -> str:
39
+ public = {k: v for k, v in self.__dict__.items() if k in self._public}
40
+ return f"{type(self).__name__}({', '.join(f'{k}={v!r}' for k, v in public.items())}, secrets={REDACTED})"
41
+
42
+
43
+ @dataclass(frozen=True, repr=False)
44
+ class KalshiCredentials:
45
+ key_id: str
46
+ private_key_pem: bytes = field(repr=False)
47
+ """RSA private key, PEM. Signs `timestamp + method + path` per request."""
48
+ env: Literal["demo", "prod"] = "prod"
49
+ """`prod` trades on Kalshi itself; `demo` on its practice exchange."""
50
+ _public = ("key_id", "env")
51
+ __repr__ = _redacted_repr
52
+
53
+ @property
54
+ def secrets(self) -> list[str]:
55
+ return [self.private_key_pem.decode(errors="ignore")]
56
+
57
+
58
+ SYNPATH_BUILDER_CODE = "0x3373b88f438d41079965d0742b16c602aa932cbc3b7ee589db36cbf85778c869"
59
+ """Synpath's Polymarket builder code, attached to orders unless the account
60
+ sets its own or opts out (`POLYMARKET_BUILDER_CODE=none`). Its builder fee
61
+ rate is 0: it adds nothing to what an order costs."""
62
+
63
+ BUILDER_CODE = re.compile(r"^0x[0-9a-fA-F]{64}$")
64
+
65
+
66
+ @dataclass(frozen=True, repr=False)
67
+ class PolymarketCredentials:
68
+ private_key: str = field(repr=False)
69
+ """The signer's private key, hex. Signs every order (EIP-712) and, when
70
+ no API credentials are given, derives them."""
71
+ signature_type: int = 0
72
+ """Which wallet the orders spend from: 0 an EOA (allowlisted accounts
73
+ only), 1 a legacy Magic/Google proxy wallet, 2 a legacy Safe, 3 a
74
+ Deposit Wallet -- the default for every account created since
75
+ 2026-05-04."""
76
+ funder: str | None = None
77
+ """The account wallet that holds the pUSD and tokens. Required for types
78
+ 1-3; for an EOA it is the signer's own address."""
79
+ api_key: str | None = None
80
+ api_secret: str | None = field(default=None, repr=False)
81
+ api_passphrase: str | None = field(default=None, repr=False)
82
+ """CLOB API credentials, when already created. Derived from the wallet
83
+ key on first use otherwise."""
84
+ builder_code: str | None = SYNPATH_BUILDER_CODE
85
+ """A bytes32 builder code attached to every order for attribution. Public,
86
+ not a secret. Synpath's by default; None attaches none."""
87
+ relayer_api_key: str | None = field(default=None, repr=False)
88
+ relayer_api_key_address: str | None = None
89
+ """A Relayer API key (polymarket.com -> Settings -> API Keys) for gasless
90
+ wallet transactions: approvals, split, merge, redeem."""
91
+ rpc_url: str | None = None
92
+ """A Polygon JSON-RPC endpoint, for an EOA's own on-chain transactions."""
93
+ _public = ("signature_type", "funder", "api_key", "builder_code", "relayer_api_key_address")
94
+ __repr__ = _redacted_repr
95
+
96
+ @property
97
+ def secrets(self) -> list[str]:
98
+ return [v for v in (self.private_key, self.api_secret, self.api_passphrase, self.relayer_api_key) if v]
99
+
100
+
101
+ @dataclass(frozen=True, repr=False)
102
+ class PolymarketUSCredentials:
103
+ """The retail API at `api.polymarket.us`: a key from polymarket.us/developer
104
+ after identity verification in the app."""
105
+
106
+ key_id: str
107
+ secret_key: str = field(repr=False)
108
+ """Base64 Ed25519 private key, shown once at creation. Signs
109
+ `timestamp + METHOD + path` on every request."""
110
+ _public = ("key_id",)
111
+ __repr__ = _redacted_repr
112
+
113
+ @property
114
+ def secrets(self) -> list[str]:
115
+ return [self.secret_key]
116
+
117
+
118
+ @dataclass(frozen=True, repr=False)
119
+ class PolymarketUSExchangeCredentials:
120
+ """The exchange API at `api.{preprod,prod}.polymarketexchange.com`: a
121
+ firm onboarded by Polymarket US, with a client id, an RSA key pair and a
122
+ participant id per user."""
123
+
124
+ client_id: str
125
+ private_key_pem: bytes = field(repr=False)
126
+ """RSA private key, PEM. Signs the client-assertion JWT exchanged for a
127
+ three-minute access token."""
128
+ participant_id: str
129
+ """`firms/<firm>/users/<user>`, exactly as issued at onboarding."""
130
+ account: str | None = None
131
+ """The trading account orders are placed on. The first account the user
132
+ may trade when not given."""
133
+ env: Literal["preprod", "prod"] = "preprod"
134
+ _public = ("client_id", "participant_id", "account", "env")
135
+ __repr__ = _redacted_repr
136
+
137
+ @property
138
+ def secrets(self) -> list[str]:
139
+ return [self.private_key_pem.decode(errors="ignore")]
140
+
141
+
142
+ Credentials = KalshiCredentials | PolymarketCredentials | PolymarketUSCredentials | PolymarketUSExchangeCredentials
143
+
144
+ ENV_NAMES: dict[str, tuple[str, ...]] = {
145
+ "kalshi": ("KALSHI_KEY_ID", "KALSHI_PRIVATE_KEY_PATH", "KALSHI_ENV"),
146
+ "polymarket": (
147
+ "POLYMARKET_PRIVATE_KEY", "POLYMARKET_SIGNATURE_TYPE", "POLYMARKET_FUNDER",
148
+ "POLYMARKET_API_KEY", "POLYMARKET_API_SECRET", "POLYMARKET_API_PASSPHRASE",
149
+ "POLYMARKET_BUILDER_CODE", "POLYMARKET_RELAYER_API_KEY", "POLYMARKET_RELAYER_API_KEY_ADDRESS",
150
+ "POLYMARKET_RPC_URL",
151
+ ),
152
+ "polymarket_us": ("POLYMARKET_US_KEY_ID", "POLYMARKET_US_SECRET_KEY"),
153
+ "polymarket_us_exchange": (
154
+ "POLYMARKET_US_CLIENT_ID", "POLYMARKET_US_PRIVATE_KEY_PATH", "POLYMARKET_US_PARTICIPANT_ID",
155
+ "POLYMARKET_US_ACCOUNT", "POLYMARKET_US_ENV",
156
+ ),
157
+ }
158
+ """What each venue reads. The first entry is the one whose absence means
159
+ "not configured"; the rest are optional or have defaults."""
160
+
161
+
162
+ def read_dotenv(path: Path | str) -> dict[str, str]:
163
+ """A `.env` file as a dict. `KEY=value`, `#` comments, optional quotes.
164
+
165
+ Deliberately small: it exists so a user can keep credentials in a
166
+ git-ignored file without another dependency, not to be a shell.
167
+ """
168
+ values: dict[str, str] = {}
169
+ text = Path(path).read_text(encoding="utf-8")
170
+ for raw in text.splitlines():
171
+ line = raw.strip()
172
+ if not line or line.startswith("#") or "=" not in line:
173
+ continue
174
+ key, _, value = line.partition("=")
175
+ key = key.strip()
176
+ if key.startswith("export "):
177
+ key = key[7:].strip()
178
+ value = value.strip()
179
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
180
+ value = value[1:-1]
181
+ values[key] = value
182
+ return values
183
+
184
+
185
+ def _read_pem(path: str, *, var: str) -> bytes:
186
+ try:
187
+ data = Path(path).expanduser().read_bytes()
188
+ except OSError as exc:
189
+ raise CredentialsMissing(f"{var} points at {path!r}, which cannot be read: {exc}") from None
190
+ if b"-----BEGIN" not in data:
191
+ raise CredentialsMissing(
192
+ f"{var} points at {path!r}, which is not a PEM key (no '-----BEGIN' line). "
193
+ f"If the venue gave you a text file with an id line on top, keep only the "
194
+ f"BEGIN...END block."
195
+ )
196
+ return data
197
+
198
+
199
+ def load_kalshi(env: Mapping[str, str]) -> KalshiCredentials | None:
200
+ key_id = env.get("KALSHI_KEY_ID")
201
+ if not key_id:
202
+ return None
203
+ path = env.get("KALSHI_PRIVATE_KEY_PATH")
204
+ if not path:
205
+ raise CredentialsMissing("KALSHI_KEY_ID is set but KALSHI_PRIVATE_KEY_PATH is not")
206
+ stage = (env.get("KALSHI_ENV") or "prod").lower()
207
+ if stage not in ("demo", "prod"):
208
+ raise CredentialsMissing(f"KALSHI_ENV must be demo or prod, got {stage!r}")
209
+ return KalshiCredentials(key_id=key_id, private_key_pem=_read_pem(path, var="KALSHI_PRIVATE_KEY_PATH"), env=stage) # type: ignore[arg-type]
210
+
211
+
212
+ def load_polymarket(env: Mapping[str, str]) -> PolymarketCredentials | None:
213
+ key = env.get("POLYMARKET_PRIVATE_KEY")
214
+ if not key:
215
+ return None
216
+ raw_type = env.get("POLYMARKET_SIGNATURE_TYPE") or "0"
217
+ try:
218
+ signature_type = int(raw_type)
219
+ except ValueError:
220
+ signature_type = -1
221
+ if signature_type not in (0, 1, 2, 3):
222
+ raise CredentialsMissing(f"POLYMARKET_SIGNATURE_TYPE must be 0, 1, 2 or 3, got {raw_type!r}")
223
+ funder = env.get("POLYMARKET_FUNDER") or None
224
+ if signature_type != 0 and not funder:
225
+ raise CredentialsMissing(
226
+ f"POLYMARKET_SIGNATURE_TYPE={signature_type} spends from a smart wallet; set POLYMARKET_FUNDER "
227
+ f"to its address (polymarket.com -> profile menu)"
228
+ )
229
+ api = [env.get(name) or None for name in ("POLYMARKET_API_KEY", "POLYMARKET_API_SECRET", "POLYMARKET_API_PASSPHRASE")]
230
+ if any(api) and not all(api):
231
+ raise CredentialsMissing(
232
+ "POLYMARKET_API_KEY, POLYMARKET_API_SECRET and POLYMARKET_API_PASSPHRASE go together; "
233
+ "set all three, or none to derive them from the wallet key"
234
+ )
235
+ relayer = env.get("POLYMARKET_RELAYER_API_KEY") or None
236
+ relayer_address = env.get("POLYMARKET_RELAYER_API_KEY_ADDRESS") or None
237
+ if relayer and not relayer_address:
238
+ raise CredentialsMissing("POLYMARKET_RELAYER_API_KEY is set but POLYMARKET_RELAYER_API_KEY_ADDRESS is not")
239
+ return PolymarketCredentials(
240
+ private_key=key, signature_type=signature_type, funder=funder,
241
+ api_key=api[0], api_secret=api[1], api_passphrase=api[2],
242
+ builder_code=_builder_code(env.get("POLYMARKET_BUILDER_CODE")),
243
+ relayer_api_key=relayer, relayer_api_key_address=relayer_address,
244
+ rpc_url=env.get("POLYMARKET_RPC_URL") or None,
245
+ )
246
+
247
+
248
+ def _builder_code(raw: str | None) -> str | None:
249
+ """`POLYMARKET_BUILDER_CODE`: unset or empty is Synpath's code, `none`
250
+ attaches none, anything else must be a bytes32 hex code."""
251
+ value = (raw or "").strip()
252
+ if not value:
253
+ return SYNPATH_BUILDER_CODE
254
+ if value.lower() == "none":
255
+ return None
256
+ if not BUILDER_CODE.match(value):
257
+ raise CredentialsMissing(
258
+ f"POLYMARKET_BUILDER_CODE must be a bytes32 hex code (0x and 64 hex characters) or 'none', got {value!r}"
259
+ )
260
+ return value
261
+
262
+
263
+ def load_polymarket_us(env: Mapping[str, str]) -> PolymarketUSCredentials | None:
264
+ key_id = env.get("POLYMARKET_US_KEY_ID")
265
+ if not key_id:
266
+ return None
267
+ secret = env.get("POLYMARKET_US_SECRET_KEY")
268
+ if not secret:
269
+ raise CredentialsMissing("POLYMARKET_US_KEY_ID is set but POLYMARKET_US_SECRET_KEY is not")
270
+ return PolymarketUSCredentials(key_id=key_id, secret_key=secret)
271
+
272
+
273
+ def load_polymarket_us_exchange(env: Mapping[str, str]) -> PolymarketUSExchangeCredentials | None:
274
+ client_id = env.get("POLYMARKET_US_CLIENT_ID")
275
+ if not client_id:
276
+ return None
277
+ path = env.get("POLYMARKET_US_PRIVATE_KEY_PATH")
278
+ if not path:
279
+ raise CredentialsMissing("POLYMARKET_US_CLIENT_ID is set but POLYMARKET_US_PRIVATE_KEY_PATH is not")
280
+ participant = env.get("POLYMARKET_US_PARTICIPANT_ID")
281
+ if not participant:
282
+ raise CredentialsMissing(
283
+ "POLYMARKET_US_CLIENT_ID is set but POLYMARKET_US_PARTICIPANT_ID is not; it is issued at "
284
+ "onboarding as firms/<firm>/users/<user>"
285
+ )
286
+ stage = (env.get("POLYMARKET_US_ENV") or "preprod").lower()
287
+ if stage not in ("preprod", "prod"):
288
+ raise CredentialsMissing(f"POLYMARKET_US_ENV must be preprod or prod, got {stage!r}")
289
+ return PolymarketUSExchangeCredentials(
290
+ client_id=client_id,
291
+ private_key_pem=_read_pem(path, var="POLYMARKET_US_PRIVATE_KEY_PATH"),
292
+ participant_id=participant,
293
+ account=env.get("POLYMARKET_US_ACCOUNT") or None,
294
+ env=stage, # type: ignore[arg-type]
295
+ )
296
+
297
+
298
+ LOADERS = {
299
+ "kalshi": load_kalshi,
300
+ "polymarket": load_polymarket,
301
+ "polymarket_us": load_polymarket_us,
302
+ "polymarket_us_exchange": load_polymarket_us_exchange,
303
+ }
304
+
305
+
306
+ def load_credentials(
307
+ env: Mapping[str, str] | None = None, *, dotenv: Path | str | None = None,
308
+ redact_logs: bool = True,
309
+ ) -> dict[str, Credentials | None]:
310
+ """Every venue's credentials, or `None` where none are configured.
311
+
312
+ The process environment wins over the `.env` file, so a variable exported
313
+ for one run overrides what the file says. With `redact_logs`, every secret
314
+ loaded is registered with the logging filter before anything else can
315
+ print it.
316
+ """
317
+ merged: dict[str, str] = {}
318
+ if dotenv is not None and Path(dotenv).exists():
319
+ merged.update(read_dotenv(dotenv))
320
+ elif dotenv is None and Path(".env").exists():
321
+ merged.update(read_dotenv(".env"))
322
+ merged.update(env if env is not None else os.environ)
323
+ loaded = {venue: loader(merged) for venue, loader in LOADERS.items()}
324
+ if redact_logs:
325
+ for creds in loaded.values():
326
+ if creds is not None:
327
+ SecretFilter.install(creds.secrets)
328
+ return loaded
329
+
330
+
331
+ def require(venue: str, loaded: Mapping[str, Credentials | None]) -> Credentials:
332
+ """The venue's credentials, or `CredentialsMissing` naming what to set."""
333
+ creds = loaded.get(venue)
334
+ if creds is None:
335
+ names = ", ".join(ENV_NAMES.get(venue, ()))
336
+ raise CredentialsMissing(f"no {venue} credentials configured; set {names}")
337
+ return creds
338
+
339
+
340
+ class SecretFilter(logging.Filter):
341
+ """Replaces registered secret values with `***` in every log record.
342
+
343
+ Attached to the root logger once, so every logger in the process is
344
+ covered, including third-party ones that might echo a request body.
345
+ Secrets are matched as substrings, so a PEM's base64 lines are scrubbed
346
+ even when only part of the key is printed.
347
+ """
348
+
349
+ _instance: "SecretFilter | None" = None
350
+
351
+ def __init__(self) -> None:
352
+ super().__init__("synpath-secrets")
353
+ self._secrets: list[str] = []
354
+
355
+ @classmethod
356
+ def install(cls, secrets: list[str]) -> "SecretFilter":
357
+ if cls._instance is None:
358
+ cls._instance = cls()
359
+ logging.getLogger().addFilter(cls._instance)
360
+ for handler in logging.getLogger().handlers:
361
+ handler.addFilter(cls._instance)
362
+ cls._instance.add(secrets)
363
+ return cls._instance
364
+
365
+ def add(self, secrets: list[str]) -> None:
366
+ for secret in secrets:
367
+ for piece in _pieces(secret):
368
+ if piece and piece not in self._secrets:
369
+ self._secrets.append(piece)
370
+ self._secrets.sort(key=len, reverse=True)
371
+
372
+ def scrub(self, text: str) -> str:
373
+ for secret in self._secrets:
374
+ if secret in text:
375
+ text = text.replace(secret, REDACTED)
376
+ return text
377
+
378
+ def filter(self, record: logging.LogRecord) -> bool:
379
+ if not self._secrets:
380
+ return True
381
+ try:
382
+ message = record.getMessage()
383
+ except Exception: # pragma: no cover - a broken format string is not ours to fix
384
+ return True
385
+ scrubbed = self.scrub(message)
386
+ if scrubbed != message:
387
+ record.msg = scrubbed
388
+ record.args = ()
389
+ return True
390
+
391
+
392
+ def _pieces(secret: str) -> list[str]:
393
+ """A secret and, for a PEM, each of its base64 lines."""
394
+ pieces = [secret.strip()]
395
+ if "-----BEGIN" in secret:
396
+ pieces += [
397
+ line.strip() for line in secret.splitlines()
398
+ if line.strip() and not line.startswith("-----")
399
+ ]
400
+ return [p for p in pieces if len(p) >= 8]