Synpath
SDKs

Python SDK

The Synpath Python library: one API for Kalshi, Polymarket, Polymarket US and Opinion, in process.

Installation

pip install synpath

Python 3.10 or newer. One install covers market data, order entry, live streams, the execution engine and the REST server. Only the Polymarket US exchange API's gRPC streams are an extra: pip install "synpath[grpc]".

Quick Start

import synpath

client = synpath.Client()                   # market data needs no credentials

# Search one venue. Results are ranked by relevance and come back as a page.
page = client.fetch_markets(venue="kalshi", query="fed rate cut", limit=5)
for market in page:
    print(f"ID: {market.id}, Title: {market.title}, YES ask: {market.yes.quote.ask}")

# Every id starts with its venue, so one call reaches any of them.
market = client.fetch_market("polymarket:2252244")
book = client.fetch_order_book(market.id, depth=5)
print(book.best_bid, book.best_ask)

See Synpath IDs for the id format and Schemas for every field on a Market.

Reads are synchronous and order entry is async: a strategy awaits its orders while its data calls stay plain. The per-venue clients (synpath.Kalshi(), KalshiTrading(...)) are what Client routes to and can be used directly.

Credentials

Market data needs none. Order entry uses each venue's own key, read from the environment or a .env file next to your code, and never leaves your machine. Cross-venue matching and historical data are hosted by Synpath and take a Synpath API key, SYNPATH_API_KEY.

import synpath

client = synpath.Client(synpath.load_credentials())   # KALSHI_*, POLYMARKET_*, POLYMARKET_US_* from .env
synpath init        # asks for your venue keys and writes .env, readable by you only
synpath doctor      # which venues are configured; prints no secret

The variables each venue needs are on the Authentication page.

Trading

Place an Order

from synpath import OrderRequest

order = await client.create_order(OrderRequest(
    market_id="polymarket:2252244",
    side="buy",              # buy takes YES, sell takes NO
    type="limit",            # limit or market
    time_in_force="gtc",     # gtc, ioc, fok, gtd
    price="0.42",            # always the YES price, 0 to 1
    amount=10,               # contracts
))
print(f"Order placed: {order.id} ({order.status})")

Selling at 0.70 is the same order as buying NO at 0.30. On Polymarket, where YES and NO are separate tokens, sell buys the NO token; pass reduce_only=True to sell YES tokens you hold instead.

List Orders

for order in await client.fetch_open_orders(venue="polymarket"):
    print(f"{order.id}: {order.status}, {order.filled}/{order.amount}")

Get an Order

order = await client.fetch_order(order.id, market_id=order.market_id)
print(order.status, order.filled, order.remaining)

Edit an Order

from synpath import EditRequest

order = await client.edit_order(EditRequest(order_id=order.id, price="0.41", amount=8), venue="polymarket")
print(order.queue_priority_preserved)   # Kalshi keeps queue place on a size decrease; Polymarket never does

Cancel an Order

cancelled = await client.cancel_order(order.id, market_id=order.market_id)
print(cancelled.status, cancelled.filled)   # canceled, and whatever matched first

await client.cancel_all_orders(market_id="polymarket:2252244")

Complex Orders

Stops, trailing stops, icebergs, one-cancels-the-other, brackets, TWAP, pegs, smart takers and orders on a bucket are held by the execution engine and sent to the venue as ordinary orders when their condition is met. The engine is your self-hosted server (synpath serve), which must be running; every parent is journaled there, so a restart resumes it where it was. Point the client at the server and the trading calls go through it.

synpath serve            # your self-hosted server: holds your venue keys, runs the engine and the streams
from synpath import Client, OrderRequest, OrderType

client = Client(server="http://127.0.0.1:8000")     # access token found on this machine; pass access_token= for a remote server

Stop

stop = await client.create_order(OrderRequest(
    market_id="kalshi:KXELONMARS-99", side="sell", amount=20,
    type=OrderType.STOP_MARKET, stop_price="0.40",
    params={"trigger_source": "touch", "max_slippage": "0.02"},
))
print(stop.status)      # waiting, then triggered

Trailing stop

