Skip to content

Two venues, one question

Pair the markets two prediction venues list for the same question, and read both settlement rule texts side by side.

Kalshi and Polymarket both list markets on the next Federal Reserve rate cut. A trader who prices one against the other needs to know if the two contracts settle the same way. Both venues publish the rules text. Nobody lines them up.

Which Polymarket market asks the same question as each open Kalshi market in the KXRATECUT series? How do the two venues’ settlement rules and close times differ?

Kalshi markets Polymarket events
Publisher Kalshi, through its public trade API Polymarket, through the Gamma API
Address https://external-api.kalshi.com/trade-api/v2/markets?series_ticker=KXRATECUT&status=open&limit=1000&mve_filter=exclude https://gamma-api.polymarket.com/events?active=true&closed=false&limit=100&offset=0&order=volume24hr&ascending=false
Format JSON, records under /markets JSON, an array of events, each holding an array of markets
Cadence Kalshi lists and settles markets all day the same
Rights permission_asserted, because Kalshi’s Data Terms of Use require Kalshi’s prior written consent before archived Kalshi data is shared terms_of_service_asserted, because the Gamma API is a public unauthenticated read interface and the Terms of Use allow reading what it serves
Why it carries rules_primary, the text the contract settles by it carries description, which is Polymarket’s resolution rules text

The Kalshi basis is a written consent held by one workspace. It is not a licence anybody inherits by copying this recipe. State the basis a build actually has, and unknown where there is none.

The two venues share no identifier. There is no ticker, no event id and no slug in common, so the recipe has to build the key it joins on.

