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.

Example IDs are illustrative. The friendly ids and hashes here (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, or yes/no.
  • variable — the line value, when the market has one (a spread of -3.5, a total of 45.5, a prop line of 28.5). Absent / 0 for 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 the side_key, not the participant.
  • consensus_price — a fair-value probability across venues (0–1), for display; each venue's own price lives in partner_liquidities[].
One position (moneyline, home side)
{
  "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_requireddoes this market have a line? true (spread, total, prop) means each outcome is pinned to a variable, so the market is a ladder: group the positions by variable and 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_participantscan 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 same market_key holds one independent row per participant: split by participant_id into a book (or line) each.
Shapevariable_requiredallow_multiple_participantssidesHow to lay out
Moneylinefalsefalsehome / awayOne book, two sides
Spread / Totaltruefalsehome·away / over·underLadder: pair the two sides at each line
Player proptruetrueover / underOne row per participant, paired over/under
Tournament / futuresfalsetrueyes / noOne 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.

Book: moneyline
Chiefs vs. 49ers — Moneyline
  home  Kansas City Chiefs      0.52   (per-venue prices in partner_liquidities[])
  away  San Francisco 49ers     0.50

Spread & 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 home and away at 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 over and under at the same line.
Book: total (a rung per line, two sides per rung)
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.57

The 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.

Book: player_points (one card per athlete)
Player points
  LeBron James    line 28.5   over 0.50   under 0.52
  Anthony Davis   line 22.5   over 0.49   under 0.53
The opposite side is same-participant. The counter-bet to "LeBron over 28.5" is "LeBron under 28.5" — same participant_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.

Book: team_tournament_winner (one yes/no per team)
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.86

Grouping, 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 by participant_id — one book (props/futures) per participant.
  • If variable_required, sub-bucket by variable — one rung per line.
  • Within a bucket, pair the two side_keys home/away, over/under, or yes/no.
Reconstructing books from the flat liquidity array
// 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 total the participant_id is an Over/Under stand-in, not a team — key the bet off side_key. Because allow_multiple_participants is false there, you won't sub-bucket by it anyway.
  • Line values are numeric. Bucket on the numeric variable (e.g. compare 45.5), not on formatted display text.