Prediction Market Data Normalization: Polymarket & Kalshi
Normalize Polymarket and Kalshi data without mixing outcomes, prices or resolution rules. A Node.js tutorial with a runnable example and validation checklist.
Gizmolab Team · Published · 16 min read
In this tutorial
- 1. Normalize meaning before comparing prices
- 2. Map venue identifiers and quote semantics explicitly
- 3. Define a narrow canonical schema
- 4. Parse decimal prices without silent rounding
- 5. Reject false matches across resolution rules
- 6. Run the offline normalization example
- 7. Treat freshness and transport recovery as separate checks
- 8. Validate edge cases before connecting a dashboard
- 9. Extend the adapter without losing provenance
- Complete runnable source
1. Normalize meaning before comparing prices
Prediction market data normalization converts venue-specific records into a common model while retaining the information needed to interpret them. The difficult part is not renaming fields. It is ensuring that two records refer to the same condition, outcome direction, observation time and resolution terms before a dashboard puts their prices side by side.
This tutorial builds an offline Node.js example for ingestion and validation. It uses fictional markets and explicitly defined adapter records, not live Polymarket or Kalshi responses. There are no API keys, network requests, orders or wallet operations. The goal is a small, inspectable boundary you can test before connecting a real source.
Keep the original source payload or an authorized archival reference alongside the normalized record. A useful pipeline has three layers: the raw venue response, a versioned venue adapter, and a validated common model. When a provider changes a field or rule, you can identify which adapter produced a record and reprocess it without guessing what the old value meant.
2. Map venue identifiers and quote semantics explicitly
Polymarket distinguishes events, resolvable markets and outcome token IDs. Preserve those identities rather than reducing an event title to a global market key. Its price documentation also distinguishes displayed prices from the bid and ask available in the order book. A last trade, midpoint and executable quote must retain different labels in your model.
Kalshi’s current order-book documentation describes fixed-point string pairs under orderbook_fp, including yes_dollars and no_dollars. Its fixed-point reference defines the units used by price and quantity fields. Keep the source strings through parsing. Do not copy an older integer-cents assumption into an adapter for a current dollar-string field.
Kalshi documents how complementary bid sides can imply asks for its binary order book. If your adapter derives a price that way, record the derivation and source side. Do not apply that transformation to an arbitrary multi-outcome market or confuse a derived ask with a directly supplied quote. The example below accepts an already identified quote kind and does not reconstruct an order book.
| Concern | Venue-specific source | Common-model rule |
|---|---|---|
| Identity | Polymarket event, condition and outcome-token identifiers | Namespace market and outcome IDs by venue; preserve source references |
| Identity | Kalshi event and market tickers | Keep event grouping separate from the tradable market |
| Price | Polymarket order-book bid/ask or display-related price | Store quote kind and outcome direction explicitly |
| Price | Kalshi fixed-point dollar strings | Parse decimal strings using declared units; do not infer cents |
| Lifecycle | Each venue’s status and timing fields | Use a reviewed mapping; retain the raw status and unknown values |
3. Define a narrow canonical schema
The downloadable module expects a documented adapter contract. Identity fields are nonempty strings, including large numerical-looking IDs. The key is a serialized tuple of venue, market and outcome, which avoids collisions caused by joining unescaped identifiers with punctuation. Never convert a token ID to a JavaScript Number merely because it contains digits.
Map each source outcome to condition_true or condition_false through an explicit rule. Array position zero is not a universal synonym for Yes. Keep bid, ask, last and midpoint as separate quote kinds. This example handles binary contracts with a stated $1 face value; it does not support baskets, variable payouts or multi-outcome transformations.
Separate sourceAt, receivedAt, closeAt, the terms’ observationAt and resolvedAt. Source time describes the quote; receipt time describes ingestion; close time describes the trading boundary; observation time describes the event being measured. A market can stop trading before its result is known. Expected resolution time, if your product needs it, belongs in another nullable field rather than replacing actual resolution time.
- Map unrecognized venue lifecycle values to an explicit unknown state and alert for review.
- Require canonical UTC ISO timestamps at this boundary; convert source formats and time zones in the venue adapter.
- Preserve null when a quote or source timestamp is absent. Do not turn missing data into a zero price.
- Store raw venue identifiers and rule references outside the compact example model when building the real adapter.
4. Parse decimal prices without silent rounding
The example scales a dollar price into integer millionths using BigInt arithmetic, then returns a JSON-safe string. The strings “0.615” and “0.6150” both become “615000”. A separately declared legacy or synthetic cents input of “61.5” produces the same result. That cents option demonstrates explicit units; it is not presented as Kalshi’s current response format.
Only unsigned decimal strings or null are accepted. Exponent notation, floating-point numbers, out-of-range prices and precision that cannot be represented exactly are rejected. Additional trailing zeros are accepted when the value remains exact. This policy forces the adapter owner to make a deliberate precision decision instead of quietly changing a quote.
The scale is a teaching choice, not a claim that every venue or future schema uses six decimal places. Validate the current provider’s allowed price intervals, quantity precision and field semantics separately. Price, quantity, volume, notional and open interest are different measures; do not run all of them through the same function or label a traded price as an independently calibrated probability.
5. Reject false matches across resolution rules
Similar titles do not prove equivalent contracts. Polymarket’s resolution documentation explains that resolution rules define the source and handling of edge cases. Preserve a reference to those rules and review amendments. A market measuring an official published observation can differ materially from one measuring a preliminary reading or another source, even if both titles mention the same event.
Our synthetic registry compares a reviewed subject, predicate, observation time, resolution source, void rule and payout terms, plus the canonical outcome direction. The predicate must include its operator: “greater than 100” differs from “at least 100”. Differences produce rejection reasons rather than a fuzzy confidence score that downstream code might mistake for approval.
The reviewed flag is only a teaching placeholder. In production, accept reviewed mappings from an authenticated, versioned registry with reviewer identity, source rule references and change history. Text equality is not semantic proof: the comparator checks structured values that someone has already reviewed. A malicious or mistaken adapter must not be allowed to declare arbitrary markets equivalent.
The example also rejects differing or missing close times as a conservative review gate. Two contracts can measure the same event while closing at different times, so this intentionally creates some false negatives. Keep semantic equivalence and comparable trading windows as separate production decisions. Even an approved match says nothing about execution availability, fees, collateral, liquidity or a profitable trade.
6. Run the offline normalization example
Download prediction-market-normalization.mjs using the link below, save it locally and run it with a currently supported Node.js release. It has no package dependencies. The complete source printed on this page is the same file served by the download link, so the explanation and runnable artifact stay aligned.
The two fictional venues report equal bid prices but specify different resolution sources. Normalization succeeds because each record is structurally valid. Comparison then rejects the pair because they do not meet the reviewed matching criteria. This separation is useful: a valid record can still be unsuitable for a cross-venue comparison.
node prediction-market-normalization.mjs{
"prices": ["615000", "615000"],
"comparison": {
"equivalent": false,
"reasons": ["Different resolutionSource"]
}
}7. Treat freshness and transport recovery as separate checks
A received message is not necessarily a recent quote. The example’s freshness function uses an explicit evaluation time and maximum age, flags missing source time, and requires clock review when timestamps are in the future or inconsistent. It does not infer that a fresh last trade is an executable ask. Apply quote-kind, market-status and rule-review gates separately.
A production stream also needs reconnect behavior: keep the last accepted sequence or source checkpoint when available, detect gaps, obtain a fresh snapshot and apply updates according to the venue’s ordering contract. Deduplicate by a documented source identity; do not assume timestamps are unique. Mark the feed stale while recovery is incomplete.
Keep bounded raw records for debugging according to your provider permissions and retention requirements. Persist adapter version, receive time, validation failures and rule-review version. If a schema change drops a required field, quarantine it and raise an operational signal. Filling the missing field with zero would hide a broken pipeline behind a plausible-looking chart.
8. Validate edge cases before connecting a dashboard
The downloadable code is exercised by the website’s automated tests. These cases are useful acceptance checks for your own adapter too. Add real, permissioned provider fixtures and transport tests when connecting live APIs; the synthetic module alone cannot verify source availability, endpoint access or market-rule interpretation.
| Input or change | Expected behavior | Problem it prevents |
|---|---|---|
| Null quote and real zero | Preserve two different values | Missing data shown as a price |
| Decimal strings in declared units | Exact normalized integer string | Cents/dollars and floating-point errors |
| Excess precision or unknown units | Reject rather than round or guess | Silent quote corruption |
| Large market or token ID | Preserve string identity | Number precision loss |
| Opposite outcome direction | Reject the match | Yes/No inversion |
| Changed source, predicate, time or void rule | Reject pending reviewed mapping | False cross-venue equivalence |
| Closed but unresolved | Keep resolvedAt null | Premature settlement claims |
| Stale, future or absent source time | Flag stale, clock-review or missing | Misleading freshness |
9. Extend the adapter without losing provenance
Start a real integration by choosing a specific venue API version and a small market set. Write a mapping document that identifies the source for every canonical field, its units, null behavior and validation rule. Keep source quantities and order-book depth in their own exact-decimal structures rather than adding unvalidated numbers to the price record.
Next, add lifecycle tests, permissioned historical fixtures and rules-change handling. Decide how a reviewed mapping is revoked when source rules change and how consumers learn that their previous comparison is no longer approved. A market directory, a price feed and a rule registry have different refresh needs; make those responsibilities visible in the design.
For a dashboard or alerting product, show the venue, quote kind, source time, freshness and rule-review state alongside the price. If the interface collapses those distinctions, a correct backend can still produce a misleading comparison. Build the display and export format from the same validated model.
Complete runnable source
// Offline teaching example. These are synthetic adapter records, NOT venue API responses.
// Run with Node.js: node prediction-market-normalization.mjs
import { pathToFileURL } from 'node:url'
function nonempty(value, name) {
if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${name} must be a nonempty string`)
return value
}
function timestamp(value, name, nullable = false) {
if (nullable && value === null) return null
nonempty(value, name)
const parsed = new Date(value)
if (!Number.isFinite(parsed.valueOf()) || parsed.toISOString() !== value) {
throw new TypeError(`${name} must be canonical UTC ISO, including milliseconds`)
}
return value
}
// Price per $1-face-value contract, scaled to millionths; no binary float arithmetic.
// 'cents' is an explicit legacy/synthetic adapter unit, not an assumed live API format.
export function normalizePrice(value, unit) {
if (!['dollars', 'cents'].includes(unit)) throw new TypeError('Unknown price unit')
if (value === null) return null // Missing quote is different from a real zero price.
if (typeof value !== 'string' || !/^\d+(\.\d+)?$/.test(value) || value.length > 80) {
throw new TypeError('Price must be a bounded unsigned decimal string or null')
}
const [whole, fraction = ''] = value.split('.')
const numerator = BigInt(whole + fraction) * 1_000_000n
const denominator = 10n ** BigInt(fraction.length) * (unit === 'cents' ? 100n : 1n)
if (numerator > denominator * 1_000_000n) throw new RangeError('Price exceeds $1 face value')
if (numerator % denominator !== 0n) throw new RangeError('Price needs more than six decimal places')
return (numerator / denominator).toString()
}
export function normalizeRecord(input) {
const venue = nonempty(input.venue, 'venue')
const marketId = nonempty(input.marketId, 'marketId')
const outcomeId = nonempty(input.outcomeId, 'outcomeId')
if (!['condition_true', 'condition_false'].includes(input.canonicalOutcome)) throw new TypeError('Explicit outcome mapping required')
if (!['bid', 'ask', 'last', 'midpoint'].includes(input.quoteKind)) throw new TypeError('Explicit quote kind required')
if (!['open', 'closed', 'resolved', 'unknown'].includes(input.status)) throw new TypeError('Explicit adapter status required')
return {
key: JSON.stringify([venue, marketId, outcomeId]), // No collisions from punctuation in IDs.
venue, marketId, outcomeId,
canonicalOutcome: input.canonicalOutcome,
quoteKind: input.quoteKind,
priceMicros: normalizePrice(input.price, input.priceUnit),
sourceAt: timestamp(input.sourceAt, 'sourceAt', true),
receivedAt: timestamp(input.receivedAt, 'receivedAt'),
closeAt: timestamp(input.closeAt, 'closeAt', true),
resolvedAt: timestamp(input.resolvedAt, 'resolvedAt', true),
status: input.status,
rawStatus: nonempty(input.rawStatus, 'rawStatus'),
// A teaching flag cannot replace an authenticated, versioned rule-review registry.
terms: input.terms ? { ...input.terms } : null,
}
}
const TERM_FIELDS = ['subject', 'predicate', 'observationAt', 'resolutionSource', 'voidRule', 'payoutTerms']
export function compareContracts(left, right) {
const reasons = []
if (left.canonicalOutcome !== right.canonicalOutcome) reasons.push('Different outcome mapping')
for (const [label, record] of [['left', left], ['right', right]]) {
if (record.terms?.reviewed !== true) reasons.push(`${label}: terms have not been reviewed`)
for (const field of TERM_FIELDS) {
if (typeof record.terms?.[field] !== 'string' || !record.terms[field].trim()) reasons.push(`${label}: missing ${field}`)
}
}
for (const field of TERM_FIELDS) {
if (left.terms?.[field] !== right.terms?.[field]) reasons.push(`Different ${field}`)
}
// Conservative gate: differing close times require review even if the event is the same.
if (left.closeAt === null || right.closeAt === null || left.closeAt !== right.closeAt) reasons.push('Close times require review')
return { equivalent: reasons.length === 0, reasons }
}
export function quoteFreshness(record, now, maxAgeMs) {
timestamp(now, 'now')
if (!Number.isSafeInteger(maxAgeMs) || maxAgeMs < 0) throw new TypeError('Invalid maximum age')
if (record.priceMicros === null || record.sourceAt === null) return 'missing'
const received = Date.parse(record.receivedAt)
const source = Date.parse(record.sourceAt)
const current = Date.parse(now)
if (source > current || received > current || source > received) return 'clock-review'
return current - source > maxAgeMs ? 'stale' : 'fresh'
}
export function demoRecords() {
const common = {
marketId: 'demo-market-900719925474099312345', outcomeId: 'demo-yes',
canonicalOutcome: 'condition_true', quoteKind: 'bid', priceUnit: 'dollars',
sourceAt: '2026-10-01T12:00:00.000Z', receivedAt: '2026-10-01T12:00:01.000Z',
closeAt: '2026-10-30T23:00:00.000Z', resolvedAt: null, status: 'open', rawStatus: 'adapter-demo-open',
terms: { reviewed: true, subject: 'Fictional Example Index', predicate: 'index > 100',
observationAt: '2026-10-31T00:00:00.000Z', resolutionSource: 'Example Authority A',
voidRule: 'void if official observation is unavailable', payoutTerms: 'USD 1 per winning contract' },
}
return [
normalizeRecord({ ...common, venue: 'demo-venue-a', price: '0.615' }),
normalizeRecord({ ...common, venue: 'demo-venue-b', price: '0.6150',
terms: { ...common.terms, resolutionSource: 'Example Authority B' } }),
]
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const [left, right] = demoRecords()
console.log(JSON.stringify({ prices: [left.priceMicros, right.priceMicros], comparison: compareContracts(left, right) }, null, 2))
}
Build a prediction market data integration
Gizmolab helps teams scope venue adapters, canonical schemas, feed recovery and product interfaces. Start with your required venues, data rights and acceptance criteria.
Explore Predict API integration →