Aller au contenu
Guides d'ingénierie

Normalisation des données du marché de prévision : Polymarket et Kalshi

Normalisez les données Polymarket et Kalshi sans mélanger les résultats, les prix ou les règles de résolution. Un didacticiel Node.js avec un exemple exécutable et une liste de contrôle de validation.

Gizmolab Équipe · Publié · 16 min de lecture

1. Normaliser le sens avant de comparer les prix

La normalisation des données de marché de prédiction convertit les enregistrements spécifiques au lieu en un modèle commun tout en conservant les informations nécessaires à leur interprétation. Le plus difficile n'est pas de renommer les champs. Il s'agit de garantir que deux enregistrements font référence à la même condition, à la même direction de résultat, au même temps d'observation et aux mêmes conditions de résolution avant qu'un tableau de bord ne mette leurs prix côte à côte.

Ce didacticiel crée un exemple Node.js hors ligne pour l'ingestion et la validation. Il utilise des marchés fictifs et des enregistrements d'adaptateur explicitement définis, et non des réponses Polymarket ou Kalshi en direct. Il n'y a pas de clés API, de requêtes réseau, de commandes ou d'opérations de portefeuille. L'objectif est une petite limite inspectable que vous pouvez tester avant de connecter une source réelle.

Conservez la charge utile source originale ou une référence d’archives autorisée à côté de l’enregistrement normalisé. Un pipeline utile comporte trois couches : la réponse brute du site, un adaptateur de site versionné et un modèle commun validé. Lorsqu'un fournisseur modifie un champ ou une règle, vous pouvez identifier quel adaptateur a produit un enregistrement et le retraiter sans deviner la signification de l'ancienne valeur.

2. Cartographier les identifiants des lieux et citer explicitement la sémantique

Polymarket distingue les événements, les marchés résolvables et les ID de jeton de résultat. Préservez ces identités plutôt que de réduire le titre d’un événement à une clé de marché mondiale. Sa documentation sur les prix distingue également les prix affichés des cours acheteur et vendeur disponibles dans le carnet d'ordres. Une dernière transaction, un cours médian et un cours exécutable doivent conserver des étiquettes différentes dans votre modèle.

La documentation actuelle du carnet de commandes de Kalshi décrit les paires de chaînes à virgule fixe sous orderbook_fp, y compris yes_dollars et no_dollars. Sa référence à virgule fixe définit les unités utilisées par les champs de prix et de quantité. Conservez les chaînes source grâce à l'analyse. Ne copiez pas une ancienne hypothèse de cents entiers dans un adaptateur pour un champ de chaîne de dollars actuel.

Kalshi documente comment des offres complémentaires peuvent impliquer des demandes pour son carnet d'ordres binaire. Si votre adaptateur dérive un prix de cette façon, enregistrez la dérivation et le côté source. N'appliquez pas cette transformation à un marché arbitraire à résultats multiples et ne confondez pas une demande dérivée avec une cotation directement fournie. L'exemple ci-dessous accepte un type de cotation déjà identifié et ne reconstitue pas de carnet d'ordres.

PréoccupationSource spécifique au lieuRègle de modèle commun
IdentitéPolymarket identifiants d'événement, de condition et de jeton de résultatMarché des espaces de noms et identifiants de résultats par lieu ; préserver les références sources
IdentitéKalshi tickers d'événements et de marchésGardez le regroupement d'événements séparé du marché échangeable
PrixPolymarket cours acheteur/vendeur du carnet d'ordres ou prix lié à l'affichageStocker explicitement le type de devis et la direction du résultat
PrixKalshi chaînes de dollars à virgule fixeAnalyser les chaînes décimales en utilisant les unités déclarées ; ne pas en déduire des centimes
Cycle de vieLes champs de statut et de timing de chaque lieuUtiliser une cartographie révisée ; conserver le statut brut et les valeurs inconnues

3. Définir un schéma canonique étroit

Le module téléchargeable attend un contrat d'adaptateur documenté. Les champs d'identité sont des chaînes non vides, comprenant de grands identifiants d'apparence numérique. La clé est un tuple sérialisé de lieu, de marché et de résultat, qui évite les collisions causées par la jonction d'identifiants non échappés avec de la ponctuation. Ne convertissez jamais un ID de jeton en numéro JavaScript simplement parce qu'il contient des chiffres.

Mappez chaque résultat source à condition_true ou condition_false via une règle explicite. La position zéro du tableau n’est pas un synonyme universel de Oui. Conservez l'offre, la demande, le dernier et le milieu comme types de devis distincts. Cet exemple gère les contrats binaires avec une valeur nominale déclarée $1 ; il ne prend pas en charge les paniers, les paiements variables ou les transformations à résultats multiples.

