Composite Orders and max_combined_price
This page covers composite orders in Kalshi custom strategies (platform: kalshi, strategy: custom): rules that rest one or two limit buys priced from the live order book, the max_combined_price cap for paired YES and NO quotes, and the settings Deep Research can sweep for a paired quote. Composite orders can't share a strategy with per-fill exits or rule-level schedules; see What can be combined. Rule order, deploy checks and how the maker backtest differs from live are on the Advanced Kalshi Rules page.
Composite orders
A composite rule swaps action for orders: one or two limit buy orders (legs) that rest on the book until they fill or the Bot cancels them. Each leg is priced from the live book when the rule fires, as its outcome's best bid or best ask plus a signed offset. Use them to improve a bid by a cent, rest a bid just under the ask, or quote YES and NO together.
| Parameter | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
orders | list | none | 1 or 2 legs: at most one YES buy and one NO buy | Replaces action with resting limit buys |
orders[].side | string | required | yes, no (lowercase) | The outcome the leg buys, and whose book its reference reads |
orders[].action | string | required | buy | Only buying is supported. Exit with sell_all, sell_yes or sell_no rules. |
orders[].size | integer | required | Whole number above 0 | Contracts for this leg |
orders[].price | mapping | required | Keys reference and offset only | A relative price. There is no fixed-price form. |
orders[].price.reference | string | required | best_bid, best_ask | The top-of-book price the leg is anchored to |
orders[].price.offset | number (dollars) | 0 | Above -1 and below 1 | Added to the reference: 0.01 is one cent higher |
orders[].post_only | boolean | false | true, false, unquoted ("true" in quotes is a YAML error) | Refuse to send a leg priced at or through the ask |
max_combined_price | number (dollars) | none | Above 0, at most 1 | Hard cap on the YES + NO leg prices of a pair |
A one-leg quote that improves the YES bid by a cent looks like this:
rules:
- name: improve_yes_bid
when:
all:
- {field: order_count, op: "==", value: 0}
- {field: spread, op: ">", value: 0.025}
orders:
- side: yes
action: buy
size: 1
price: {reference: best_bid, offset: 0.01}
post_only: trueHow leg prices resolve
All legs are priced from the same order-book snapshot, the one the rule's conditions were checked against. Kalshi has one book per market, so each outcome's ask comes from the other outcome's bid:
| Leg | best_bid means | best_ask means |
|---|---|---|
side: yes | The top YES bid | 1 − the top NO bid |
side: no | The top NO bid | 1 − the top YES bid |
Leg price = reference + offset, rounded to the nearest cent, then clamped into your risk.price_floor to risk.price_ceiling band. With max_combined_price set, the Bot then adds the two leg prices and compares the sum with the cap.
From one book snapshot to a capped YES/NO pair
- YES leg
- NO leg
| Book | Best bid | Best ask |
|---|---|---|
| YES | 0.45 | 0.50 (1 − NO bid) |
| NO | 0.50 | 0.55 (1 − YES bid) |
paired_best_bid_sum = 0.95| Book | Best bid | Best ask |
|---|---|---|
| YES | 0.48 | 0.51 (1 − NO bid) |
| NO | 0.49 | 0.52 (1 − YES bid) |
paired_best_bid_sum = 0.97Combined leg price against the cap
reference: best_bid and offset: 0.01, with max_combined_price: 0.98. In the paired quote example, the gate paired_best_bid_sum < 0.965 already stops scenario B from firing; the cap is the backstop that checks the actual leg prices after rounding and clamping.orders
Legs are good-till-cancelled limit buys. A leg keeps resting until one of these happens:
- it fills,
- the composite rule fires again (it cancels and re-sends every leg),
- a
cancel_allrule fires, - the top-level
trading_schedulecloses, active_windowends, or a risk halt stops the Bot.
A sell_all rule does not pull resting legs in live or paper trading. It only sells contracts you hold. Put a cancel_all rule next to it if the quotes should come down too.
Both legs go out in one request, but they are separate orders: one can fill while the other keeps resting. If Kalshi rejects one leg, the Bot cancels the leg it accepted and logs an error.
Every leg counts as an entry, so entry limits see it:
risk.max_entries_per_marketcounts each leg. When entries already used plus the legs in the batch exceed the cap, the whole batch is skipped. A pair needs 2, somax_entries_per_market: 1blocks every paired quote. A leg that fills keeps its entry for the rest of the run. Unfilled legs that the Bot cancels free theirs.risk.max_notionalis checked against the whole batch (size × limit price, summed over the legs). A batch that doesn't fit is skipped, never shrunk.risk.max_portfolio_positionsapplies to a composite rule in a new market, as it does to any entry.- Buying power: the combined cost of every leg must fit your available balance.
- The top-level
trading_schedulegates a composite rule like any other entry, and the Bot cancels resting legs when the schedule closes. - Series rollover: on a rolling
series_ticker, a composite rule can't quote a market the Bot keeps only to manage positions after the series moves on. It is logged as blocked (market_not_current) and evaluation moves on to the next rule.
Leg size
orders[].size is the leg's contract count: a whole number above 0. A quoted "1" is a YAML error, and a fraction such as 1.5 fails with rules[0].orders[0].size: must be a whole number, got 1.5. With max_combined_price, the YES and NO sizes must be equal.
Leg sizes meet your deployment risk limits, which you confirm when you deploy:
- Max contracts per order and Max dollars per order apply to each leg. Studio prefills them from the strategy's largest single order, which includes leg sizes: legs of size 2 prefill 2 contracts and $2. Raise Max dollars per order if a leg costs more than it allows; otherwise the Bot refuses every batch.
- Max open contracts counts what you hold, plus your resting orders, plus every new leg. The Bot applies the lower of it and
risk.max_position. - Max daily notional counts each leg.
A leg over Max contracts per order, Max dollars per order, Max open contracts or Max daily notional, a new market past max_portfolio_positions, or legs that cost more than your buying power stop the rule with an error. By then the Bot has already cancelled its old quotes. The rest of that market's rules, including a fallback cancel_all, are skipped for that loop.
A rule-level size: on a composite rule does nothing to the orders sent, so leave it out. The validator adds up a composite rule's buy legs and rejects a rule whose legs together exceed risk.max_position ("composite buy orders together exceed risk.max_position from an empty position"), because every leg rests at once. Live, a batch that would take held plus resting contracts past the lower of max_position and Max open contracts is refused.
price.reference
best_bidanchors the leg to its own outcome's top bid: offset0joins the bid,0.01improves it by a cent.best_askanchors to its own outcome's ask: offset-0.01rests one cent under it.- If the price a leg needs is missing because that part of the book is empty, the rule stops with the error "cannot resolve yes best_bid from an incomplete book". Nothing is cancelled, and the rest of that market's rules are skipped for that loop, including a fallback
cancel_allbelow it. Gate the rule on the book fields it relies on, such asyes_best_bid > 0.05orpaired_best_bid_sum < 0.965. While a side is missing, the fields that read it are unavailable, and every comparison with them is false, whatever the operator,!=included, so the guard fails closed.spreaddoesn't work as this guard: a missing side makes it read wide, sospread > 0.035passes wherepaired_best_bid_sum < 0.965fails closed. best_askwith offset0andpost_only: falsebuys immediately at the ask in live trading.
price.offset
The offset is a signed dollar amount, above -1 and below 1. The default is 0.
- Use whole cents. Live pricing rounds to the cent, and half-cent offsets round unevenly: 0.30 + 0.005 becomes 0.30, but 0.30 + 0.015 becomes 0.32. The maker backtest keeps a finer price grid, so sub-cent offsets also price differently there.
- Clamping can move a quote toward the market. A best bid of 0.02 plus 0.01 with
price_floor: 0.05becomes a 0.05 bid. - There is no fixed price. To bound prices, use
risk.price_floorandrisk.price_ceiling, or gate the rule with book conditions.
post_only
With post_only: true, the Bot checks each leg before it cancels or sends anything. If a leg's final price is at or above that outcome's best ask, the whole rule stops with an error such as "post-only yes quote at 0.51 would cross ask 0.50". Nothing is sent, your previous quotes keep resting, and the rest of that market's rules are skipped for the loop. The flag also goes to Kalshi, so a leg that would cross by the time it arrives is rejected, and the Bot cancels the other leg.
The validator does not catch a crossing setup such as reference: best_ask with offset: 0.05. It fails only at runtime.
With post_only: false (the default), a leg priced at or through the ask trades immediately as a taker, and any unfilled remainder rests.
What one firing does
This is the live sequence each time a composite rule matches, and every point where it can stop:
What one firing of a composite rule does, live
- The rule matchesIts conditions pass, entries aren't blocked (by the top-level schedule, active_window.end, an edge data outage or Max daily loss), and the market is the series' current one (not one kept only for exits after a rollover).OtherwiseThe next rule is checked, as usual.
- Price each legReference + offset, rounded to the cent, clamped to price_floor and price_ceiling.ErrorA referenced book side is missing. Nothing is cancelled, and the rest of this market's rules are skipped this loop.
- Post-only checkEach post_only leg must be below its own outcome's best ask.ErrorA leg would cross. Old quotes keep resting, and the rest of this market's rules are skipped this loop.
- Cap checkWith max_combined_price, the YES and NO prices must add up to the cap or less.SkippedOver the cap: the Bot cancels its resting orders in the market and skips the pair.
- CancelEvery resting order of this Bot in the market is cancelled, including other rules' entries.
- Limit checksSchedule, order pacing, max_entries_per_market, max_notional, max_portfolio_positions, max_position, deployment risk limits, buying power.Skipped or refusedOrder pacing, max_entries_per_market and max_notional skip the batch. max_portfolio_positions, max_position, deployment risk limits and buying power refuse it with an error, which also skips the rest of this market's rules this loop. Nothing is placed, and the market has no quote until the rule next fires successfully.
- Send the legs togetherOne request with every leg, as good-till-cancelled limit buys.ErrorKalshi rejects a leg: the Bot cancels the legs it accepted.
- Legs restUntil they fill, the rule fires again, a cancel_all rule fires, the top-level schedule closes, active_window ends, edge data goes missing, or a risk halt stops the Bot.RestingOne leg can fill while the other keeps resting.
order_count == 0 to let a quote rest instead of being cancelled and re-sent each loop.Two things follow from the order of these steps. First, the cancel happens before the limit checks, so a refused batch leaves the market with no quote until the rule next fires successfully. Second, the log records the rule as fired even when the batch was then skipped. Look for the blocked entry next to it (max_combined_price, entry_cap, max_notional or order_batch) to see why nothing was placed. order_batch blocked means the batch was held back by order pacing, by the order-attempt budget (by default 20 order attempts per 60 seconds, where each leg counts as one attempt), or by a pause after Kalshi rate-limits the account (5 minutes, unless Kalshi asks for a different wait).
A batch refused by a deployment risk limit, by max_portfolio_positions or by your buying power is an error instead: the rule is not logged as fired, and the rest of that market's rules are skipped for the loop.
Keeping a quote in the queue
A composite rule cancels and re-sends its legs on every loop where it matches, even if the prices haven't changed. Each re-send puts your order at the back of the queue at that price. To let a quote rest:
- Gate the quote rule on
order_count == 0, usually withposition_size == 0. - Put a separate
cancel_allrule above it for the conditions that should pull the quote: someone outbid you, the spread collapsed, the market is near close.
Your own quotes are part of the book the next loop sees. After a +0.01 bid rests, yes_best_bid, no_best_bid, paired_best_bid_sum and spread all include it. A gate such as spread >= 0.03 can then fail because of your own bid, a fallback cancel_all pulls it, and the rule quotes and cancels on alternate loops, or chases its own bid up a cent at a time. Pick thresholds that account for your own quote, as the examples do.
Also keep in mind:
- Each firing cancels all of this Bot's resting orders in that market, including resting
buy_yes/buy_noorders from other rules. order_countcounts every resting order your account has in the market, including ones you placed by hand.- Book fields are computed in floating point, so a true 3-cent spread can come out as 0.0299999. Thresholds set halfway between cents, such as
spread > 0.025, avoid that edge.
max_combined_price
A hard cap, in dollars per YES + NO pair, on what a paired quote may cost. It goes on a composite rule with exactly one YES leg and one NO leg of equal size, and it is checked right before the Bot sends the pair.
| Parameter | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
max_combined_price | number (dollars per pair) | none (no cap) | Above 0, at most 1 | Skips the pair when YES price + NO price is above the cap |
The exact check
The Bot prices both legs first: reference plus offset, rounded to the cent, clamped to the price band. A missing book side or a post-only cross stops the rule before this point. Then it adds the two final prices:
- Sum at or under the cap: the pair continues to the normal path. The Bot cancels its old orders in the market, runs the limit checks and sends both legs.
- Sum over the cap: the Bot cancels all of its resting orders in that market, skips the pair, and logs a blocked
max_combined_pricedecision with the combined price and the cap.
When both legs fill, Kalshi nets your YES and NO contracts in the same market. position_size returns to 0, and each completed pair locks in $1 minus the combined price, before fees. A cap of 1 allows pairs that cost the full $1 payout. Those pairs gain nothing before fees and lose money whenever fees apply.
Cap versus paired_best_bid_sum
paired_best_bid_sum is a condition field: the raw YES best bid plus the raw NO best bid, including your own resting quotes. It ignores your offsets and the price band. It makes a readable gate, but only max_combined_price checks the prices you would actually pay.
The two work together. In the paired quote example, the gate paired_best_bid_sum < 0.965 plus two +0.01 offsets keeps the pair at or under 0.98. The cap of 0.98 re-checks the real leg prices after rounding and clamping.
Legging and partial fills
The cap limits what a pair costs. It does not make both legs fill.
- If one leg fills and the other doesn't, you hold one side of the market, and its value moves with the market until you exit or the other leg fills.
- Legs can fill partly. Kalshi nets whatever YES and NO contracts have filled, and
position_sizeshows the unmatched remainder. - Gate re-quoting on
position_size == 0so the rule doesn't stack new pairs on top of a one-sided position. Decide how a lone leg comes down: for example acancel_allnear close, or when other traders bid above you.
max_combined_price validator messages
These mistakes are rejected when you save. Messages for other composite leg mistakes are in Common validator messages.
| Mistake | Validator message |
|---|---|
One leg, two YES legs, or an action rule | "requires exactly one YES and one NO order" |
| YES and NO sizes differ | "requires equal YES and NO order sizes" |
0, a negative value, or above 1 | "must be greater than 0 and at most 1" |
Deep Research sweeps for paired quotes
When Deep Research explores a broad grid for a Kalshi two-sided quote, it can sweep three settings that aren't YAML keys: maker.quote_offset (both legs' price.offset, moved together), maker.min_spread (the quote rule's spread >= threshold and the cancel rule's spread < threshold, moved together) and maker.refresh_interval (loop.interval, 10 seconds or more).
Deep Research is Kalshi-only and covers crypto and weather markets (see Backtesting and Deep Research). Its default grid for this shape has 100 variants around your own values: 5 whole-cent offsets, 4 whole-cent spread thresholds and 5 loop intervals. For example, an offset of 0.03, a spread threshold of 0.06 and a 10-second loop give offsets 0 to 0.04, thresholds 0.05 to 0.08 and intervals of 10, 12, 14, 16 and 18 seconds. In every variant the offset must stay below the spread threshold, and every variant must pass the same checks as a save.
If the strategy doesn't match the shape below, the maker axes can't be used. If your offset or threshold isn't a whole cent, the default grid leaves them out. A strategy with no numeric params then sweeps only risk.price_floor and risk.price_ceiling, which still moves your quotes because legs are clamped into that band. These are ranges Deep Research explores, not recommended settings.
It recognizes a strategy only in this exact shape:
- One quote rule with a
when.allblock containing exactly onespreadcondition,op: ">="and a literal number. Two legs, oneyesand oneno, bothaction: buy,reference: best_bid,post_only: true, with equal offsets. Amax_combined_priceon the quote rule is allowed. - One
cancel_allrule whosewhen.allhas exactly onespreadcondition withop: "<"and the same number. - Identical guard conditions on
position_size,portfolio_position_countandorder_countin both rules.5and5.0count as different.
rules:
- name: pull quotes when narrow
when:
all:
- {field: spread, op: "<", value: 0.06}
- {field: portfolio_position_count, op: "<", value: 5}
action: cancel_all
- name: quote both outcomes
when:
all:
- {field: spread, op: ">=", value: 0.06}
- {field: portfolio_position_count, op: "<", value: 5}
max_combined_price: 0.98
orders:
- side: yes
action: buy
size: 1
price: {reference: best_bid, offset: 0.03}
post_only: true
- side: no
action: buy
size: 1
price: {reference: best_bid, offset: 0.03}
post_only: trueThe trade-offs of this shape:
- It has no
order_count == 0gate, since adding one to the quote rule alone makes the shape unrecognized. Live, the rule therefore cancels and re-sends both legs every loop. Your own quotes also narrowspread, so the cancel rule can pull them on the next loop. The maker replay keeps unchanged quotes in place and doesn't show this. - The two leg prices add up to 1 − spread + 2 × offset. Wherever twice the offset reaches the live spread, the pair costs $1.00 or more; this example's 0.03 offset does that whenever the spread is exactly 0.06. The
max_combined_price: 0.98in the example makes the Bot skip those pairs, and it keeps the same value in every variant. - To compare changes the grid can't express, such as different YES and NO offsets, give Deep Research explicit strategy candidates instead.