Per-Fill Profit Targets and Stops
This page covers per-fill exits in Kalshi custom strategies (platform: kalshi, strategy: custom): the four keys that let a sell_yes or sell_no rule manage each purchase at its own price, how the Bot tracks those purchases, and when it blocks trading because it can't trust them. Per-fill exits can't share a strategy with composite orders. For where these rules go in your rule list and how they run in backtests, see Rule order and requirements and Per-fill exits in backtests.
A plain sell_yes rule sells your whole YES position when its conditions pass. Add a per-fill key and it manages individual purchases instead. Each buy fill becomes a lot with its own actual price, and the rule sells a lot when that lot's own target or stop is reached. Average cost is never used.
Error messages and logs call these entry-relative exits or entry targets. Each exit that fires is logged under its key's name (entry_profit_offset, entry_stop_loss_offset or entry_profit_targets) with the fill's price, target and quantity.
| Parameter | Type | Default | Allowed values | What it does |
|---|---|---|---|---|
entry_profit_offset | number (dollars) | none | Above 0, below 1 | Sells a lot once the bid reaches lot price + offset, rounded up to the cent |
entry_stop_loss_offset | number (dollars) | none | Above 0, below 1 | Sells a lot once the bid falls to lot price − offset or lower |
entry_profit_targets | list of rows | none | At least one row | Gives fills at an exact price their own target price |
entry_profit_targets[].entry_price | number (dollars) | required | Whole cents above 0 and below 1, unique across rows | The exact fill price the row applies to |
entry_profit_targets[].target_price | number (dollars) | required | Whole cents above entry_price, below 1 | The minimum sale price for those fills |
entry_profit_min_time_to_expiry | duration | none (every fill eligible) | A whole number plus s, m or h | Uses the table only for fills made with more than this long left before close |
Each per-fill rule:
- uses
action: sell_yesfor YES lots, judged on the YES bid, oraction: sell_nofor NO lots, judged on the NO bid, - has no
size(the quantity comes from each lot) and noorders, - carries exactly one kind of exit: one offset, or one table. Put the stop and the target on separate rules,
- still needs a
whenblock, usuallyyes_position_size > 0orno_position_size > 0.
The messages the validator gives for mixing these keys are in What can be combined, and those for bad values are in Common validator messages.
How fills become lots
The Bot records each of its own buy fills per market: the price actually paid, quantity, fees and time. Every loop, it checks that record against your Kalshi fills and holdings.
- Oldest first. Buying the opposite outcome nets against your oldest lots first, and so do ordinary exits such as
sell_all. - Start flat. The Bot never adopts contracts it didn't buy itself: only fills of this deployment's own orders become lots.
- Separate ownership. Additional same-side contracts from manual trades or other Bots do not become this Bot's lots. New entries wait while the account holds inventory outside this Bot. If the account holds fewer contracts than its lots require, the Bot blocks new entries with "entry lots disagree with account holdings; new entries blocked, exits allowed". Exits remain capped at this Bot's available inventory and the account's holdings on that side, after pending exits are reserved.
- One record per deployment. The record survives an ordinary Bot restart. A runner upgrade or rebuild, including the updates Turbine rolls out, starts every Bot on an empty disk; the Bot then rebuilds each market's lots from this deployment's own fills, which it recognizes by their order ids. A rebuilt record doesn't know which lot an earlier exit was aimed at, so it closes the oldest lots first. Deploying the strategy again starts a new, empty record that doesn't adopt the previous deployment's contracts, so close per-fill positions before you deploy again.
- Settlement. When Kalshi reports the market settled (its API says
finalized), the remaining lots close at the result and the market's record is done.
How a per-fill rule matches
A per-fill rule is eligible only when both are true:
- a lot on its side qualifies: its target is reached, its stop is triggered, or a table row matches and is reached, and
- the rule's
whenconditions pass.
If no lot qualifies, evaluation falls through to the next rule, so an entry below can still fire that loop.
When the rule fires, the Bot picks the oldest qualifying lot, skipping older lots that don't qualify. It sends one immediate-or-cancel, reduce-only limit sell for that lot, and handles at most one lot per market per loop.
- Quantity is the smallest of: the lot's remaining contracts, 40, and your deployment's Max contracts per order. Because your entry orders are capped by the same Max contracts per order limit, a lot normally sells in one order. It takes several loops only if the lot is larger than 40 contracts, or if you lowered Max contracts per order after the lot was bought. A partial fill reduces only that lot.
- Polling, not resting. Targets and stops are checked once per loop, at the current bid. They are not orders waiting on the book, so a price that passes through between loops can be missed, and a stop can sell well below its trigger.
entry_profit_offset
The target for each lot is its fill price plus the offset, rounded up to the cent. When that side's best bid is at or above the target, the Bot sends an immediate-or-cancel sell with the target as its limit. That limit is a minimum, so the sell can fill higher.
- A 0.38 fill with a 0.05 offset has a 0.43 target. A sub-cent fill at 0.853 has a target of 0.903, rounded up to 0.91.
- The offset is measured before fees. A 5-cent offset is not a 5-cent net gain.
- If fill price plus offset is above 0.99, the target can never be reached. It is not lowered. The Bot logs a blocked decision instead, at most every 15 minutes.
entry_stop_loss_offset
A lot's stop triggers when that side's best bid is at or below the lot's fill price minus the offset. The comparison is exact, with no rounding. The sell is sent at the current bid rounded down to the cent, not at the trigger price.
- A fill at 0.85 with a 0.10 stop triggers at a bid of 0.75. If the bid gaps straight to 0.729, the Bot sends the sell at 0.72.
- The sale price can be well under the trigger, and the sell can fill partly or not at all. An unfilled remainder is tried again only while the stop condition still holds.
- A missing bid never triggers a stop, and the rounded bid must be from 0.01 to 0.99.
entry_profit_targets
A table maps an exact fill price to its own target price. A lot qualifies only if its actual fill price exactly equals a row's entry_price and the bid is at or above that row's target_price. The Bot then sells it with an immediate-or-cancel limit at target_price, which is a minimum.
rules:
- name: table_targets
when:
all:
- {field: yes_position_size, op: ">", value: 0}
action: sell_yes
entry_profit_targets:
- {entry_price: 0.85, target_price: 0.95}
- {entry_price: 0.86, target_price: 0.95}
- {entry_price: 0.87, target_price: 0.96}
- {entry_price: 0.88, target_price: 0.97}- Fills without a row get no table target. A buy that got price improvement (you bid 0.88 and filled at 0.85) matches the 0.85 row. A sub-cent fill such as 0.8501 matches no row. Pair every table with a stop rule that covers all fills.
- Rows are cent-grid prices. Both prices must be whole cents above 0 and below 1, and each target must be above its entry price.
0.855,1.00and duplicate entry prices are rejected. - For
sell_no, rows are NO prices. The table reads NO fills and the NO bid. - The price band doesn't apply. Exit prices are not clamped by
risk.price_floororrisk.price_ceiling. - Targets must be on the market's price grid. If a target isn't a valid price for that market, the exit fails with "entry target is outside the supported market price grid" and the rest of that market's rules are skipped for that loop.
- YAML anchors work, so a YES rule and a NO rule can share one table. See one target table for YES and NO fills.
Each fill has its own stop and target
- Fill A, bought at 0.85 (older)
- Fill B, bought at 0.88
- Fill price
- Stop trigger (fill − 0.08)
- Table target
- YES best bid at each loop
entry_profit_min_time_to_expiry
Restricts the table on the same rule to fills made with more than this much time left before the market closes. Eligibility is decided at the moment of the fill and never changes after, so an early fill can still take profit inside the final window. A fill whose time or market close time is unknown is not eligible.
Valid values are a whole number followed by s, m or h: 90s, 15m, 2h, 48h. 1d, 1.5h, 1h30m, 0m and a bare 900 are rejected. It is only allowed on a rule that has entry_profit_targets, and it doesn't affect stops on other rules.
This differs from a time_to_expiry condition, which checks the time left now. With rows 0.85 → 0.95 and 0.88 → 0.97, entry_profit_min_time_to_expiry: 30m, and a market closing at 17:00:
| Fill | Price | Filled at | Time left at fill | Table target |
|---|---|---|---|---|
| A | 0.85 | 16:10 | 50 min | 0.95 |
| B | 0.88 | 16:30 | 30 min | None: the time left must be more than 30 minutes |
| C | 0.84 | 16:05 | 55 min | None: no row for 0.84 |
| D | 0.8501 | 16:12 | 48 min | None: no exact match |
If the bid reaches 0.95 at 16:50, lot A sells even though only 10 minutes remain. B, C and D are covered by the stop rule only. The entry-price profit table example pairs this kind of table and time gate with a stop rule.
When per-fill exits block entries
When the Bot can't trust its lots, it blocks new entries in that market and logs a blocked decision, but exits keep running:
| Log message | Cause | What still runs |
|---|---|---|
| "entry lots disagree with account holdings; new entries blocked, exits allowed" | The account holds fewer contracts on a side than this Bot's lots require | Exits against available Bot-owned inventory, capped at account holdings, and max_loss |
| "entry target submission awaiting confirmed order and fills" | An earlier per-fill exit's result is unknown (for example after a timeout), until Kalshi confirms the order and its fills | Plain exits (sell_yes, sell_no, sell_all) and max_loss can use only unreserved Bot-owned inventory. Per-fill exits pause too, so a lot is never sold twice |
An unconfirmed exit clears on its own once Kalshi confirms it, and the mismatch clears once holdings match the lots again. The Studio terminal shows a mismatch as the entry_lots_mismatch guardrail. To trade that market again sooner, stop the Bot, close the position, fix the cause, and deploy again from flat.
A different error, "entry target is outside the supported market price grid", appears only when an exit rule fires with a target or stop price that isn't a valid price for that market. That exit isn't sent, and the rest of that market's rules are skipped for the loop. Change the table row or offset so that targets land on the market's price grid.
With risk.max_loss set, the loss ledger is built from these lots in every market the run has evaluated, not just the current one. A holdings mismatch in a market the Bot evaluates halts the run the way any max_loss attribution mismatch does: the Bot cancels its orders, sells the inventory its lots account for, and opens nothing new for the rest of the run. Start the run flat, with no resting orders, in every market it may trade. The check also re-reads every market the run has evaluated, on every loop. With market.selection: all or a fast-rolling series that list keeps growing, so each loop sends more requests to Kalshi and can run into its rate limits, which pause new orders.