Séparez sourceAt, reçuAt, closeAt, les termes observationAt etsolvingAt. L'heure source décrit la citation ; l'heure de réception décrit l'ingestion ; l'heure de fermeture décrit la frontière commerciale ; le temps d’observation décrit l’événement mesuré. Un marché peut cesser de négocier avant que son résultat ne soit connu. Le temps de résolution attendu, si votre produit en a besoin, appartient à un autre champ nullable plutôt que de remplacer le temps de résolution réel.

  • Mappez les valeurs de cycle de vie d’un site non reconnues à un état inconnu explicite et alertez pour examen.
  • Exiger des horodatages canoniques UTC ISO à cette limite ; convertir les formats source et les fuseaux horaires dans l'adaptateur de lieu.
  • Conserver null lorsqu'un devis ou un horodatage source est absent. Ne transformez pas les données manquantes en un prix nul.
  • Stockez les identifiants de lieu bruts et les références de règles en dehors de l'exemple de modèle compact lors de la création du véritable adaptateur.

4. Analyser les prix décimaux sans arrondi silencieux

L'exemple met à l'échelle un prix en dollars en millionièmes entiers à l'aide de l'arithmétique BigInt, puis renvoie une chaîne sécurisée JSON. Les chaînes « 0,615 » et « 0,6150 » deviennent toutes deux « 615000 ». Une entrée de centimes hérités ou synthétiques déclarée séparément de « 61,5 » produit le même résultat. Cette option en cents démontre des unités explicites ; il n’est pas présenté comme le format de réponse actuel de Kalshi.

Seules les chaînes décimales non signées ou null sont acceptées. La notation des exposants, les nombres à virgule flottante, les prix hors plage et la précision qui ne peuvent pas être représentées exactement sont rejetées. Des zéros de fin supplémentaires sont acceptés lorsque la valeur reste exacte. Cette politique oblige le propriétaire de l’adaptateur à prendre une décision délibérée et précise au lieu de modifier discrètement un devis.

L'échelle est un choix pédagogique et non une affirmation selon laquelle chaque lieu ou schéma futur utilise six décimales. Validez séparément les intervalles de prix autorisés, la précision de la quantité et la sémantique des champs du fournisseur actuel. Le prix, la quantité, le volume, l’intérêt notionnel et ouvert sont des mesures différentes ; ne les exécutez pas tous via la même fonction et ne qualifiez pas un prix négocié de probabilité calibrée indépendamment.

5. Rejetez les fausses correspondances dans les règles de résolution

Des titres similaires ne prouvent pas des contrats équivalents. La documentation de résolution de Polymarket explique que les règles de résolution définissent la source et le traitement des cas extrêmes. Conserver une référence à ces règles et revoir les modifications. Un marché mesurant une observation officielle publiée peut différer sensiblement d'un marché mesurant une lecture préliminaire ou une autre source, même si les deux titres mentionnent le même événement.

Notre registre synthétique compare un sujet examiné, un prédicat, un temps d'observation, une source de résolution, une règle d'annulation et des conditions de paiement, ainsi que l'orientation canonique du résultat. Le prédicat doit inclure son opérateur : « supérieur à 100 » diffère de « au moins 100 ». Les différences produisent des raisons de rejet plutôt qu’un score de confiance flou que le code en aval pourrait prendre pour une approbation.

Le drapeau révisé n’est qu’un espace réservé à l’enseignement. En production, acceptez les mappages révisés à partir d’un registre authentifié et versionné avec l’identité du réviseur, les références aux règles sources et l’historique des modifications. L'égalité du texte n'est pas une preuve sémantique : le comparateur vérifie les valeurs structurées que quelqu'un a déjà examinées. Un adaptateur malveillant ou erroné ne doit pas être autorisé à déclarer des marchés arbitraires équivalents.

L’exemple rejette également les heures de fermeture différentes ou manquantes en tant que point de contrôle conservateur. Deux contrats peuvent mesurer le même événement tout en se clôturant à des moments différents, ce qui crée intentionnellement des faux négatifs. Conservez l’équivalence sémantique et les fenêtres commerciales comparables en tant que décisions de production distinctes. Même une correspondance approuvée ne dit rien sur la disponibilité de l'exécution, les frais, les garanties, la liquidité ou une transaction rentable.

6. Exécutez l'exemple de normalisation hors ligne

Téléchargez prédiction-market-normalization.mjs en utilisant le lien ci-dessous, enregistrez-le localement et exécutez-le avec une version Node.js actuellement prise en charge. Il n’a aucune dépendance de package. La source complète imprimée sur cette page est le même fichier servi par le lien de téléchargement, donc l'explication et l'artefact exécutable restent alignés.

Les deux sites fictifs rapportent des prix d'offre égaux mais spécifient des sources de résolution différentes. La normalisation réussit parce que chaque enregistrement est structurellement valide. La comparaison rejette ensuite la paire car elle ne répond pas aux critères de correspondance examinés. Cette séparation est utile : un enregistrement valide peut toujours ne pas convenir à une comparaison entre sites.

