跳转到正文
工程指南

预测市场数据标准化:Polymarket & Kalshi

正常化 Polymarket 和 Kalshi 数据,而不混淆结果、价格或分辨率规则。 一个Node.js教程与一个可运行的示例和验证检查列表。

Gizmolab 团队 · 发表 · 16 分钟阅读

1. 在比较价格之前先将含义标准化

预测市场 数据正常化将特定场所的记录转换成一个常见的模型,同时保持解读它们所需的信息。 困难的部分不是重命名字段。 它确保两个记录提到相同的条件,结果方向,观察时间和分辨率条款,然后一个仪表板将他们的价格放在一边。

本教程构建了一个离线 Node.js 示例,用于摄入和验证。 它使用虚构的市场和明确定义的适配器记录,而不是直播Polymarket或Kalshi响应。 没有 API 键、网络请求、订单或 钱包 操作。 目标是一个小,可检测的边界,您可以在连接真正的源之前进行测试。

保留原始源效用负载或授权的档案引用,并在正常化的记录旁边。 一个有用的管道有三个层:原始场地响应、版本场地适配器和验证的通用模型。 当提供商更改一个字段或规则时,您可以识别哪个适配器产生了记录并重新处理它,而无需猜测旧值意味着什么。

2. 明确映射场地标识符并引用语义

Polymarket区分事件、可解决的市场和结果代币ID。 保存这些身份,而不是将事件标题缩小为全球市场密钥。 其价格文档还区分出显示的价格与订单簿中可用的报价和请求。 最后一个交易,中间点和可执行的报价必须在您的模型中保留不同的标签。

Kalshi 的当前订单簿文档描述了 orderbook_fp 中的固定点字符串对,包括 yes_dollars 和 no_dollars。 其固定点参考定义了价格和数量领域使用的单位。 保持源字符串通过分析。 不要将较旧的整位元假设复制到当前美元字符串字段的适配器中。

Kalshi 记录了互补的投标方如何暗示对其二进制订单簿的要求。 如果您的适配器以这种方式产生价格,请记录衍生和源侧。 不要将这种转变应用于任意的多结果市场,或将衍生请求与直接提供的报价混淆。 下面的示例接受已识别的报价类型,不重建订单簿。

关心具体地点来源共同模式规则
身份Polymarket 事件、条件和结果符号标识符名称空间市场和结果ID按地点;保存源引用
身份Kalshi事件和市场标记将事件组合与可交易市场分开
价格Polymarket 订单书订单/请求或显示相关的价格商店引用类型和结果方向明确
价格Kalshi 固定点美元字符串使用声明单位对数字符串进行分析;不要推断百分比
生命周期每个场所的状态和时机领域使用经过审查的映射;保留原始状态和未知值

3. 定义一个狭窄的规范模式

可下载的模块预计有文档化的适配器合同。 身份字段是非空字符串,包括大型数字外观的ID。 关键是场地、市场和结果的序列化混合,这可以避免通过连接不可逃的标识符与点击引起的碰撞。 永远不要将代币 ID 转换为 JavaScript 号码,仅仅因为它包含数字。

通过一个明确的规则将每个源结果绘制为 condition_true 或 condition_false。 Array position zero 不是 Yes 的通用同义词。 保持投标,请求,最后和中间点作为单独的报价类型。 此示例处理了指定的 $1 面值的二进制合约;它不支持篮子、可变支付或多结果转换。

分开源At,收到At,关闭At,条款观察At和解决At。 来源时间描述了报价;接收时间描述了摄入量;近期时间描述了交易边界;观察时间描述了被测量的事件。 一个市场可以在其结果知道之前停止交易。 预计分辨率时间,如果您的产品需要它,属于另一个可撤回字段,而不是取代实际分辨率时间。

  • 将未被识别的场所生命周期值绘制到明确未知状态,并警告进行审查。
  • 需要在这个边界上使用 UTC ISO 定时标记;在场地适配器中转换源格式和时区。
  • 在没有引用或源时刻印时保留 null。 不要把缺失的数据变成零价格。
  • 在构建实际适配器时,在紧凑示例模型之外存储原始场地标识符和规则引用。

4. 解析小数价格而不进行静默舍入

该示例使用 BigInt 算法将美元价格缩小为整数百万分之一,然后返回一个 JSON 安全字符串。 字符串“0.615”和“0.6150”都变成“615000”。 “61.5”的遗产或合成美分输入会产生相同的结果。 该cents 选项显示了明确的单位;它不是 Kalshi 的当前响应格式。

只接受未签名的十进制字符串或 null。 不能准确表示的指标符号、浮点数、范围外的价格和精确性被拒绝。 当值保持准确时,将接受额外的后续零。 这种政策迫使适配器所有者做出有意的准确性决定,而不是悄悄地改变报价。

这个尺度是一个教学选择,而不是声称每个场所或未来计划使用六个十字位。 单独验证当前供应商允许的价格范围、数量精度和领域语义。 价格,数量,量,概念和开放利息是不同的衡量;不要通过相同的函数运行它们,或将交易价格标记为独立校准的概率。

5.拒绝跨解析规则的错误匹配