recipe.json
{
"dataset": {
"name": "Kalshi and Polymarket rate-cut markets, settlement compared",
"description": "Each row is one open Kalshi market in the KXRATECUT series, paired with the Polymarket market that asks the same question, carrying both venues' settlement rule texts and the gap between their close times."
},
"question": {
"text": "For each open Kalshi market in the KXRATECUT series, which Polymarket market asks the same question, and how do the two venues' settlement rules and close times differ?"
},
"table": {
"name": "settlement_differences",
"description": "One row per open Kalshi market in the series, paired with the best-scoring Polymarket market.",
"grain": [
"kalshi_market_ticker"
],
"columns": [
{
"name": "kalshi_market_ticker",
"type": "string",
"nullable": false,
"description": "Kalshi's ticker for the market, one row per ticker.",
"presentation": {
"chart": "none"
}
},
{
"name": "kalshi_title",
"type": "string",
"nullable": true,
"description": "The question the Kalshi market asks, as Kalshi words it."
},
{
"name": "kalshi_strike",
"type": "string",
"nullable": true,
"description": "The label Kalshi gives the yes side, either a named outcome or a strike."
},
{
"name": "kalshi_rules",
"type": "string",
"nullable": true,
"description": "The primary settlement rule text the Kalshi market settles by, verbatim.",
"presentation": {
"chart": "none"
}
},
{
"name": "kalshi_close_time",
"type": "timestamp",
"nullable": true,
"description": "When Kalshi closes trading on the market, in UTC.",
"presentation": {
"chart": "timeline",
"bucket": "month"
}
},
{
"name": "kalshi_status",
"type": "string",
"nullable": true,
"description": "Kalshi's market status at fetch time."
},
{
"name": "kalshi_result",
"type": "string",
"nullable": true,
"description": "Kalshi's settled result where the market has settled."
},
{
"name": "polymarket_market_id",
"type": "string",
"nullable": true,
"description": "Polymarket's identifier for the best-matching market, empty when no market scored 0.6 or more.",
"presentation": {
"chart": "none"
}
},
{
"name": "polymarket_event_title",
"type": "string",
"nullable": true,
"description": "The title of the Polymarket event the matched market belongs to."
},
{
"name": "polymarket_question",
"type": "string",
"nullable": true,
"description": "The question the matched Polymarket market asks."
},
{
"name": "polymarket_rules",
"type": "string",
"nullable": true,
"description": "The resolution rules text of the matched Polymarket market, verbatim.",
"presentation": {
"chart": "none"
}
},
{
"name": "polymarket_resolution_source",
"type": "string",
"nullable": true,
"description": "The resolution source Polymarket names for the matched market, empty when it names none."
},
{
"name": "polymarket_end_date",
"type": "timestamp",
"nullable": true,
"description": "When the matched Polymarket market ends, in UTC.",
"presentation": {
"chart": "timeline",
"bucket": "month"
}
},
{
"name": "polymarket_closed",
"type": "boolean",
"nullable": true,
"description": "Whether the matched Polymarket market was already closed at fetch time.",
"presentation": {
"chart": "share"
}
},
{
"name": "match_score",
"type": "decimal",
"nullable": true,
"description": "The combined pairing score between 0 and 1: 0.6 title similarity and 0.4 strike match.",
"presentation": {
"chart": "histogram",
"bins": 10,
"story": "Scores split into a cluster of near-identical questions and a tail of loose pairs."
}
},
{
"name": "match_tier",
"type": "string",
"nullable": false,
"description": "exact when the titles are near-identical and the strike matches, strong at 0.75 or more, weak at 0.6 or more, unmatched below.",
"presentation": {
"chart": "top_values"
}
},
{
"name": "strike_matched",
"type": "boolean",
"nullable": true,
"description": "Whether the Kalshi yes-side label appears in the Polymarket question or equals its outcome label.",
"presentation": {
"chart": "share"
}
},
{
"name": "close_gap_hours",
"type": "integer",
"nullable": true,
"description": "Hours from the Kalshi close time to the Polymarket end date, negative when Polymarket ends first.",
"presentation": {
"chart": "histogram",
"bins": 8
}
},
{
"name": "kalshi_rules_chars",
"type": "integer",
"nullable": true,
"description": "Length of the Kalshi rules text in characters."
},
{
"name": "polymarket_rules_chars",
"type": "integer",
"nullable": true,
"description": "Length of the Polymarket rules text in characters."
}
]
},
"sources": [
{
"name": "kalshi_rate_markets",
"description": "Every open market in the Kalshi KXRATECUT series, with its question, strike, close time and the rules text it settles by.",
"source_class": "user_api",
"data_classification": "public",
"locator": {
"kind": "https_url",
"display_locator": "https://external-api.kalshi.com/trade-api/v2/markets?series_ticker=KXRATECUT&status=open&limit=1000&mve_filter=exclude"
},
"rights_claim": {
"claimed_basis": "permission_asserted",
"claim_evidence_digest": "sha256:6d85310bdb00d20c33eb839336e6da06f5b7695b51255fbec0e431fe5c27f825",
"claim_note": "Kalshi's Data Terms of Use require Kalshi's prior written consent before archived Kalshi data is shared. The workspace that registered this recipe holds that consent, and the evidence digest is over the terms as read on 10 September 2026."
},
"connector": {
"adapter_id": "public.https",
"credential_mode": "none",
"origin": "https://external-api.kalshi.com",
"parameters": [
{
"name": "reader.family_id",
"value": "json.tabular"
},
{
"name": "reader.family_version",
"value": "1.1.0"
},
{
"name": "reader.decode_options",
"value": "{\"columns\":[{\"name\":\"ticker\",\"pointer\":\"/ticker\",\"required\":true},{\"name\":\"event_ticker\",\"pointer\":\"/event_ticker\",\"required\":true},{\"name\":\"title\",\"pointer\":\"/title\",\"required\":false},{\"name\":\"yes_sub_title\",\"pointer\":\"/yes_sub_title\",\"required\":false},{\"name\":\"rules_primary\",\"pointer\":\"/rules_primary\",\"required\":false},{\"name\":\"close_time\",\"pointer\":\"/close_time\",\"required\":false},{\"name\":\"status\",\"pointer\":\"/status\",\"required\":false},{\"name\":\"result\",\"pointer\":\"/result\",\"required\":false}],\"document_format\":\"json\",\"expand\":[],\"records_pointer\":\"/markets\"}"
}
]
},
"limits": {
"max_source_bytes": 8388608,
"max_rows": 2000,
"max_requests": 1
}
},
{
"name": "polymarket_top_events",
"description": "The hundred open Polymarket events with the highest 24-hour volume, with every nested market's question, resolution rules text and end date.",
"source_class": "user_api",
"data_classification": "public",
"locator": {
"kind": "https_url",
"display_locator": "https://gamma-api.polymarket.com/events?active=true&closed=false&limit=100&offset=0&order=volume24hr&ascending=false"
},
"rights_claim": {
"claimed_basis": "terms_of_service_asserted",
"claim_evidence_digest": "sha256:4f51a9df131d659136c5a727b6206207420356f8cc37b42d5e7394429c24c023",
"claim_note": "Polymarket's Gamma API is a public unauthenticated read interface to market listings and their resolution rules. The Polymarket Terms of Use allow reading publicly served market data, and only market metadata and rules text are retained, never user or wallet data."
},
"connector": {
"adapter_id": "public.https",
"credential_mode": "none",
"origin": "https://gamma-api.polymarket.com",
"parameters": [
{
"name": "reader.family_id",
"value": "json.tabular"
},
{
"name": "reader.family_version",
"value": "1.1.0"
},
{
"name": "reader.decode_options",
"value": "{\"columns\":[{\"name\":\"event_id\",\"pointer\":\"/id\",\"required\":true},{\"name\":\"event_title\",\"pointer\":\"/title\",\"required\":false},{\"name\":\"id\",\"pointer\":\"/markets/id\",\"required\":true},{\"name\":\"question\",\"pointer\":\"/markets/question\",\"required\":false},{\"name\":\"description\",\"pointer\":\"/markets/description\",\"required\":false},{\"name\":\"resolution_source\",\"pointer\":\"/markets/resolutionSource\",\"required\":false},{\"name\":\"end_date\",\"pointer\":\"/markets/endDate\",\"required\":false},{\"name\":\"closed\",\"pointer\":\"/markets/closed\",\"required\":false},{\"name\":\"group_item_title\",\"pointer\":\"/markets/groupItemTitle\",\"required\":false},{\"name\":\"updated_at\",\"pointer\":\"/markets/updatedAt\",\"required\":false}],\"document_format\":\"json\",\"expand\":[\"/markets\"],\"records_pointer\":\"\"}"
}
]
},
"limits": {
"max_source_bytes": 33554432,
"max_rows": 20000,
"max_requests": 1
}
}
],
"transform": {
"engine": "duckdb_sql",
"steps": [
{
"step_id": "kalshi_markets",
"sql": "select ticker as market_ticker, title, nullif(yes_sub_title,'') as strike, rules_primary as rules, try_cast(concat(substr(close_time,1,19),'+00') as timestamp with time zone) as close_time, status, nullif(result,'') as result, trim(regexp_replace(regexp_replace(regexp_replace(lower(coalesce(title,'')), '\\$?(\\d+)k\\b', '\\1000', 'g'), '[^a-z0-9 ]', ' ', 'g'), ' +', ' ', 'g')) as title_norm, trim(regexp_replace(regexp_replace(regexp_replace(lower(coalesce(yes_sub_title,'')), '\\$?(\\d+)k\\b', '\\1000', 'g'), '[^a-z0-9 ]', ' ', 'g'), ' +', ' ', 'g')) as strike_norm from kalshi_rate_markets",
"description": "Cast the Kalshi relation and normalize the question and the strike label into the text the pairing scores."
},
{
"step_id": "polymarket_markets",
"sql": "select id as market_id, event_id, event_title, question, description as rules, nullif(resolution_source,'') as resolution_source, try_cast(concat(substr(end_date,1,19),'+00') as timestamp with time zone) as end_date, try_cast(closed as boolean) as closed, trim(regexp_replace(regexp_replace(regexp_replace(lower(coalesce(question,'')), '\\$?(\\d+)k\\b', '\\1000', 'g'), '[^a-z0-9 ]', ' ', 'g'), ' +', ' ', 'g')) as question_norm, trim(regexp_replace(regexp_replace(regexp_replace(lower(coalesce(group_item_title,'')), '\\$?(\\d+)k\\b', '\\1000', 'g'), '[^a-z0-9 ]', ' ', 'g'), ' +', ' ', 'g')) as group_norm from polymarket_top_events qualify row_number() over (partition by id order by updated_at desc) = 1",
"description": "Cast the Polymarket relation and keep the latest row per market identifier, because one market can appear under more than one event row."
},
{
"step_id": "best_pairs",
"sql": "select k.market_ticker, p.market_id, p.event_id, p.event_title, p.question, p.rules, p.resolution_source, p.end_date, p.closed, jaro_winkler_similarity(k.title_norm, p.question_norm) as title_similarity, case when k.strike_norm <> '' and (p.group_norm = k.strike_norm or position(k.strike_norm in p.question_norm) > 0) then true else false end as strike_matched, 0.6 * jaro_winkler_similarity(k.title_norm, p.question_norm) + 0.4 * (case when k.strike_norm <> '' and (p.group_norm = k.strike_norm or position(k.strike_norm in p.question_norm) > 0) then 1 else 0 end) as match_score from kalshi_markets k cross join polymarket_markets p qualify row_number() over (partition by k.market_ticker order by match_score desc, p.market_id) = 1 and match_score >= 0.6",
"description": "Score every Kalshi market against every Polymarket market and keep the single best pair per Kalshi ticker, dropping a best pair that scores under 0.6."
},
{
"step_id": "settlement_differences",
"sql": "select k.market_ticker as kalshi_market_ticker, k.title as kalshi_title, k.strike as kalshi_strike, k.rules as kalshi_rules, k.close_time as kalshi_close_time, k.status as kalshi_status, k.result as kalshi_result, b.market_id as polymarket_market_id, b.event_title as polymarket_event_title, b.question as polymarket_question, b.rules as polymarket_rules, b.resolution_source as polymarket_resolution_source, b.end_date as polymarket_end_date, b.closed as polymarket_closed, cast(b.match_score as decimal(6,4)) as match_score, case when b.market_id is null then 'unmatched' when b.title_similarity >= 0.95 and b.strike_matched then 'exact' when b.match_score >= 0.75 then 'strong' else 'weak' end as match_tier, b.strike_matched, cast(date_diff('hour', k.close_time, b.end_date) as integer) as close_gap_hours, cast(length(k.rules) as integer) as kalshi_rules_chars, cast(length(b.rules) as integer) as polymarket_rules_chars from kalshi_markets k left join best_pairs b on b.market_ticker = k.market_ticker",
"description": "Keep every Kalshi market and attach the paired Polymarket market where the score cleared the floor. An unmatched market keeps its row with empty Polymarket columns."
}
]
},
"checks": [
{
"check_id": "one_row_per_kalshi_market",
"kind": "key_uniqueness",
"enforcement": "required",
"columns": [
"kalshi_market_ticker"
],
"description": "One row per Kalshi ticker. A duplicate means the pairing step returned more than one Polymarket market for a ticker."
},
{
"check_id": "match_score_bounded",
"kind": "value_range",
"enforcement": "required",
"columns": [
"match_score"
],
"min_value": "0",
"max_value": "1"
},
{
"check_id": "kalshi_rules_present",
"kind": "null_ceiling",
"enforcement": "required",
"columns": [
"kalshi_rules"
],
"max_null_ppm": 50000
},
{
"check_id": "some_matches",
"kind": "null_ceiling",
"enforcement": "advisory",
"columns": [
"polymarket_market_id"
],
"max_null_ppm": 900000,
"description": "At least a tenth of the Kalshi markets should pair with a Polymarket market. An advisory failure records a pairing that stopped working without stopping the build."
},
{
"check_id": "markets_present",
"kind": "row_expectation",
"enforcement": "required",
"min_rows": 1
}
],
"units": [
{
"column": "close_gap_hours",
"unit": "h"
},
{
"column": "kalshi_rules_chars",
"unit": "{count}"
},
{
"column": "polymarket_rules_chars",
"unit": "{count}"
}
],
"timezone": "UTC"
}