Exécutez le module téléchargé
node prediction-market-normalization.mjs
Résultat attendu des luminaires synthétiques
{
  "prices": ["615000", "615000"],
  "comparison": {
    "equivalent": false,
    "reasons": ["Different resolutionSource"]
  }
}

7. Traitez la fraîcheur et la récupération lors du transport comme des contrôles distincts

Un message reçu n'est pas nécessairement un devis récent. La fonction de fraîcheur de l'exemple utilise une heure d'évaluation explicite et un âge maximum, signale l'heure source manquante et nécessite une révision de l'horloge lorsque les horodatages sont futurs ou incohérents. Cela ne signifie pas qu'une nouvelle dernière transaction est une demande exécutable. Appliquez séparément les portes de type de cotation, de statut de marché et de révision des règles.

Un flux de production doit également adopter un comportement de reconnexion : conserver la dernière séquence acceptée ou le dernier point de contrôle source lorsqu'il est disponible, détecter les lacunes, obtenir un nouvel instantané et appliquer les mises à jour conformément au contrat de commande du lieu. Dédoublonner par une identité source documentée ; ne présumez pas que les horodatages sont uniques. Marquez le flux périmé alors que la récupération est incomplète.

Conservez des enregistrements bruts délimités pour le débogage en fonction des autorisations de votre fournisseur et des exigences de conservation. Conserver la version de l'adaptateur, l'heure de réception, les échecs de validation et la version de révision des règles. Si une modification de schéma supprime un champ obligatoire, mettez-le en quarantaine et émettez un signal opérationnel. Remplir le champ manquant avec zéro cacherait un pipeline cassé derrière un graphique d'apparence plausible.

8. Validez les cas extrêmes avant de connecter un tableau de bord

Le code téléchargeable est exercé par les tests automatisés du site Internet. Ces cas constituent également des contrôles d’acceptation utiles pour votre propre adaptateur. Ajoutez des appareils de fournisseur réels et autorisés et des tests de transport lors de la connexion en direct APIs ; le module synthétique ne peut à lui seul vérifier la disponibilité des sources, l'accès aux points finaux ou l'interprétation des règles du marché.

Saisie ou modificationComportement attenduProblème qu'il évite
Cotation nulle et zéro réelConserver deux valeurs différentesDonnées manquantes affichées sous forme de prix
Chaînes décimales en unités déclaréesChaîne entière normalisée exacteCents/dollars et erreurs à virgule flottante
Excès de précision ou unités inconnuesRejeter plutôt que d'arrondir ou de devinerCorruption de citation silencieuse
Grand marché ou identifiant de jetonPréserver l'identité de la chaînePerte de précision des nombres
Direction de résultat opposéeRejeter le matchInversion oui/non
Modification de la source, du prédicat, de l'heure ou de la règle d'annulationRejeter le mappage révisé en attenteFausse équivalence entre sites
Fermé mais non résoluGarder résolu à nullRéclamations à règlement prématuré
Heure source périmée, future ou absenteDrapeau obsolète, en révision ou manquantFraîcheur trompeuse

9. Prolongez l'adaptateur sans perdre sa provenance

Démarrez une véritable intégration en choisissant une version de lieu spécifique API et un petit ensemble de marchés. Rédigez un document de cartographie qui identifie la source de chaque champ canonique, ses unités, son comportement nul et sa règle de validation. Conservez les quantités d'origine et la profondeur du carnet de commandes dans leurs propres structures décimales exactes plutôt que d'ajouter des nombres non validés à l'enregistrement des prix.

Ensuite, ajoutez des tests de cycle de vie, des appareils historiques autorisés et une gestion des modifications de règles. Décidez comment un mappage révisé est révoqué lorsque les règles source changent et comment les consommateurs apprennent que leur comparaison précédente n'est plus approuvée. Un annuaire de marché, un flux de prix et un registre de règles ont des besoins d'actualisation différents ; rendre ces responsabilités visibles dans la conception.

Pour un tableau de bord ou un produit d'alerte, affichez le lieu, le type de devis, l'heure de la source, la fraîcheur et l'état de révision des règles à côté du prix. Si l’interface supprime ces distinctions, un backend correct peut toujours produire une comparaison trompeuse. Construisez le format d’affichage et d’export à partir du même modèle validé.

Source exécutable complète

prédiction-market-normalization.mjs — montages synthétiques, pas d'appels réseau
// 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))
}

Construire une intégration de données de marché de prédiction

Gizmolab aide les équipes à définir les adaptateurs de site, les schémas canoniques, la récupération de flux et les interfaces de produits. Commencez par les lieux requis, les droits en matière de données et les critères d'acceptation.

Explorer l'intégration de Predict API →