类似的标题不证明同等的合同。 Polymarket 的解决文件解释了解决规则定义边缘案例的来源和处理。 保留这些规则的参考,并审查修改。 衡量官方发表的观察的市场可能与衡量初步阅读或其他来源的市场有显著的不同,即使两种标题都提到同一事件。

我们的合成注册表比较了审查的主题,预言,观察时间,分辨率来源,无规则和支付条款,以及加拿大结果方向。 序列必须包含其操作员:“大于100”不同于“至少100”。 差异产生拒绝原因,而不是模糊的信心分数,下游代码可能错误的批准。

被审查的国旗仅仅是教学场所。 在生产中,接受从经过审核的版本注册表的审查地图,具有审查员身份,源规则引用和更改历史记录。 文本平等不是语义证据:比较器检查了某人已经审查的结构化值。 恶意或错误的适配器不得宣称任意市场等同。

该例子还拒绝不同或缺少的近期作为保守的审查门户。 两个合约可以测量相同的事件,而在不同的时间关闭,所以这故意产生一些虚假负数。 将语义等同性和可比较的交易窗口作为独立的生产决策。 即使是经过批准的匹配也对执行可用性、费用、担保、流动性或盈利交易一无所知。

6. 运行离线标准化示例

使用下面的链接下载 prediction-market-normalization.mjs,将其本地保存,并使用目前支持的 Node.js 版本运行。 它没有包装依赖性。 该页面上印刷的完整源是由下载链接提供的相同文件,因此解释和可运行的文物保持一致。

这两个虚构的场所报告了相同的报价,但指定了不同的解决方案来源。 正常化成功,因为每个记录都是结构性有效的。 然后比较拒绝这对,因为它们不符合审查的匹配标准。 这种分离是有用的:有效的记录可能仍然不适合交叉收入比较。

运行下载的模块
node prediction-market-normalization.mjs
合成装置的预期输出
{
  "prices": ["615000", "615000"],
  "comparison": {
    "equivalent": false,
    "reasons": ["Different resolutionSource"]
  }
}

7. 将新鲜度和运输回收作为单独的检查

收到的消息不一定是最近的引用。 此示例的新鲜度函数使用明确的评估时间和最大年龄,标记缺少的源时间,并且需要时钟审查时,时刻印在未来或不一致。 这并不意味着新鲜的最后一个交易是一个可执行的请求。 单独应用报价类型、市场状态和规则审查门。

生产流也需要重新连接行为:在可用时保留最后一个被接受的序列或源检查点,检测漏洞,获得新鲜的快照,并根据场所的订单合同应用更新。 通过文档化源身份进行重复;不要假定时刻印件是独一无二的。 在恢复不完整时,标记饲料停留。

根据您的提供商权限和保留要求,保持限定的原始记录进行调试。 保持适配器版本,接收时间、验证失败和规则修订版本。 如果一个方案的变化下降了所需的字段,将其隔离并提高操作信号。 用零填写缺少的字段会隐藏一个破碎的管道,在一个看起来可信的图表后面。

8. 在连接仪表板之前验证边缘情况

可下载的代码由网站的自动测试执行。 这些案例对于自己的适配器也是有用的接受检查。 在连接直播 APIs 时添加真实的、允许的供应商设备和运输测试;单独的合成模块无法验证源可用性、终端访问或市场规则的解释。

输入或变更预期行为问题阻止了
零引用和真正的零保持两个不同的价值观缺少的数据显示为价格
十进制字符串在声明单位精确正常整数字符串美金/美元和浮点错误
过度精确或未知单元拒绝而不是圆形或猜测腐败的沉默引用
大市场或代币ID保持 String 身份数字精度损失
相反的结果方向拒绝比赛是 否 反转
更改的源、预兆、时间或无效规则拒绝等待审查的地图虚假交叉收入等价
关闭但未解决继续解决零早期结算声称
恒定的、未来的或缺席的源时间旗帜停留,钟表审查或失踪虚假新鲜度

9. 扩展适配器而不丢失来源

通过选择特定场所的 API 版本和小型市场集,开始真正的整合。 写一份描绘文档,标识每个 Canon 字段的源,其单位, null 行为和验证规则。 保持源量和订单书的深度在自己的精确的十进制结构,而不是添加未经验证的数字到价格记录。

接下来,添加生命周期测试、允许的历史记录和规则更改处理。 当源规则发生变化时,决定如何撤销经过审查的映射,以及消费者如何了解他们以前的比较不再被批准。 市场目录、价格源和规则注册表有不同的更新需求;使这些责任在设计中可见。

对于仪表板或警报产品,请在价格旁边显示场地、报价类型、源时间、新鲜度和规则审查状态。 如果接口崩溃了这些区别,正确的后端仍然可以产生误导性的比较。 从同一个验证的模型中构建显示和导出格式。

完整的可运行源代码

Prediction-market-normalization.mjs — 合成装置,无网络调用
// 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))
}

构建预测市场数据集成

Gizmolab 可帮助团队实现现场适配器、规范方案、饲料恢复和产品界面。 首先,您需要的场所、数据权利和接受标准。

探索 Predict API 集成