Both sources pin json.tabular@1.1.0. The two shapes they read are not the same.

Kalshi answers one object with an array under /markets, so records_pointer is /markets and expand is empty. Polymarket answers a bare array of events, each holding its own array of markets, so records_pointer is the empty string and expand is ["/markets"]. Expanding /markets emits one row per nested market and repeats the event’s own fields on each row. Write a pointer under an expanded array as /markets/question, not /question.

Polymarket’s rules text is the description field. Its resolutionSource names the source it resolves against, and it often holds nothing.

A join needs a key that both sides carry. These two sides carry none, so the first two steps normalize each venue’s question text into one comparable string:

trim(regexp_replace(regexp_replace(regexp_replace(lower(coalesce(title,'')),
'\$?(\d+)k\b', '\1000', 'g'), '[^a-z0-9 ]', ' ', 'g'), ' +', ' ', 'g'))

Three passes. The first turns $100k into 100000, because one venue writes a strike in thousands and the other writes it out. The second replaces everything that is not a lowercase letter, a digit or a space. The third collapses runs of spaces.

best_pairs then scores every Kalshi market against every Polymarket market:

0.6 * jaro_winkler_similarity(k.title_norm, p.question_norm)
+ 0.4 * (case when k.strike_norm <> '' and (p.group_norm = k.strike_norm
or position(k.strike_norm in p.question_norm) > 0) then 1 else 0 end) as match_score

