Testing¶
The SDK ships a record/replay test layer that lets you capture real HTTP traffic once and serve it from disk forever after — no network, no auth, no flakiness.
from kalshi.testing import (
RecordingTransport,
AsyncRecordingTransport,
ReplayTransport,
AsyncReplayTransport,
FixtureNotFoundError,
)
Record once, replay forever¶
from pathlib import Path
from kalshi import KalshiClient
from kalshi.testing import RecordingTransport, ReplayTransport
FIXTURES = Path("tests/fixtures/kalshi")
# Record once against demo (or real) API:
def record() -> None:
with KalshiClient.from_env(transport=RecordingTransport(FIXTURES)) as c:
c.exchange.status()
c.markets.list(status="open", limit=5)
# Replay in tests — no network, no auth required:
def test_status_offline() -> None:
with KalshiClient(transport=ReplayTransport(FIXTURES)) as c:
assert c.exchange.status().exchange_active
Async mirror with AsyncRecordingTransport / AsyncReplayTransport and
AsyncKalshiClient.
How matching works¶
Requests are fingerprinted as HTTP method + URL path + sorted query parameters. Two things are deliberately ignored:
- The request body. Two POSTs with different bodies to the same path replay the same fixture.
- The
KALSHI-ACCESS-SIGNATUREandKALSHI-ACCESS-TIMESTAMPheaders. Your replays don't need a valid key; the signature drift between record and replay never causes a miss.
On-disk layout¶
Each (method, path) pair gets one JSON file:
fixtures/
GET_trade-api_v2_exchange_status.json
GET_trade-api_v2_markets.json
POST_trade-api_v2_portfolio_orders.json
Each file holds a list of {request, response} pairs. Replays cycle through
the list in FIFO order; when exhausted, they wrap to the first entry. This
lets you record the same call twice with different responses (e.g.
pre-and-post-order state).
Re-entering a fixtures dir¶
Recording into an existing dir appends pairs to the right file rather than overwriting it. If you want a clean re-record, delete the dir first.
Misses¶
def test_unknown_endpoint() -> None:
with KalshiClient(transport=ReplayTransport(FIXTURES)) as c:
try:
c.markets.get("UNRECORDED-TICKER")
except FixtureNotFoundError as e:
print("missing:", e)
FixtureNotFoundError (a LookupError subclass) signals a request that has
no matching fixture file. Catch it in tests where you expect uncovered paths.
When to re-record¶
Re-record after:
- An SDK upgrade that adds new request kwargs or response fields.
- A wire-spec drift (Kalshi adds a field; your fixtures are now stale).
- Any test that fails because the model can no longer parse the recorded body.
The contract-drift tests in CI catch model/spec misalignment; recorded fixtures are a separate caching layer below that.
Security¶
Recorded fixtures contain the full response body returned by Kalshi — balances, positions, order history, anything else the API returns:
Treat fixture directories like secrets unless scrubbed
Always add the fixtures directory to .gitignore unless you've manually
scrubbed the JSON. Prefer recording against the demo environment so the
captured account state isn't real money.
Mocking with respx (alternative)¶
If you don't want a disk fixture layer, the SDK accepts any
httpx.BaseTransport, so respx.mock works too:
import respx
from kalshi import KalshiClient
with respx.mock(base_url="https://api.elections.kalshi.com") as router:
router.get("/trade-api/v2/exchange/status").respond(200, json={"exchange_active": True})
with KalshiClient(transport=router) as client:
assert client.exchange.status().exchange_active
kalshi.testing is the better choice when you want to capture real responses
end-to-end; respx is the better choice when you want fully synthetic
responses with no record step.
Reference¶
kalshi.testing ¶
Record/replay mock layer for testing against the Kalshi SDK.
Drop a :class:RecordingTransport (or :class:AsyncRecordingTransport) into the
client once to capture real API calls to disk, then swap in
:class:ReplayTransport (or :class:AsyncReplayTransport) to run tests offline.
Requests are matched by HTTP method + URL path + sorted query parameters. The
KALSHI-ACCESS-SIGNATURE and KALSHI-ACCESS-TIMESTAMP headers are ignored,
so signature drift between record and replay does not cause misses.
.. warning::
Recorded fixtures contain the full response body returned by Kalshi. If you
record against an account with real funds you will write balances, positions,
order history, and any PII the API returns to disk. **Always ``.gitignore``
the fixture directory unless you have manually scrubbed the JSON, and prefer
recording against the demo environment whenever possible.**
Usage::
from pathlib import Path
from kalshi import KalshiClient
from kalshi.testing import RecordingTransport, ReplayTransport
# Record once against the real API:
with KalshiClient.from_env(transport=RecordingTransport(Path("fixtures"))) as c:
c.exchange.status()
# Replay in tests, no network:
with KalshiClient(transport=ReplayTransport(Path("fixtures"))) as c:
c.exchange.status() # served from fixtures/GET_trade-api_v2_exchange_status.json
AsyncRecordingTransport ¶
AsyncRecordingTransport(
dir_path: str | Path,
*,
real_transport: AsyncBaseTransport | None = None
)
Bases: AsyncBaseTransport
Async equivalent of :class:RecordingTransport.
RecordingTransport ¶
Bases: BaseTransport
Sync httpx transport that proxies real requests and records each pair to disk.
Wraps any underlying httpx.BaseTransport (defaults to a fresh
httpx.HTTPTransport) so real network calls go through, while every
request/response pair is buffered in memory and flushed to disk on
close() — see #105 for the prior O(N²) per-request rewrite this fixes.
AsyncReplayTransport ¶
Bases: AsyncBaseTransport
Async equivalent of :class:ReplayTransport.
FixtureNotFoundError ¶
Bases: LookupError
Raised when no recorded fixture matches an incoming request.
ReplayTransport ¶
Bases: BaseTransport
Sync httpx transport that serves responses from a directory of fixtures.
Reads JSON fixture files written by :class:RecordingTransport. Requests are
matched by HTTP method, URL path, and sorted query parameters (body and auth
headers are ignored).
Raises :class:FixtureNotFoundError when no matching fixture exists — never
returns a synthetic 404 silently.