Kalshi Perps Custom Strategies
This page covers custom Kalshi Perps strategies (platform: kalshi_perps with strategy: custom): the indicators, fields, operators, actions and setups your rules use, and the order the Bot checks them in. For every key in one table, see All Kalshi Perps parameters, and for complete strategies built this way, see Kalshi Perps Example Strategies.
strategy: custom runs your own logic, built from four parts:
indicators: values computed from the market's candles, read by name.rules: condition and action pairs. At least one is required.setups: optional named flags that remember an event between ticks.setup_rules: optional rules that only turn setups on and off.
These four keys are rejected on archetypes, and params is ignored on custom. The envelope (markets, loop, execution, sizing, risk, funding_awareness) works exactly as described in Markets and Execution and Sizing and Risk, and take-profit and stop-loss are checked before your rules on every tick.
indicators
Each indicator becomes a field your rules can read by its name, on either side of a condition:
indicators:
- {name: ema_fast, kind: ema, window: 12, timeframe: 1h}
- {name: ema_slow, kind: ema, window: 26, timeframe: 1h}
rules:
- name: fast above slow while flat
when:
all:
- {field: position, op: "==", value: 0}
- {field: ema_fast, op: ">", value_field: ema_slow}
action: open_longname: 1 to 31 lowercase letters, digits and underscores, starting with a letter. Names must be unique and can't reuse a base field such asmark_price.kind: see Indicator kinds.window: 2 to 400 candles.timeframe:1m,1h,4hor1d. 4h candles are built from 1h candles in 4-hour blocks starting at 00:00 UTC. There is no 5m timeframe for indicators; 5-minute data exists only through the MACD fields.
How they behave:
- Perp prices, closed candles. Indicators read the perp contract's own price in dollars per contract, not the spot asset, and only closed candles. A value changes once per candle close. When a candle has no traded close, the Bot uses the mid of its bid and ask close instead.
- Recomputed every tick for every market. Each indicator is one candle request per market per tick, so many indicators on several markets at a short interval add up.
- Warm-up.
sma,emaand Bollinger kinds need at leastwindowclosed candles;rsi,atrand Donchian kinds needwindow+ 1. Until then the value is missing and rules that read it don't fire. A window longer than the market's candle history never produces a value. Perps were listed in mid-2026, so long1dwindows may not have enough history yet. - Slightly different from charting tools. EMA, RSI and ATR are seeded from the candles the Bot fetches (about three windows' worth), so they can differ a little from charts that use longer history.
Note: Don't give an indicator the name of a MACD field (such as
macd_1m) or one ofexchange_position,account_unrealized_pnloraccount_margin_used. Studio accepts these names, but your indicator then silently replaces the MACD value or an internal value; an indicator namedexchange_positionchanges how much acloseorreducerule can sell.
Indicator kinds
| Kind | Value | Units |
|---|---|---|
sma | Average of the last window closes. | $ per contract |
ema | Exponential average, weight 2 ÷ (window + 1), seeded with a simple average. | $ per contract |
rsi | Wilder RSI. 100 when there were no losses. | 0 to 100 |
atr | Wilder average true range. | $ per contract |
bollinger_upper | Simple average + 2 standard deviations of the last window closes. | $ per contract |
bollinger_lower | Simple average − 2 standard deviations. | $ per contract |
donchian_high | Highest high of the window candles before the latest closed candle. | $ per contract |
donchian_low | Lowest low of the window candles before the latest closed candle. | $ per contract |
- The Bollinger width is fixed at 2 standard deviations in custom strategies. For a middle band, add an
smawith the same window. (dip_reverter'sbb_multchanges the width only in that archetype.) - Donchian values leave out the latest closed candle so a breakout can be compared against them. Custom rules have no candle-close field, so compare
mark_price(the live price) withdonchian_highordonchian_low.
rules
Rules are checked top to bottom on each tick, for each market. The first rule whose conditions hold runs its action, applies its arm and clear, and ends that market's tick, even when the action turns out to do nothing.
rules:
- name: exit long on bearish trend # 1 to 64 letters, digits, spaces, _ or -
when:
all: # or any:
- {field: position, op: ">", value: 0}
- {field: ema_fast, op: "<", value_field: ema_slow}
action: closename: 1 to 64 letters, digits, spaces, underscores or hyphens, starting with a letter or digit. Quotes and other punctuation are rejected. Names may repeat. Acloserule's name appears in the log as the close reason.when: write one ofall(every condition must hold) orany(at least one), with at least one condition. Writing both is accepted, but onlyallis used:anyis silently ignored and not even checked, so a typo inside it won't be caught. Write only one.- Each condition has a
field, anop, and eithervalue(a number) orvalue_field(another field), never both.valuemust be a plain number:"0"in quotes fails withvalue must be a number. Don't use the YAML values.nanor.inf; they pass the save check but make the rule fail with an error when it runs, which skips the rest of that tick. actionandsize_pct: see Actions andsize_pct.armandclear: see Setups and setup rules.
Write numbers in the field's own units: dollars per contract for prices, contracts for position, dollars for P&L, margin and balance, 0 to 100 for RSI, USD per coin for MACD, and 1 or 0 for setups.
Fields rules can read
Rules can read the 11 base fields below, the Coinbase MACD fields, your indicator names and your setup names.
| Field | What it is | Missing when |
|---|---|---|
mark_price | Kalshi's current price for the perp, in $ per contract. Refreshed at most once a minute. | Never (0 if Kalshi sends none) |
bid | Best bid, in $ per contract, from the same market list. | Never (0 if there is no bid) |
ask | Best ask, in $ per contract. | Never (0 if there is no ask) |
spread | ask − bid, in $, when both exist; otherwise 0. | Never |
position | This Bot's own signed position in contracts: positive long, negative short, 0 flat. Built from its own fills and kept across restarts. Not your account's position. | Never |
avg_entry | Weighted-average entry price of the Bot's open position, in $ per contract. Moves when the Bot adds, not on partial closes. 0 when flat. | Never |
unrealized_pnl | (mark_price − avg_entry) × position, in $, for this Bot only. Excludes fees and funding. 0 when flat. | Never |
margin_used | The Bot's own estimate: |position| × avg_entry ÷ sizing.leverage, in $. Not Kalshi's margin figure. | Never |
liquidation_price | Kalshi's estimated liquidation price for your whole account's position in this market, in $ per contract. | No account position in the market, and always in paper trading |
available_balance | Your perps account's available balance, in $. Shared by every Perps Bot and your manual trading. 0 if Kalshi's response has no readable figure. | Never |
funding_rate_estimate | Kalshi's current funding-rate estimate for the market. Positive means longs pay shorts. Needs funding_awareness: true. | The funding request failed |
position, avg_entry, unrealized_pnl and margin_used describe only this Bot. In paper trading they describe the paper account, which only this Bot trades.
Setups read as 1 (armed) or 0. Compare them with ==.
Coinbase MACD fields
Six more fields read MACD from Coinbase spot candles of the market's underlying coin, not from the perp's own price. They work only on markets with a Coinbase product in the markets table.
| Field | What it is |
|---|---|
macd_1m | EMA(12) − EMA(26) of completed 1-minute Coinbase closes, in USD per coin. |
macd_signal_1m | EMA(9) of macd_1m. |
macd_histogram_1m | macd_1m − macd_signal_1m. |
macd_5m | The same on 5-minute Coinbase candles. |
macd_signal_5m | EMA(9) of macd_5m. |
macd_histogram_5m | macd_5m − macd_signal_5m. |
- Comparing with a number works for any MACD field:
macd_histogram_5m > 0, ormacd_histogram_1m crosses_above 0(the same moment MACD crosses its signal line). - Comparing with another field works only as a MACD line against its own signal line on the same interval, in either order:
macd_1mwithmacd_signal_1m, ormacd_5mwithmacd_signal_5m. Anything else is rejected, including histogram against signal, 1m against 5m, and MACD againstmark_priceor an indicator. - Units are per coin. A threshold that is meaningful on BTC-USD means something very different on XRP-USD, so rescale thresholds for each asset.
- When MACD is missing. The fields are missing, and rules that read them don't fire, while fewer than 34 completed candles have been fetched, while the newest candle is more than 10 seconds late, or while the data is more than 15 seconds old. They are also missing while any candle is absent from the last 250: Coinbase leaves out candles for minutes with no trades, and one gap keeps the 1m fields missing for about 250 minutes, or the 5m fields for about 21 hours. If the Coinbase feed is unavailable on a tick, every MACD field is missing on that tick and the log says Coinbase data is unavailable and MACD rules cannot fire. Take-profit and stop-loss keep working during these gaps.
# Rejected: MACD fields compare field-to-field only as a line against its own signal
# error: MACD fields may only be compared field-to-field as macd_<interval> against macd_signal_<interval> of the same interval
version: 1
platform: kalshi_perps
strategy: custom
markets:
- ticker: KXBTCPERP
sizing:
margin_usd: 10
leverage: 1
rules:
- name: histogram above signal
when:
all:
- {field: position, op: "==", value: 0}
- {field: macd_histogram_1m, op: ">", value_field: macd_signal_1m}
action: open_longKXHYPEPERP and KXKSHIBPERP have no matching Coinbase product, so any strategy whose rules read MACD is rejected on them with ... has no supported Coinbase product.
Operators and crossovers
Comparisons (>, <, >=, <=, ==) test the current state on every tick. A rule built from them fires on every tick while it holds, so it keeps retrying. == is exact equality: use it for position == 0 and setup == 1, not for prices. != is not supported.
Crossovers (crosses_above, crosses_below) are events. They are true only on the tick where the left side minus the right side changes sign, compared with the last tick on which both sides were available.
A comparison holds on every tick; a crossover happens once
- Fast above slow
- Fast below slow
ema_fast − ema_slowema_fast > ema_slowcrosses_abovecrosses_belowema_fast > ema_slow is true on ticks 1, 7 and 8, so it fires, and retries, on each of them. crosses_above is true only on tick 7, compared with the last reading on the other side (tick 5). crosses_below is true only on tick 2. Crossover memory is kept per market and cleared by a restart.- Touching isn't crossing. Equal values don't count as either side. If the next value is on the other side, the crossover happens on that tick.
- The first reading after a start never counts. After a deploy, restart or update, the Bot needs one reading to know which side it is on.
- Gaps are bridged. If a side is missing for a while, the next available reading is compared with the last one before the gap.
- Memory is per market and per pair. Each market remembers each left/right pair separately, and
crosses_aboveandcrosses_belowon the same pair share that memory. - Memory updates before the rules. It is updated once per tick, before any rule runs, so a tick on which an earlier rule fires still records which side each pair is on. The crossover itself is true only on that tick, though: if a rule higher up fires and ends the tick, a crossover rule below it misses that crossover. On a tick where take-profit, stop-loss or the ATR stop sent a close, rules don't run and crossover memory isn't updated.
- No retry. A rule triggered by a crossover fires on the crossing tick only. If its order is skipped, blocked or unfilled, it waits for the next crossover.
- Not on setups. A crossover on a setup fails with
setups are 1 or 0; compare them with ==, not a crossover.
Actions
| Action | Bot is flat | Bot is long | Bot is short |
|---|---|---|---|
open_long | Opens a long of margin_usd × leverage | Adds another full entry (same as add) | Blocked: short position still open; close it first |
open_short | Opens a short | Blocked: long position still open; close it first | Adds another full entry |
add | Blocked: no open position | Adds a full entry in the same direction | Adds a full entry in the same direction |
reduce | Does nothing | Closes size_pct% of the position | Closes size_pct% of the position |
close | Does nothing | Closes the whole position | Closes the whole position |
- Entries (
open_long,open_short,add) followexecutionand pass the same checks every time: the leverage cap, the one-contract minimum, the funding veto, and your deployment's API error limit, order pacing and Max dollars per order. - Exits (
reduce,close) are immediate reduce-only orders bounded bymax_slippage_pct, limited to what your shared account can actually reduce. - No automatic flip. To go from long to short, close first; the short can open on a later tick.
- Event-contract actions such as
buy_yesfail withaction must be one of open_long, open_short, add, reduce, close.
size_pct
The share of the Bot's current position a reduce rule closes, in percent. Above 0 and up to 100; left out or 0 means 100. Other actions accept only 0 or 100 (otherwise size_pct is only used by reduce).
The step is |position| × size_pct ÷ 100, rounded down to whole contracts (or to 0.01 on a market with fractional trading). 25% of 10 contracts is 2 contracts. If the step rounds to zero, as 25% of 3 contracts does, the reduce is skipped with reduce step rounds to zero.
Note: A
reducerule written with comparisons fires on every tick while it holds, and each time it closes a share of what is left. To trim only once, pair it with a setup: require the setup== 0andarmit on the reduce. See Add once, trim once.
Missing data
A rule never fires on missing data. If any comparison in a rule reads a missing value, the whole rule is false, even inside any. A crossover whose side is missing is simply not true on that tick. Values can be missing because:
- an indicator is still warming up,
- MACD is warming up, late, stale or has a gap,
liquidation_pricehas no account position (and always in paper trading),- the funding request failed.
This fails safe for entries, but also for exits: an exit rule that reads a missing indicator doesn't fire until the value is back. Take-profit and stop-loss don't depend on indicators and keep working.
Setups and setup rules
A setup is a named flag that remembers an event between ticks, such as "a breakout happened" or "already trimmed this position". Each market keeps its own copy. Rules read it by name as 1 (armed) or 0.
setups:
- {name: breakout_seen, expires_after_seconds: 14400} # clears itself 4 hours after it was last armed
- {name: trimmed} # stays armed until a rule clears itsetups: at most 8, oncustomonly. Every declared setup must be armed by at least one rule or setup rule.setups[].name: same pattern as indicator names, unique, and different from base fields, MACD fields, your indicators and the reserved namesexchange_position,account_unrealized_pnlandaccount_margin_used.setups[].expires_after_seconds: when set, fromloop.intervalto 604800 (7 days). Left out or 0 means it never expires. Expiry is checked at the start of each tick's rules, so it takes effect on the first tick at or after the deadline. Arming an armed setup restarts the clock.setup_rules: at most 16, and only when setups are declared. Each has aname, awhen(same grammar, fields and operators as trading rules, including crossovers) and at least one ofarmorclear. Setup rules never trade: anactionon one is ignored.armandclearon trading rules apply when the rule fires, right after its action, whether or not an order was placed or filled.- Order on one tick: setup rules apply in list order, and the last arm or clear on a setup wins; a firing trading rule's
armorclearcomes after them. A single rule can't arm and clear the same setup. - Every setup is cleared by a stop, restart, redeploy or update (see Stops, restarts and updates).
- In the log. A rule that actually changes a setup logs
armed <name>orcleared <name>after its rule name; re-arming an armed setup restarts its clock without a log line. An expiry logssetup <name> expired.
The life of a setup
0Every setup starts here, and a stop, restart, redeploy or update puts it back here.- 0 → 1armA setup rule matches, or a trading rule with
armfires, even if its order is blocked. - 1 → 1arm againStays armed. The expiry clock restarts.
- 1 → 0clearA rule with
clearfires. On one tick, setup rules apply in list order and the last arm or clear wins; a trading rule's arm or clear comes after them. - 1 → 0expire
expires_after_secondsafter it was last armed. Without it, never.
1Read it with ==. Crossovers on setups are rejected.long_reentry with expires_after_seconds: 360 and a 15-second loop (the small marks). Arming it again at 300 s moves the deadline to 660 s. Expiry is checked just before the rules run on each tick, so it takes effect on the first tick at or after the deadline (drawn as if ticks land exactly every 15 seconds). Each market keeps its own copy of every setup.Unlike trading rules, every setup rule whose conditions hold applies, in list order, and each sees the changes made by the ones above it. Trading rules then see the result on the same tick.
A setup with no expiry and no rule that clears it stays armed until the Bot restarts. You can use that on purpose, for example to enter only once per run.
# Rejected: every declared setup must be armed by some rule
# error: no rule or setup rule arms this setup
version: 1
platform: kalshi_perps
strategy: custom
markets:
- ticker: KXETHPERP
sizing:
margin_usd: 10
leverage: 1
setups:
- {name: trend_confirmed}
rules:
- name: enter once confirmed
when:
all:
- {field: position, op: "==", value: 0}
- {field: trend_confirmed, op: "==", value: 1}
action: open_longEvaluation order
For a custom strategy, the strategy step of each tick runs in this order, separately for each market:
- Compute your indicators and any MACD fields your rules read.
- Update crossover memory for every crossover pair in your rules and setup rules.
- Clear setups whose
expires_after_secondshas passed. - Apply every setup rule whose conditions hold, in list order.
- Check trading rules top to bottom. The first one whose conditions hold runs its action and its
arm/clear, and ends this market's tick.
All of this happens only if take-profit, stop-loss and the ATR stop didn't send a close first (see What happens on every tick).
Writing rules that behave
Because the first match ends the tick, and comparisons fire on every tick, a few patterns keep custom strategies predictable:
- Guard exits with the position. An exit rule without
position > 0(or< 0) matches while flat, ends the tick, and hides every entry rule below it. - Guard entries with
position == 0. A comparison-basedopen_longthat stays true while already long adds another full entry on every tick. - Put exits first. Exits written as comparisons retry on every tick until the position is closed.
- Use crossovers for once-per-event entries, and comparisons for anything that should retry. A crossover entry that is blocked waits for the next crossover.
- Use setups for "once" logic: trim once, add once, enter once per signal.
- Readiness guards. A rule that reads an indicator already waits for it. To hold a rule back until some other indicator is ready, add a condition on it, such as
atr_1h > 0.