jaro_winkler_similarity returns DOUBLE, so the last step casts match_score to decimal(6,4). A decimal column refuses a floating result.

qualify row_number() over (partition by k.market_ticker order by match_score desc, p.market_id) = 1 keeps one Polymarket market per Kalshi ticker. The tiebreak on p.market_id matters. Without it the engine orders two candidates of identical score however it likes, and the sealed table stops replaying.

and match_score >= 0.6 drops a best pair that is still a bad pair. Every venue lists questions that read alike and settle differently.

from kalshi_markets k
left join best_pairs b on b.market_ticker = k.market_ticker

A left join keeps every open Kalshi market. An inner join answers a narrower question, “which Kalshi markets have a Polymarket counterpart”, with a table that looks like the whole series. match_tier reads unmatched where polymarket_market_id is null, so the count of pairs and the count of markets are both readable off the sealed table.

key_uniqueness on kalshi_market_ticker is the one that catches this shape’s failure. The pairing step is a cross join. Drop the qualify and every Kalshi market fans out into one row per Polymarket market. The table still looks like a table, every count computed from it is wrong, and nothing else in the document notices.

value_range on match_score between "0" and "1" catches a scoring expression whose weights stopped adding up. Numeric bounds travel as decimal strings, because a digested document carries no fractional JSON number.

