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,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())
|
synpath/trading/base.py
ADDED
|
@@ -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]
|