Custom Rule Validation
When you save a strategy: custom strategy on Kalshi, Polymarket or Polymarket US, Studio validates it before it can run. This page covers the three validation passes, the exit rule every custom strategy needs, the behavior checks that catch rule sets whose logic is broken, and the mistakes those checks cannot catch. Worked examples of each behavior check are on Exit Checks and Entry Checks.
What happens when you save
Studio checks a custom strategy in three passes:
- Structure. Unknown keys, a rule with no name, a
whenwith both or neither ofall/any, a rule with both or neither ofaction/orders, and an empty rule list are rejected. - Fields, values and actions. Fields must exist and be allowed on your venue, operators must be valid, values must have the right type, and at least one rule must be an exit.
- Behavior. If the first two passes succeed, Studio checks whether your rules make sense together. This catches rule sets in which every key is valid but the logic is broken.
Each error has a path such as rules[1].when.all[0].op and a message. Behavior errors also name the rules involved and include a sample state that shows the problem.
The rejected examples here and on the Exit Checks and Entry Checks pages are complete strategies that Studio refuses. Each lists the error text it produces, followed by a corrected version that saves.
The required exit rule
Every custom strategy needs at least one rule whose action is sell_yes, sell_no, sell_all or cancel_all, so positions cannot only grow. buy_*, skip and composite orders rules do not count. Polymarket US rejects sell_yes and sell_no, so there only sell_all and cancel_all count, and the error names only those two.
# Rejected: the only rule buys, and nothing ever sells or cancels
# error: requires at least one rule with action sell_yes, sell_no, sell_all or cancel_all
version: 1
platform: kalshi
strategy: custom
strategy_name: "Buy only (rejected)"
market:
series_ticker: KXHIGHNY
risk:
max_position: 2
price_floor: 0.05
price_ceiling: 0.95
loop:
interval: 30
rules:
- name: buy_yes_cheap
when:
all:
- {field: position_size, op: "==", value: 0}
- {field: yes_best_ask, op: "<=", value: 0.30}
action: buy_yes
size: 1Fixed: add a stop-loss and a take-profit with a gap between them. The entry also gets a spread limit, so the $0.10 stop is wider than the spread paid on a 1-contract entry (see unrealized_pnl).
version: 1
platform: kalshi
strategy: custom
strategy_name: "Cheap YES with a P&L bracket"
market:
series_ticker: KXHIGHNY
risk:
max_position: 2
price_floor: 0.05
price_ceiling: 0.95
loop:
interval: 30
rules:
- name: stop_loss
when:
all:
- {field: position_size, op: ">", value: 0}
- {field: unrealized_pnl, op: "<=", value: -0.10}
action: sell_all
- name: take_profit
when:
all:
- {field: position_size, op: ">", value: 0}
- {field: unrealized_pnl, op: ">=", value: 0.15}
action: sell_all
- name: buy_yes_cheap
when:
all:
- {field: position_size, op: "==", value: 0}
- {field: yes_best_ask, op: "<=", value: 0.30}
- {field: spread, op: "<=", value: 0.05}
action: buy_yes
size: 1Behavior checks
Each check name links to a worked example: a rejected strategy and a version that saves.
| Check | Error message | Rejected when | Fix |
|---|---|---|---|
contradictory_predicate | predicate is contradictory and can never match | The conditions of an all block can never be true together, or a threshold is outside the field's range (price > 1, position_size < 0) | Fix the thresholds. |
exhaustive_predicate | entry predicate is exhaustive | A buy rule is true for every possible value of the fields it reads (price >= 0, price < 50) | Add a position gate and a real price band. |
unreachable_rule | earlier rules make this rule unreachable | Earlier rules, including skip and cancel_all, already match every state this rule would | Put the narrower rule first, or tighten the earlier one. |
exhaustive_exit_coverage | exit rules collectively match every held-position state | Your sell rules together fire in every state where you hold a position, so the Bot could never hold | Leave a hold zone between your exits. |
state_invariant_exit | exit predicate fires in profitable, neutral, and losing states | A sell rule's only condition is on position_size, and it matches while holding 1 contract | Add a P&L, price or time condition. |
immediate_entry_exit_loop | entry and exit rules deterministically alternate on unchanged market state | Right after an entry fills, with prices unchanged, an exit is the first match, and then the same entry fires again | Make the exit require the market to move. |
unsatisfiable_action | entry size must be positive; entry size exceeds risk.max_position from an empty position; entry cannot satisfy risk.max_notional within the configured price bounds | A buy's size is negative, above risk.max_position, or size × risk.price_floor exceeds risk.max_notional | Lower the size or raise the risk limit. |
The checker treats each built-in field as an independent range: prices, spreads and book fields from 0 to 1, paired sums from 0 to 2, counts from 0 up, and P&L, balance and derived metrics unbounded. "Holding" means position_size > 0. Because the fields are independent, the checker also considers states the market rarely shows, such as a NO bid far above the NO ask.
Exits only count as exits here when they are sell_yes, sell_no or sell_all; cancel_all never sells, so it is ignored by the three exit checks.
Held-position states: which exits pass
- Stop-loss exit
- Take-profit exit
- Sell whenever held
unrealized_pnl (whole position, dollars) in which a sell rule fires while position_size > 0. The axis is cut at ±$0.50; P&L continues in both directions. Together, your exits must leave some held state in which no exit fires. Keep 0.00 (the vertical line) inside that hold zone as well: the checker treats the moment right after a fill as P&L 0.00, and an exit that matches there creates the entry/exit loop shown further down.What the checker cannot see
Behavior validation catches common logic mistakes, not all of them. A strategy that saves can still be wrong. You are responsible for these:
- Conditions it cannot turn into a range. A rule that contains any
edge.*field, anyvalue_fieldcomparison, anytime_to_expirycondition, oryes_position_size/no_position_sizeis left out of the contradictory, always-matches, unreachable and exit-coverage checks, and it never counts as covering later rules. Its buy size is still checked, and the loop check still runs, but there an edge, duration, side-position or derived condition never matches (avalue_fieldcomparison between two built-in fields can still match). Soyes_position_size > 0 → sell_yestogether withyes_position_size == 0 → buy_yessaves even though it loops, and an entry of onlytime_to_expiry >= "0s"saves even though it is always true. - Condition order inside
all. One exception to the point above: the checker reads analllist top to bottom, and if the conditions before the first unanalyzable one already contradict each other, the rule is still rejected. Soall: [price > 1, time_to_expiry > "5m"]is rejected but the same two conditions in the opposite order save. - Ungated exits that are true while flat, such as
unrealized_pnl >= 0withoutposition_size > 0. They starve every entry below them. - A
skipplaced above your exits. See Recommended rule order for how to order exits, guards and entries. - Unit mistakes that happen to stay in range.
yes_best_ask <= 40next to a position gate saves, and it is always true. - Whole numbers. The checker treats every field as continuous, so
position_size > 0together withposition_size < 1is not flagged. - Rules with a
trading_scheduleor per-fill exits get only some of the checks; see Advanced Kalshi Rules. - The loop check is a sample. It tries a limited set of prices and states, so passing it does not prove your rules cannot alternate.