await client.create_order(OrderRequest(
    market_id="kalshi:KXELONMARS-99", side="sell", amount=20,
    type=OrderType.TRAILING_STOP, stop_price="0.40", params={"trail": "0.03"},
))

Iceberg

await client.create_order(OrderRequest(
    market_id="kalshi:KXELONMARS-99", side="buy", amount=1000, price="0.45",
    type=OrderType.ICEBERG, params={"display": "50", "reload_delay_s": 2},
))

One cancels the other

await client.create_order(OrderRequest(
    market_id="kalshi:KXELONMARS-99", side="sell", amount=100, type=OrderType.OCO,
    params={"legs": [
        {"side": "sell", "type": "limit", "price": "0.60"},             # take profit
        {"side": "sell", "type": "stop_market", "stop_price": "0.35"},  # stop loss
    ]},
))

Bracket

await client.create_order(OrderRequest(
    market_id="kalshi:KXELONMARS-99", side="buy", amount=100, type=OrderType.BRACKET,
    params={"entry": {"type": "limit", "price": "0.40"},
            "take_profit": {"price": "0.55"},
            "stop_loss": {"stop_price": "0.30"}},
))

TWAP

await client.create_order(OrderRequest(
    market_id="kalshi:KXELONMARS-99", side="buy", amount=600, price="0.45",
    type=OrderType.TWAP, params={"window_s": 1800, "slices": 12, "style": "limit"},
))

Peg

await client.create_order(OrderRequest(
    market_id="kalshi:KXELONMARS-99", side="buy", amount=100,
    type=OrderType.PEG, params={"reference": "near", "max_price": "0.55", "min_stay_s": 5},
))

Smart taker

await client.create_order(OrderRequest(
    market_id="kalshi:KXELONMARS-99", side="buy", amount=500,
    type=OrderType.SMART_TAKER, params={"clip": "50", "interval_s": 2, "limit": "0.48"},
))

Every parameter each type takes is on the Create complex order page. Cancel the parent with await client.cancel_order(parent.id) and every live child is pulled.

Buckets

A bucket is one instrument made of the same proposition on several venues. A market order on it is routed across the members at the best prices net of fees and reported as one order with a weighted-average price. See the Smart Order Routing - Buckets concept page.

from synpath import BucketMember

bucket = await client.create_bucket(book="alpha", name="Fed cut in September", members=[
    BucketMember(market_id="kalshi:KXFEDDECISION-26SEP-C25"),
    BucketMember(market_id="polymarket:2252244", flip=True),     # this listing is worded the other way round
])
order = await client.create_order(OrderRequest(
    market_id=bucket.market_id, side="buy", amount=200, type=OrderType.MARKET, price="0.42",   # the worst price you accept
))
report = await client.fetch_bucket_order(bucket.id, order.id)      # filled, average, per venue
position = await client.fetch_bucket_position(bucket.id)           # netted in bucket terms

Portfolio

Positions

for p in await client.fetch_positions():
    print(p.market_id, p.side, p.contracts, p.entry_price)   # long holds YES, short holds NO

Fills

page = await client.fetch_my_trades(venue="polymarket")
for fill in page:
    print(fill.market_id, fill.side, fill.price, fill.amount, fill.fee, fill.settlement)

Balances

balance = await client.fetch_balance("kalshi")
print(balance.total, balance.available, balance.locked)

P&L

Through the engine, whose ledger nets every fill on the YES leg and rolls it up:

engine.pnl("book")        # per strategy
engine.pnl("market")      # per contract
engine.pnl("account")

Market Data

Search Markets

# One venue, one page
page = client.fetch_markets(venue="kalshi", query="bitcoin", limit=20, sort="volume")
next_page = client.fetch_markets(venue="kalshi", query="bitcoin", limit=20, cursor=page.next_cursor)

# Every venue's first page at once
page = client.fetch_markets(query="fed")

# Events, with their markets nested
events = client.exchange("polymarket").fetch_events(query="election", limit=10)
ParameterTypeDescription
venuestr | Nonekalshi, polymarket or polymarket_us. Omit for every venue's first page.
querystr | NoneText filter, server-side on every venue, ranked by relevance.
limitint | NoneRows per page, up to 100.
cursorstr | NoneFrom a previous page's next_cursor. Needs venue.
statusstropen (default), closed, settled or all. Polymarket cannot filter by settled.
sortstr | Nonevolume, liquidity or newest.

