Two venues, one question
Pair the markets two prediction venues list for the same question, and read both settlement rule texts side by side.
The question
Section titled “The question”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?
The sources
Section titled “The sources”| 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.
The recipe
Section titled “The recipe”{ "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"}Part by part
Section titled “Part by part”Two Reader pins, two record shapes
Section titled “Two Reader pins, two record shapes”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.
The key the recipe builds
Section titled “The key the recipe builds”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_scorejaro_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.
The join keeps the unmatched rows
Section titled “The join keeps the unmatched rows”from kalshi_markets kleft join best_pairs b on b.market_ticker = k.market_tickerA 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.
Checks
Section titled “Checks”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.
Cadence
Section titled “Cadence”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.
Register, run, inspect
Section titled “Register, run, inspect”mr-data dataset create --name "Kalshi and Polymarket rate-cut markets, settlement compared" --jsonmr-data recipe recipe.json --json
mr-data run --recipe RECIPE_ID --digest RECIPE_DIGEST --sample --max-rows 2000 --jsonmr-data peek RUN_ID --jsonmr-data checks RUN_ID --jsonmr-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 --jsonWhat the run reports
Section titled “What the run reports”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 expectedenough_markets row count 6693 satisfies the declared minimum of 3000some_matches column 'polymarket_market_id' is 817869 ppm null (5474 of 6693 rows), at or below the declared ceiling of 900000 ppmThat 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.