Market model & books
How a contest's liquidity is shaped so you can lay it out into per-market “books” — pairing the two sides of a line, and splitting props and futures into one row per participant.
The problem this solves
GET /flow/v1/contests/:id/liquidity returns a flat list of priced positions for a contest — every bettable outcome, across every venue, in one array. To render a betting screen you usually want the opposite: outcomes grouped into books — a moneyline with its two sides, a spread ladder with home/away paired at each line, a player-prop card with one row per athlete. This page shows how to reconstruct those books from the fields each position already carries, using two market properties as the guide.
ct_abc123, p_kc, the 6-part position hash) exist only for readability. In production every id and hash is an opaque string. Treat them as opaque: store verbatim, never construct one, split a hash, or pattern-match a prefix.What a position carries
Each entry in the liquidity array describes one outcome you can bet, with every venue that prices it hanging off partner_liquidities[]. The fields that matter for layout:
position_hash— the outcome's stable key. It is composed of six parts in a fixed order:contest : market : side : variable : participant : timeframe. Two positions that share everything but one part are siblings along that axis (e.g. the same market and line but a different side). Treat the whole string as opaque — don't split it; the discrete fields below are given to you explicitly.market_key— which market this is (moneyline,spread,total,player_points, a tournament winner, …). Group by this first.side_key— the outcome within the market:home/away,over/under, oryes/no.variable— the line value, when the market has one (a spread of-3.5, a total of45.5, a prop line of28.5). Absent /0for markets with no line.participant_id(+participant_name) — the team or athlete the outcome is about. On totals it is a synthetic Over/Under participant; what makes the bet is theside_key, not the participant.consensus_price— a fair-value probability across venues (0–1), for display; each venue's own price lives inpartner_liquidities[].
{
"position_hash": "ct_abc123:mk_ml:side_home:var_0:p_kc:tf_full",
"market_key": "moneyline",
"side_key": "home",
"variable": 0,
"participant_id": "p_kc",
"participant_name": "Kansas City Chiefs",
"consensus_price": 0.52,
"partner_liquidities": [
{ "partner_id": "kalshi", "price": 0.49, "available": 5400,
"liquidity_hash": "kalshi:ct_abc123:mk_ml:side_home:var_0:p_kc:tf_full" }
]
}Two properties decide the layout
The market catalog (GET /flow/v1/contests/:id/markets and GET /flow/v1/markets/:id) gives each market two booleans that tell you how its positions fan out. You can also infer the shape straight from the positions, but reading the flags is the reliable way.
variable_required— does this market have a line?true(spread, total, prop) means each outcome is pinned to avariable, so the market is a ladder: group the positions byvariableand pair the two sides at each line.false(moneyline, futures winner) means there is a single line value (0) and the market is one flat set of sides.allow_multiple_participants— can many participants coexist in one market?false(moneyline, spread, total) means the two sides are the whole market — one book.true(player props, tournament/futures winners) means the samemarket_keyholds one independent row per participant: split byparticipant_idinto a book (or line) each.
| Shape | variable_required | allow_multiple_participants | sides | How to lay out |
|---|---|---|---|---|
| Moneyline | false | false | home / away | One book, two sides |
| Spread / Total | true | false | home·away / over·under | Ladder: pair the two sides at each line |
| Player prop | true | true | over / under | One row per participant, paired over/under |
| Tournament / futures | false | true | yes / no | One row per participant, each a yes/no binary |
Moneyline — two fixed sides
variable_required: false, allow_multiple_participants: false. The market is exactly two positions — side_key: "home" and "away" — both at variable: 0. No line, no per-participant fan-out. Render them as one two-row book; the two sides sum to ~1.
Chiefs vs. 49ers — Moneyline
home Kansas City Chiefs 0.52 (per-venue prices in partner_liquidities[])
away San Francisco 49ers 0.50Spread & Total — a ladder keyed by the line
variable_required: true, allow_multiple_participants: false. Each line is a rung on a ladder, and every rung has two paired sides. Group by variable, then pair within the rung:
- Spread — pair
homeandawayat the same line. (The two sides describe the same margin from each team's perspective; each side is its own position, so match them by rung.) - Total — pair
overandunderat the same line.
Chiefs vs. 49ers — Total points
line 44.5 over 0.50 under 0.52
line 45.5 over 0.48 under 0.54
line 46.5 over 0.45 under 0.57The two positions on a rung share the same market_key and variable and differ only in side_key — that is what makes them a pair.
Player prop — one row per participant
variable_required: true, allow_multiple_participants: true. Now the same market_key (e.g. player_points) holds many athletes at once. Split by participant_id first, then it behaves like a mini total: an over/under pair at each line for that athlete.
Player points
LeBron James line 28.5 over 0.50 under 0.52
Anthony Davis line 22.5 over 0.49 under 0.53participant_id and variable, flipped side_key. It is not a different athlete. This is the practical meaning of allow_multiple_participants: true: participants are independent books, so pair sides within a participant, never across.Tournament / futures winner — one row per participant, yes/no
variable_required: false, allow_multiple_participants: true. A "who wins it all" market lists every contender under one market_key, each as an independent yes/no binary at variable: 0. Split by participant_id; each participant is its own two-outcome book. Unlike a moneyline, the yes prices across contenders do not sum to 1 — they are separate markets.
NBA Champion 2026
Oklahoma City Thunder yes 0.28 no 0.74
Boston Celtics yes 0.22 no 0.80
Denver Nuggets yes 0.15 no 0.86Grouping, end to end
A single pass over the liquidity array reconstructs every book:
- Bucket by
market_key— each market is its own section. - If
allow_multiple_participants, sub-bucket byparticipant_id— one book (props/futures) per participant. - If
variable_required, sub-bucket byvariable— one rung per line. - Within a bucket, pair the two
side_keys — home/away, over/under, or yes/no.
// positions = GET /flow/v1/contests/:id/liquidity (the flat array)
// markets = GET /flow/v1/contests/:id/markets (for the two flags)
const flags = new Map(markets.map((m) => [m.key, m]))
function bookKeyFor(pos) {
const m = flags.get(pos.market_key)
const parts = [pos.market_key]
if (m?.allow_multiple_participants) parts.push(pos.participant_id) // props / futures
if (m?.variable_required) parts.push(String(pos.variable)) // spread / total / prop line
return parts.join('|') // e.g. "player_points|p_lebron|28.5"
}
const books = new Map()
for (const pos of positions) {
const key = bookKeyFor(pos)
if (!books.has(key)) books.set(key, {})
books.get(key)[pos.side_key] = pos // "home"/"away", "over"/"under", "yes"/"no"
}
// Each value now holds the paired sides of one book/rung.To actually place a bet against any side, take that position's chosen partner_liquidities[].liquidity_hash to Placing orders. The same six-part key also drives GET /flow/v1/depth/:position_hash for full orderbook depth on a single outcome.
Edge cases worth handling
- A side can be missing. A venue may price only one side of a rung. Render the pair with a blank cell rather than assuming both exist.
- Totals use synthetic participants. On
totaltheparticipant_idis an Over/Under stand-in, not a team — key the bet offside_key. Becauseallow_multiple_participantsisfalsethere, you won't sub-bucket by it anyway. - Line values are numeric. Bucket on the numeric
variable(e.g. compare45.5), not on formatted display text.