Arbitrage Outcome Mapping and Strategies
This page covers two keys every arbitrage strategy (platform: arbitrage) sets. mapping tells the Bot how leg B's outcomes line up with Kalshi's. strategy picks how the Bot trades: a hedged complementary lock, a single-leg directional buy, or your own custom rules. For every key at a glance, see All arbitrage parameters.
Outcome mapping
mapping.a_yes_equals
This key tells the Bot which leg-B outcome is the same event as Kalshi YES. Every strategy needs it.
b_yes: both venues describe the event the same way. Kalshi YES and leg-B YES happen together.b_no: the labels are inverted. Kalshi YES happens exactly when leg-B NO happens.
For example, take Kalshi's "Celtics win" market for a Lakers at Celtics game. Against Polymarket's "Will the Celtics beat the Lakers?", the mapping is b_yes. Against "Will the Lakers beat the Celtics?", it's b_no.
A lock buys opposite outcomes, so the mapping decides which two pairs the Bot can buy:
Which pairs the Bot buys for each mapping value
a_yes_equals: b_yes Same labels
Kalshi “Celtics win” and Polymarket “Will the Celtics beat the Lakers?”. Kalshi YES and leg-B YES happen together.
a_yes_equals: b_no Inverted labels
Kalshi “Celtics win” and Polymarket “Will the Lakers beat the Celtics?”. Kalshi YES happens exactly when leg-B NO happens.
complementary and custom price both pairs every loop and take the one with the better net edge. directional doesn't use the mapping, but it's still required.
For a Polymarket leg, leg-B YES is the yes_token_id outcome, whatever the market calls it. On Up/Down series it's Up.
Warning: Studio can't check that the mapping is right. With the wrong value, both legs pay on the same outcome: instead of a hedge, you hold twice the exposure to one result. Read both markets' rules and confirm which outcome matches before you deploy.
See Inverted labels on Polymarket for a complete b_no example.
Older mapping keys
Older drafts wrote the mapping like this:
mapping:
leg_a_yes_to:
leg_b_side: yes # the same as a_yes_equals: b_yesleg_b_side accepts yes, no, b_yes or b_no (letter case doesn't matter). It's read only when a_yes_equals is missing. If both are set, a_yes_equals wins without a warning. A leg_a_no_to block and rationale keys are ignored, even when they contradict leg_a_yes_to. Any other value is rejected as mapping.a_yes_equals: must be b_yes or b_no. Use a_yes_equals.
Strategies
strategy picks one of three ways to trade:
complementary | directional | custom | |
|---|---|---|---|
| What it does | Hedged lock across both venues | Buys one side on one venue | Your rules decide when to lock, cancel or skip |
| Hedged | Yes, when both legs fill | No | Yes, when a rule runs execute_lock |
mapping.a_yes_equals | Picks the lock pairs | Required, not used | Picks the lock pairs |
risk.max_position_usdc | Per-lock budget | Position cap: this ÷ max_price contracts | Per-lock budget for execute_lock |
risk.min_net_edge_bps | Lock edge after fees | Edge below max_price after fees | Always applied by execute_lock |
risk.max_leg_slippage_bps | Both legs | The single order | Both legs of execute_lock |
risk.max_unhedged_seconds | Used | Not used | Used by execute_lock |
params | Ignored | Read | Ignored |
rules | Never run | Never run | Required |
On complementary and directional, a rules block never runs, but Studio still checks its shape. An unknown rule key or a badly typed value there rejects the save.
complementary
The built-in hedged lock described in How arbitrage works. Each loop it prices both lock pairs, keeps the better one, and places the lock only when every check passes: edge, size, visible depth and fresh quotes. It needs no params or rules.
directional
Warning:
directionalis not arbitrage. It buys one side on one venue and holds it without a hedge.
Each loop, the Bot reads the ask for params.side on params.leg and buys when both of these hold:
- the ask is at or below
params.max_price, and - (max_price − ask) × 10,000, minus the estimated fee in bps, is at least
risk.min_net_edge_bps.
For example, with max_price: 0.40 and min_net_edge_bps: 200 on Polymarket US: at a 0.37 ask the fee rounds to $0.01 (100 bps), so the edge is 300 − 100 = 200 and the Bot buys. At 0.38 the edge is 200 − 100 = 100, so it skips.
The order's limit is the ask plus max_leg_slippage_bps, so it can sit slightly above max_price.
Things to know:
- It builds a position. The Bot buys again on every loop while the condition holds, and every buy adds to one position on that leg and side. It never sells: the position stays open until the market settles or you close it yourself.
max_position_usdccaps the position. The Bot holds at mostmax_position_usdc÷max_pricecontracts on its leg and side, rounded down: the number that budget buys at the highest ask it pays. $10 at 0.40 is 25 contracts. Before each buy it reads what the venue says that market already holds, so contracts from before a restart, or ones you bought by hand, count too. Contracts on the other side don't count toward this cap, but they do count toward Max open contracts. The order's slippage allowance can take the cost slightly past the budget.- Size. Each buy is
params.sizecontracts, raised to the leg's minimum order size (usually 5 on a Polymarket leg, 1 elsewhere). The Bot then cuts it to what fits under the position cap, Max contracts per order, Max dollars per order, Max open contracts and Max daily notional traded; see Deployment risk limits. When less than the minimum fits, it skips and logs "directional buy blocked by" and the limit's name. - Nothing inside
paramsis checked when you save. A misspelled key such asmax_prceis ignored, somax_pricestays 0 and the Bot never buys. An invalidlegorsidemakes every loop skip. A word where a number belongs (max_price: abc,size: lots) or a quoted decimal (size: "2.7") saves, then stops the Bot at startup. Write plain numbers.paramsitself must be a block of keys: a list or a single value (params: [1, 2],params: 5) rejects the save. - Logs explain a Bot that never buys. A Bot with no
max_pricelogs "directional max_price missing; fail closed without placing orders", and a badlegorsidelogs "directional params.leg must be a or b" or "directional params.side must be yes or no" every loop. - Rolling legs. The position cap applies to one market at a time, so each new window or daily event starts from zero; Max daily notional traded is what bounds a day. With a
recurringPolymarket US leg B,leg: bbuys in the bucket the Bot pairs when it first resolves each day's event, and stays on it until that market closes. Withoutbucket.select, that's the bucket with the best lock edge at that moment, which says nothing about direction, so setbucket.select: pinned_boundsto choose it yourself. mappingis required but not used, andmax_unhedged_secondsdoesn't apply.
See Directional buy on one venue for a complete example.
custom
Custom arbitrage strategies use ordered rules like Custom Rules on single venues, with their own fields and actions. Each loop:
- The Bot reads all four asks, both balances, and the better lock pair's cost, edge and fees. If any of these can't be read, for example because one side has no ask, no rule runs that loop.
- It checks the rules from top to bottom.
- The first rule whose
whenblock is true runs its action, and the loop ends. If no rule matches, nothing happens.
Put guard rules, such as skip and cancel_all, above the rule that locks.
Custom rule fields
| Field | Meaning |
|---|---|
leg_a.yes_ask | Kalshi YES ask, in dollars (1 − the best NO bid). |
leg_a.no_ask | Kalshi NO ask (1 − the best YES bid). |
leg_a.balance | Kalshi cash balance, in dollars. |
leg_b.yes_ask | Leg-B YES ask. On Polymarket, the YES token's ask. |
leg_b.no_ask | Leg-B NO ask. On Polymarket US, 1 − the best YES bid. |
leg_b.balance | Polymarket collateral or Polymarket US buying power, in dollars. |
combined_cost | The two asks of the better lock pair, added together, in dollars. |
net_edge_bps | Net edge of the better lock pair at 1 contract, after estimated fees. |
fee_a | Estimated Kalshi fee for 1 contract of the better pair, in dollars. |
fee_b | Estimated leg-B fee for 1 contract of the better pair, in dollars. |
halted | Accepted, but always false while rules run: a halted Bot stops before it checks rules. Don't use it. |
Field names are case-sensitive. Single-venue fields such as price, spread, time_to_expiry, edge.* and derived.* are rejected.
Compare against an unquoted number (value: 0.95) or another field (value_field: leg_b.yes_ask). Set exactly one of the two. A quoted number such as value: "0.5", a duration string such as "5m", .nan and .inf are rejected (must be a number, must be finite). halted also accepts true or false.
Custom rule actions
| Action | What it does |
|---|---|
execute_lock | Runs the full hedged lock with every check: min_net_edge_bps (at 1 contract and at the lock size), sizing (including Max open contracts and Max daily notional traded), slippage and the unhedged-time halt. The rule's conditions only decide whether to try. |
cancel_all | Cancels this Bot's resting orders in the current Kalshi market and the leg-B market. Orders you placed by hand and other Bots' orders stay. It never closes positions. |
skip | Does nothing this loop. Use it to guard the rules below it. |
Actions are case-sensitive. Single-venue actions such as buy_yes and sell_all are rejected, and so are the reserved buy@a, buy@b, sell_all@a and sell_all@b. Custom arbitrage has no required exit rule, because locks are held to settlement.
Rule keys from single-venue custom rules (size, max_combined_price, orders, trading_schedule, entry_profit_offset, entry_stop_loss_offset, entry_profit_targets and entry_profit_min_time_to_expiry) are accepted when well-typed, then ignored. size: 5 doesn't size a lock; see risk.max_position_usdc. Any other rule key is rejected.
# Rejected: custom arbitrage rules use arbitrage fields and actions, not single-venue ones
# error: unknown arbitrage state field "price"
# error: unknown arbitrage action "buy_yes"
version: 1
platform: arbitrage
strategy: custom
venues:
a:
platform: kalshi
market:
ticker: KXFEDDECISION-26OCT-C25
b:
platform: polymarket_us
market:
slug: fed-cuts-rates-25bps-october-2026
mapping:
a_yes_equals: b_yes
risk:
max_position_usdc: 25
loop:
interval: 30
rules:
- name: buy_cheap_yes
when:
all:
- {field: price, op: "<=", value: 0.40}
action: buy_yesSee Custom rules on a rolling pair for a complete strategy.