Kalshi's Perps (perpetual futures / margin) API is a separate exchange on its
own host. The SDK exposes it through standalone clients rather than a namespace on
KalshiClient, because the perps exchange uses a different host and Kalshi
recommends separate API keys for it.
| Surface | Client | Host (prod / demo) | Auth |
|---|---|---|---|
| Perps REST | PerpsClient / AsyncPerpsClient |
external-api.kalshi.com / external-api.demo.kalshi.co (/trade-api/v2) |
RSA-PSS (same signer as KalshiClient) |
| Perps WebSocket | PerpsWebSocket |
external-api-margin-ws.kalshi.com / external-api-margin-ws.demo.kalshi.co (/trade-api/ws/v2/margin) |
RSA-PSS apiKey |
| SCM "Klear" | KlearClient / AsyncKlearClient |
api.klear.kalshi.com / demo-api.kalshi.co (/klear-api/v1) |
Bearer admin_user_id:access_token |
The RSA-PSS signer and the HTTP transport are reused unchanged from the prediction API — only the host, config, and credentials differ.
from kalshi import PerpsClient
# Explicit credentials:
with PerpsClient(
key_id="your-perps-key-id",
private_key_path="~/.kalshi/perps_key.pem",
demo=True,
) as perps:
print(perps.exchange.status())PerpsConfig.production() / PerpsConfig.demo() build the canonical environments;
pass a PerpsConfig for full control (timeouts, retry policy, custom JSON loaders).
See Authentication for how RSA-PSS keys are generated and
Configuration for the config surface (shared with KalshiConfig).
Perps credentials live under a separate KALSHI_PERPS_* namespace so they
never collide with the prediction-API KALSHI_* vars:
| Variable | Purpose |
|---|---|
KALSHI_PERPS_KEY_ID |
perps API key id (omit for an unauthenticated client) |
KALSHI_PERPS_PRIVATE_KEY / KALSHI_PERPS_PRIVATE_KEY_PATH |
RSA private key (PEM string or file) |
KALSHI_PERPS_PRIVATE_KEY_PASSPHRASE |
optional passphrase for an encrypted PEM (a password= kwarg overrides it) |
KALSHI_PERPS_API_BASE_URL |
optional REST base-URL override |
KALSHI_PERPS_WS_BASE_URL |
optional WS base-URL override (keeps the WS feed off production when overriding REST) |
KALSHI_PERPS_DEMO |
"true" routes to the demo exchange |
from kalshi import AsyncPerpsClient
async with AsyncPerpsClient.from_env(demo=True) as perps:
...| Attribute | Endpoints |
|---|---|
exchange |
status(), enabled() (per-member access gate), risk_parameters() |
markets |
list(), get(), orderbook(), bulk_orderbooks(*, tickers), candlesticks(), schedules(*, tickers) |
orders |
create(), get(), list() / list_all(), cancel(), cancel_all(), decrease(), amend() |
order_groups |
list(), get(), create(), delete(), reset(), trigger(), update_limit() |
portfolio |
positions(), fills() / fills_all(), trades() / trades_all(), cross/isolated exit triggers |
margin |
balance(), risk(), notional_risk_limit(), fee_tiers(), fee_tier_rates(), api_limits(), corporate_actions() |
funding |
rate_estimate(), historical_rates(), premium_index(), history() |
transfers |
transfer_instance(), create_subaccount(), transfer_subaccount() |
fcm |
create_subtrader(subtrader_suffix=..., require_category_cap=...); risk_controls / update_risk_controls / delete_risk_controls; update_notional_risk_limit / delete_notional_risk_limit |
markets.bulk_orderbooks(*, tickers) fetches up to 100 margin orderbooks
in one call (GET /margin/markets/orderbooks). tickers is required
(1–100) and serializes as a comma-separated query string. A ticker that
is not a margin market returns 404.
books = perps.markets.bulk_orderbooks(tickers=["BTC-PERP", "ETH-PERP"])
for ob in books:
print(ob.ticker, len(ob.bids), len(ob.asks))
schedules = perps.markets.schedules(tickers=["BTC-PERP", "ETH-PERP"])
for ticker, details in schedules.items():
print(ticker, len(details.kalshi_trading_holidays))markets.schedules(*, tickers) returns Kalshi trading-holiday closures
(GET /margin/markets/schedules) as a ticker → MarginMarketScheduleDetails
map. tickers is required (1–100, each 1–200 characters) and serializes as a
comma-separated query string. Regular daily and weekend closures are not
included. Each holiday window's spec field from is start on
MarginHolidayWindow (until is the exclusive end).
The margin order side is bid / ask (not the prediction API's yes / no).
Orders create/cancel/decrease/amend are POSTs/DELETEs and are never retried.
orders.amend(..., expiration_time=) is int64 Unix seconds: omit it to keep
the current expiry, or pass 0 to clear it (good-till-canceled).
orders.create(..., market_version=) is the expected market version (starts
at 1 on MarginMarket.market_version). If set and the market's current
version differs, the server rejects with HTTP 409 / market_version_mismatch.
Omit to skip the check.
orders.create(..., market_id=) / orders.amend(..., market_id=) is a
stable exchange market UUID. It may be supplied with ticker (ticker takes
precedence when both are set). Omit so the key is not sent.
orders.create(..., subaccount=) is optional (0 = primary). Omit so the
key is not sent.
portfolio.fills() / portfolio.positions() omit subaccount to return
rows across all subaccounts (subaccount-restricted API keys still only
see their locked subaccount). Pass subaccount=0 for the primary.
portfolio.trades() / trades_all() take an optional ticker; omit it
to retrieve public trades across all margin markets.
!!! warning "Deprecated in v7.2.0 — list_fcm / list_all_fcm"
Kalshi removed GET /margin/fcm/orders from the perps OpenAPI. The SDK
methods still exist (sync + async) but emit a DeprecationWarning and may
404 against the live API. They will be removed in a future major release
once the removal is confirmed permanent. Prediction-API FCM
(client.fcm.* on /fcm/*) is unchanged.
fcm.create_subtrader(..., require_category_cap=) is required (True or
False). When true, ordinary orders need an explicit asset-class IM cap;
subtrader-wide and market caps do not satisfy this. Dedicated liquidation
remains allowed. The flag is set at create time and cannot be changed.
FCM members can set per-subtrader initial-margin caps. A cap with no
market_ticker applies across all markets; a ticker scopes it to one
market. im_cap is a non-negative OrderPrice (fixed-point dollars):
from decimal import Decimal
caps = perps.fcm.risk_controls(subtrader_id="user_desk1")
perps.fcm.update_risk_controls(
subtrader_id="user_desk1",
im_cap=Decimal("100.0000"),
market_ticker="BTC-PERP",
)
perps.fcm.delete_risk_controls(subtrader_id="user_desk1", market_ticker="BTC-PERP")
# asset_class is mutually exclusive with market_ticker
perps.fcm.risk_controls(subtrader_id="user_desk1", asset_class="Crypto")
# Member-set account notional limit (PUT/DELETE are never retried).
# The exchange enforces the smaller of this value and the Kalshi-set limit.
perps.fcm.update_notional_risk_limit(notional_value_risk_limit=Decimal("5000.0000"))
perps.fcm.delete_notional_risk_limit()risk_controls() also returns notional_limits (admin-set notional caps for
the same subtrader). Each FCMSubtraderRiskControls row includes required
current_im. margin.notional_risk_limit() reports required
total_current_usage and current_usage_by_market_ticker, plus optional
member_notional_value_risk_limit and
effective_account_notional_value_risk_limit.
Exit triggers (stop-loss / take-profit / trailing) sit on a position slot.
Bracket stop_loss_price / take_profit_price must be strictly positive when
supplied; the client rejects zero and negative values.
from decimal import Decimal
from kalshi.perps.models.portfolio import SetCrossExitTriggerRequest
listed = perps.portfolio.cross_exit_triggers("BTC-PERP")
trig = perps.portfolio.set_cross_exit_trigger(
"BTC-PERP",
request=SetCrossExitTriggerRequest(
kind="bracket",
stop_loss_price=Decimal("50000.0000"),
take_profit_price=Decimal("70000.0000"),
),
)
perps.portfolio.cancel_cross_exit_trigger("BTC-PERP", trig.id)- Prices are
DollarDecimal—FixedPointDollarsstrings with up to 6 decimal places, parsed toDecimal(no float on the price path). - Counts are
FixedPointCount— 2-decimal fixed-point with an_fpwire suffix. number/doubleratios (margin thresholds, funding rate) useMultiplierDecimalto avoid binary-float drift.- REST timestamps are RFC3339 (
AwareDatetime); some query/response fields are Unix-secondsint. - WebSocket timestamps are Unix epoch milliseconds on
*_ms-suffixed fields (ts_ms,created_ts_ms,next_funding_time_ms, …) — a real parsing difference from the event-contract WS. - Notional values —
MarginMarket(REST) and the margin ticker WS payload carry optionalvolume_notional_value/volume_24h_notional_value/open_interest_notional_value(DollarDecimal | None);MarginMarketCandlestickcarries the same fields as required (inherent to a settled historical record).MarginMarket.leverage_estimatesmaps notional position sizes ("1000","10000", …) toMultiplierDecimalleverage, orNonewhen margin config / price data is unavailable.MarginMarket.bid_size/ask_size(FixedPointCount | None) are the resting size at the best bid/ask (bid_size_fp/ask_size_fpon the wire); omitted when market stats are unavailable.MarginMarket.funding_deadband/funding_cap(MultiplierDecimal) andfunding_limit_unit("percentage"/"dollar") are required; they are the funding limits configured at market creation, expressed infunding_limit_unit.MarginMarket.funding_invalid_premium_handlingis required ("dampen"/"exclude"): how funding averages a second with no premium (no orderbook sample, no index price, or a trading halt).dampencounts that second as a premium of 0;excludeleaves it out of the average.MarginMarket.halt_sourcesis optional (list[str] | None; spec-required,Noneif absent): why the market is halted ("initial_setup","corporate_action", …). Empty means the market is not halted or the reason has not been observed — do not read empty as proof the market is live. - Mark prices (since the v3.21.0 spec sync) —
MarginMarketcarries three optional reference-price objects:settlement_mark_price,liquidation_mark_price, andreference_price, each a nestedTickerPrice(TickerPrice | None).TickerPriceis a new model withprice(DollarDecimal, aFixedPointDollarsUSD string) andts_ms(the source timestamp in epoch milliseconds,int). All three are absent from the specrequiredlist, so they areNonewhen the upstream price is unavailable. - Exchange shard (SDK v11.0.0) —
MarginMarket.exchange_index(int) is required; order groups may only reference markets that share theirexchange_index. - Trading schedule (since the v3.25.0 spec sync / SDK v7.1.0) —
MarginMarketcarries a requiredschedulekey typedMarginMarketSchedule | None.Nonemeans the market trades 24/7. When present,MarginMarketSchedulehasis_open: boolplusnext_close_ts/next_open_ts(int | None, Unix epoch seconds):next_close_tsis null while closed,next_open_tsis null while open. - Subaccounts (since the v3.21.0 spec sync) —
MarginPosition(theportfolio.positions()rows) carries a requiredsubaccount(int) identifying which subaccount holds the position.subaccount_type("cross"/"isolated"/"auto_rebalance", orNonewhen the key is absent) is optional onMarginPosition,MarginSubaccountBalance,MarginRiskPosition, andMarginFundingHistoryEntry. The spec marks it required; the SDK keeps it optional so older payloads still parse. - Portfolio hedging (since the v3.23.0 spec sync) —
MarginPositionandMarginRiskPositioncarry a requiredis_portfolio(bool),Truewhen the position is hedged within a portfolio so its margin is computed at the portfolio level.MarginOrdergained an optionalorder_reason("liquidation"/"take_profit_stop_loss") identifying system-generated orders.
Funding is the perpetual-futures tether: periodic payments between longs and shorts
keep the perp's mark price near its index. The funding resource exposes the
current in-progress estimate, historical applied rates, and the authenticated
user's per-payment history:
est = perps.funding.rate_estimate(ticker="BTC-PERP")
print(est.funding_rate, est.next_funding_time) # in-progress estimate
print(est.premium_index, est.premium_index_ts) # optional final-second premium
points = perps.funding.premium_index(
ticker="BTC-PERP", start_ts=1_700_000_000, end_ts=1_700_003_600
)
rows = perps.funding.history(start_date="2026-01-01", end_date="2026-02-01")subscribe_ticker on the WS also carries the live funding_rate and
next_funding_time_ms.
bal = perps.margin.balance(compute_available_balance=True)
for sub in bal.subaccount_balances:
print(sub.subaccount, sub.account_equity, sub.maintenance_margin)
risk = perps.margin.risk()
print(risk.account_leverage, risk.total_maintenance_margin)
for pos in risk.positions:
print(pos.market_ticker, pos.position_leverage, pos.estimated_liquidation_price)perps.margin.api_limits() (GET /account/limits/perps) returns the Perps API
tier limits in the same shape as the prediction API's client.account.limits()
(an AccountApiLimits with usage_tier, read/write token buckets, and the
grants list of ApiUsageLevelGrant).
perps.margin.corporate_actions() (GET /margin/corporate_actions) lists
scheduled notices and notices updated in the last seven days. Optional
ticker / market_id filters; omit both for all margin markets. Public
market data (no auth). Scheduled, applied, and lapsed notices include
exactly one terms object matching type (cash, split,
final_settlement, or ticker_change). Cancelled notices omit terms and
execution_time. Amounts are DollarDecimal US dollar strings.
notices = perps.margin.corporate_actions(ticker="AAPL-PERP")
for action in notices.corporate_actions:
print(action.id, action.type, action.status)
if action.cash is not None:
print(action.cash.cash_per_contract, action.cash.ex_date)from kalshi import PerpsWebSocket
from kalshi.perps import PerpsConfig
ws = PerpsWebSocket(auth=perps._auth, config=PerpsConfig.demo())
async with ws.connect() as session:
async for msg in await session.subscribe_ticker(tickers=["BTC-PERP"]):
if msg.msg.funding_rate is not None:
print(msg.msg.funding_rate.rate, msg.msg.funding_rate.next_funding_time_ms)Eight data channels — orderbook_delta (snapshot + delta, sequenced), ticker,
trade, fill, user_orders, order_group_updates, corporate_actions,
market_lifecycle_v2. The connection, sequence-gap detection, reconnect, and
backpressure machinery are reused from the event-contract WS stack (see
WebSocket); the perps orderbook is bid / ask. The
prediction-only channels (market_positions, multivariate*, communications,
cfbenchmarks_value) have no perps counterpart. corporate_actions streams
applied split / final-settlement / ticker-change notices (cash actions are
REST-only); subscribe with session.subscribe(channel="corporate_actions").
Upcoming and recent notices are listed by margin.corporate_actions().
subscribe_market_lifecycle() yields MarginMarketLifecycleV2Message for
activation and deactivation of every margin market (event_type is
activated or deactivated). Ticker filters are not supported on that channel.
The Klear API (settlement balances, obligations, margin reports, withdrawals) is a
third surface with a different auth model — a pre-generated Bearer token passed
as Authorization: Bearer <admin_user_id>:<access_token> on every request. Generate
the token and find your admin user id at https://klearing.kalshi.com (the
"Security" page). It is exposed via KlearClient / AsyncKlearClient:
from kalshi import KlearClient
with KlearClient(admin_user_id="...", access_token="...", demo=True) as klear:
reports = klear.margin.margin_reports(start_date="2026-01-01", end_date="2026-02-01")
bal = klear.margin.settlement_balance()Credentials can also come from the environment via KlearClient.from_env() (reads
KALSHI_KLEAR_ADMIN_USER_ID / KALSHI_KLEAR_ACCESS_TOKEN).
klear.margin.active_obligations() returns all currently-active settlement
obligations. klear.margin.settlement_estimate_by_asset_class() returns
next-settlement estimates keyed by asset class. (The singular
active_obligation() / settlement_estimate() endpoints were removed upstream
in the v11.0.0 reconcile.)
AssetClassLiteral is "Crypto", "Equities", "Metals", "Indices",
or "Energy". klear.margin.settlement_prices(asset_class="Crypto", settlement_time=...)
returns a ticker → centicents map at a settlement cycle.
klear.margin.estimate_maintenance_margin(asset_class="Crypto", positions=[...])
margins a hypothetical portfolio. Optional date= (YYYY-MM-DD) and
clearing_type= ("FCM" / "SelfClearing") select the matrix day and
clearing arrangement.
klear.margin.estimate_maintenance_margin_metadata(asset_class="Crypto", date=...)
returns the matrices, liquidation configs, and subgroups that feed that
estimate.
klear.margin.funding_estimate_by_asset_class() returns the next-funding
estimate keyed by asset class.
klear.margin.funding_schedule(asset_class="Crypto") returns a cron
expression evaluated in US Eastern Time.
klear.margin.member_funding_payments(funding_time=...) returns the
member's funding payments for one funding execution (distinct from the
obligation-scoped funding_payments(obligation_id)).
FCM-bound margin API keys:
keys = klear.margin.list_fcm_api_keys()
created = klear.margin.create_fcm_api_key(
name="desk", public_key=pem, fcm_subtrader_id="user_desk1"
)
klear.margin.delete_fcm_api_key(created.api_key_id)When an ObligationEntry inline detail array is capped at 1000 rows, the
matching *_truncated flag is set; page the full set via the methods below.
corporate_action_payments is required and may be empty. Those rows are
cash and split corporate-action payments already included in settlement PnL
— do not add them to the obligation total.
CorporateActionPaymentDetail.payment_time is always present and is null
for splits. Centicents fields on that model stay plain integers.
payment_amount_fp is the optional dollar-string counterpart of
payment_amount_centicents (separate attribute, not an alias).
position_quantity_fp and post_position_quantity_fp are fixed-point
contract counts. corporate_action_payments / corporate_action_payments_all
page the full set when corporate_action_payments_truncated is true (limit
max 1000):
for row in klear.margin.settlement_details_all(ob.id):
...
for row in klear.margin.maintenance_margin_details_all(ob.id):
...
for row in klear.margin.funding_payments_all(ob.id):
...
for row in klear.margin.corporate_action_payments_all(ob.id):
...Each AssetClassSettlementEstimate exposes optional
omitted_subtrader_count / group_breakdowns / omitted_group_count.
MaintenanceMarginDetail may include margin_group_id.
Subtrader groups — margined as one netted portfolio:
groups = klear.margin.list_subtrader_groups()
created = klear.margin.create_subtrader_group(subtrader_ids=["st-a", "st-b"])
klear.margin.update_subtrader_group(created.group_id, subtrader_ids=["st-a", "st-c"])
klear.margin.delete_subtrader_group(created.group_id)Money fields on the Klear margin schemas are integer centicents (1 USD = 10,000 centicents), plain int on the Python models. Where the spec also
publishes a *_fp counterpart (the same amount as a fixed-point dollar
string, 6 decimal places), the SDK exposes it as a separate optional
DollarDecimal attribute with that *_fp name. None means the key was
absent. There is no aliasing: a payload can carry both the integer and the
dollar string, and model_dump(by_alias=True) emits both names. The
withdrawal amount, GetSettlementBalanceWithdrawalResponse.amount, and
MarketSettlementEstimate.session_avg_price_fp are fixed-point dollar
strings that were never centicents.
klear.margin.withdraw_settlement_balance(amount="500.00") posts to
POST /margin/settlement_balance_withdrawal and validates the amount as
positive at construction (the single real-money write) before any request is sent.
Pass mfa_code= when the clearing member requires MFA.
The Bearer access_token is never logged and is redacted in repr() (only the
non-secret admin_user_id is shown).
The margin product is also reachable over the FIX protocol via MarginFixClient
(from kalshi import MarginFixClient), which reuses the shared FIX core with the
KALSHI_PERPS_* key and fixed-point-dollar pricing. Margin supports the
order-entry, drop-copy, and market-data sessions (no RFQ / post-trade). See
FIX protocol.