One Market

market = client.fetch_market("kalshi:KXELONMARS-99")
markets = client.fetch_markets_by_ids(["kalshi:KXELONMARS-99", "polymarket:2252244"])   # batched per venue

Order Book

book = client.fetch_order_book("polymarket:2252244", depth=10)      # YES side
no = client.fetch_order_book("polymarket:2252244", side="no")      # what NO costs
books = client.fetch_order_books(["polymarket:2252244", "polymarket:2252245"])   # one round trip

Trades and OHLCV

trades = client.fetch_trades("kalshi:KXELONMARS-99", limit=100)                  # in the YES price
candles = client.fetch_ohlcv("kalshi:KXELONMARS-99", timeframe="1h", limit=24)   # the newest 24 bars
history = client.fetch_ohlcv("kalshi:KXELONMARS-99", timeframe="1m",
                             since=1767225600000)                              # every bar since 2026-01-01
for c in candles:
    print(c.datetime, c.open, c.close, c.volume, c.price_source)               # read price_source

Fees

fee = client.fetch_fee_schedule("kalshi:KXELONMARS-99")
fee.estimate(price=0.50, contracts=100)     # 1.75, before you trade

Capabilities

A capability a venue lacks raises NotSupported rather than returning an empty list. Ask first:

kalshi = client.exchange("kalshi")
kalshi.has["fetch_ohlcv"]        # True, False or "partial"

Historical Data

Tick-level historical order books and trades, from Synpath's hosted history service. Needs a Synpath API key, which you create with synpath login and synpath keys create (see Authentication). The client reads a saved key automatically; SYNPATH_API_KEY overrides it. Times are Unix milliseconds; coverage has gaps and varies by market, so a missing book comes back with an absence_reason, never as an empty book.

import synpath

at = synpath.fetch_order_book_at("kalshi:KXQUANTUM-30", as_of_ms=1789509599000)
print(at.book.best_bid if at.book else at.absence_reason)

trades = synpath.fetch_trades_range("kalshi:KXQUANTUM-30", start_ms, end_ms)
changes = synpath.fetch_order_book_range("kalshi:KXQUANTUM-30", start_ms, end_ms)

Realtime (WebSockets)

Each venue has a stream class that turns its WebSocket into typed events and reconnects on its own. Subscribe with watch_* calls that take Synpath ids, then iterate.

import asyncio
from synpath import PolymarketMarketStream, BookEvent, TradeEvent

async def main():
    async with PolymarketMarketStream() as stream:
        await stream.watch_order_book(["polymarket:2252244"])
        async for event in stream:
            if isinstance(event, BookEvent):
                print(event.side, event.best_bid, event.best_ask)
            elif isinstance(event, TradeEvent):
                print("trade", event.price, event.amount)

asyncio.run(main())

Orders and fills stream the same way from KalshiStream, PolymarketUserStream and PolymarketUSPrivateStream. Every stream, event and guarantee is in the WebSocket API reference.

Errors

Every error is typed, and the type says whether a retry is reasonable. Through Client(server=...) the same exceptions are raised, rebuilt from your server's error body.

SynpathError
├── NetworkError            no verdict from the venue; retrying is reasonable
│   ├── RequestTimeout
│   ├── RateLimitExceeded   .retry_after when the venue says
│   └── ExchangeNotAvailable
├── ExchangeError           the venue answered and said no
│   ├── BadRequest, MarketNotFound, AuthenticationError
│   ├── InvalidOrder        off the tick, under the minimum
│   ├── OrderRejected       the venue's own reason in .reason
│   └── InsufficientFunds, MarketHalted, OrderNotFound
├── RiskRejected            the engine refused before sending; .rule names the rule
└── NotSupported            this venue has no such capability
import synpath

try:
    order = await client.create_order(request)
except synpath.RateLimitExceeded as exc:
    await asyncio.sleep(exc.retry_after or 1)     # back off and retry
except synpath.OrderRejected as exc:
    print("refused:", exc.reason)                 # the venue's own word