some_matches is advisory. It records on the receipt that fewer than a tenth of the Kalshi markets paired, without failing the run. A required check that fails ends the run with failure_code: CHECK_FAILED and seals nothing. That is the wrong answer for a pairing that has degraded rather than broken.

close_gap_hours is h. The two character counts are {count}. The rules texts, the tickers and the tier have no unit. They stay out of units entirely rather than taking none.

units omits match_score too. Nothing in the unit vocabulary says what a weighted similarity measures.

Both addresses show their current state, and neither source declares a window. That makes this recipe resync-only. Studio refuses a scheduled refresh with RESYNC_REQUIRED before acquisition, so reading both venues again is an explicit resync. The finished table says what the venues listed at the last resync and nothing about last week.

To keep the history, declare window.snapshot on each source, as in Snapshot windows. The worker then stamps every decoded row with the day it acquired the source and keeps one partition per day. A reader can then see a rules text that moved. A snapshot window is resync-only as well, so each new day is a resync.

Terminal window
mr-data dataset create --name "Kalshi and Polymarket rate-cut markets, settlement compared" --json
mr-data recipe recipe.json --json
mr-data run --recipe RECIPE_ID --digest RECIPE_DIGEST --sample --max-rows 2000 --json
mr-data peek RUN_ID --json
mr-data checks RUN_ID --json
mr-data query RUN_ID "select match_tier, count(*) from run_table group by match_tier" --json
mr-data run --recipe RECIPE_ID --digest RECIPE_DIGEST --full --json

mr-data receipt RUN_ID --json carries one coverage entry per source with its bytes, bytes_fetched, digest, the Reader name and version, the decoded column list and rows_kept.

mr-data checks RUN_ID --json reads the check results out of the receipt. Each result carries check_id, evaluated, passed, scope and a detail sentence naming the number it measured. The live Kalshi and Polymarket dataset records them in this form:

one_row_per_kalshi_market key (kalshi_market_ticker) is distinct across all 6693 rows, as expected
enough_markets row count 6693 satisfies the declared minimum of 3000
some_matches column 'polymarket_market_id' is 817869 ppm null (5474 of 6693 rows),
at or below the declared ceiling of 900000 ppm

That dataset pairs 240 Kalshi series against 1,500 Polymarket events rather than the one series here. Of its 6,693 Kalshi markets, 135 pair exactly, 605 strongly, 479 weakly and 5,474 find no Polymarket counterpart scoring 0.6 or more.

checks.scope says which rows a pass covered. version means every row of the table the run composed.

The pairing step is a cross join, so its cost is the product of the two row counts. One Kalshi series against a hundred Polymarket events is small. Every Kalshi series against every Polymarket event is not, and the sample ceiling will not save it. A ceiling cuts rows at acquisition, and the engine forms the product afterwards.

A row ceiling cuts each source separately and the join then lies. Kalshi cut at 2,000 rows and Polymarket cut at 2,000 rows are two partial lists. Markets that do have a counterpart come back unmatched, and nothing on the row names the truncation. Measure both sources with mr-data probe, then set each ceiling above what it returns.

Use a bare cast on anything the join depends on and try_cast on the measurements. A try_cast that quietly returns null on a key turns a broken join into an empty one.

try_cast(concat(substr(close_time,1,19),'+00') as timestamp with time zone) is how both venues’ times become instants. Kalshi writes 2026-09-17T18:00:00Z and Polymarket writes an ISO timestamp with fractional seconds. Taking the first nineteen characters and appending the offset admits both. A bare cast to TIMESTAMP returns a zone-free value, and a column declared timestamp refuses it with TRANSFORM_COLUMN_TYPE_MISMATCH.

A step may name only its sources and earlier steps. The engine refuses a step_id equal to a source’s name rather than resolving it, because the two would name one relation.

The rules text is long. kalshi_rules and polymarket_rules carry whole paragraphs, so the per-source byte ceiling has to hold the text rather than the ticker list. This recipe allows 8 MiB for Kalshi and 32 MiB for Polymarket.