| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { isGeopoliticalMarket } from './_bet-templates-markets-classify.mjs'; |
|
|
| export const MARKET_FEED = 'prediction:markets-bootstrap:v1'; |
| export const MARKET_SETTLEMENT_FEED = 'prediction:markets-resolution:v1'; |
|
|
| const DAY_MS = 24 * 60 * 60 * 1000; |
| |
| |
| export const MARKET_MIN_VOLUME = 25_000; |
| |
| |
| const MIN_LEAD_MS = 2 * DAY_MS; |
| const MAX_HORIZON_MS = 45 * DAY_MS; |
| |
| export const MARKET_SLOT_COUNT = 6; |
|
|
| |
| |
| |
| export function marketSlugFromUrl(url) { |
| const text = String(url || ''); |
| const poly = text.match(/polymarket\.com\/event\/([^/?#]+)/); |
| if (poly) return { source: 'polymarket', slug: poly[1] }; |
| const kalshi = text.match(/kalshi\.com\/markets\/([^/?#]+)/); |
| if (kalshi) return { source: 'kalshi', slug: kalshi[1] }; |
| return null; |
| } |
|
|
| |
| |
| |
| |
| export function parseMarketRecord(record) { |
| if (!record || typeof record !== 'object') return null; |
| const title = String(record.title || '').trim(); |
| const yesPrice = Number(record.yesPrice); |
| const volume = Number(record.volume); |
| const endDateMs = Date.parse(record.endDate ?? ''); |
| const slugInfo = marketSlugFromUrl(record.url); |
| if (!title || !slugInfo) return null; |
| if (!Number.isFinite(yesPrice)) return null; |
| if (!Number.isFinite(volume) || volume < MARKET_MIN_VOLUME) return null; |
| if (!Number.isFinite(endDateMs)) return null; |
| return { title, yesPrice, volume, endDateMs, ...slugInfo }; |
| } |
|
|
| |
| |
| |
| |
| export function eligibleMarkets(feed, nowMs) { |
| const pools = [feed?.geopolitical, feed?.tech, feed?.finance].filter(Array.isArray); |
| const seen = new Set(); |
| const out = []; |
| for (const record of pools.flat()) { |
| const market = parseMarketRecord(record); |
| if (!market) continue; |
| if (isGeopoliticalMarket(market.title)) continue; |
| if (market.endDateMs < nowMs + MIN_LEAD_MS || market.endDateMs > nowMs + MAX_HORIZON_MS) continue; |
| if (seen.has(market.slug)) continue; |
| seen.add(market.slug); |
| out.push(market); |
| } |
| return out.sort((a, b) => b.volume - a.volume); |
| } |
|
|
| function buildSlotTemplate(slot) { |
| return { |
| id: `market:slot${slot}`, |
| feedKey: MARKET_FEED, |
| domain: 'market', |
|
|
| extractMetric(feed, ctx = {}) { |
| |
| |
| |
| |
| const nowMs = Number(feed?.fetchedAt) || Number(ctx.nowMs); |
| if (!Number.isFinite(nowMs)) return null; |
| const market = eligibleMarkets(feed, nowMs)[slot]; |
| if (!market) return null; |
| return { |
| subject: market.title, |
| value: market.yesPrice, |
| endDateMs: market.endDateMs, |
| slug: market.slug, |
| source: market.source, |
| volume: market.volume, |
| }; |
| }, |
|
|
| horizonPolicy({ metric }) { |
| return metric.endDateMs; |
| }, |
|
|
| buildResolutionSpec({ metric, deadlineMs }) { |
| return { |
| kind: 'hard', |
| |
| |
| |
| |
| |
| |
| |
| metricKey: `${MARKET_SETTLEMENT_FEED}|yesPrice(slug==${metric.slug})`, |
| operator: 'crosses', |
| threshold: 50, |
| baselineValue: round2(metric.value), |
| window: 'at-deadline', |
| deadline: deadlineMs, |
| sourceFeed: MARKET_SETTLEMENT_FEED, |
| question: normalizeQuestion(metric.subject), |
| }; |
| }, |
|
|
| buildQuestion({ spec }) { |
| return spec.question; |
| }, |
|
|
| buildId({ metric }) { |
| return `market:${metric.slug}`; |
| }, |
|
|
| decorate({ metric }) { |
| return { |
| marketSlug: metric.slug, |
| marketSource: metric.source, |
| calibration: { marketPrice: round2(metric.value), source: metric.source }, |
| }; |
| }, |
|
|
| userValueScore() { |
| return 0.8; |
| }, |
| }; |
| } |
|
|
| export const MARKET_BET_TEMPLATES = Array.from({ length: MARKET_SLOT_COUNT }, (_, i) => buildSlotTemplate(i)); |
|
|
| function normalizeQuestion(title) { |
| const text = String(title).trim(); |
| return /\?$/.test(text) ? text : `${text}?`; |
| } |
|
|
| function round2(value) { |
| if (!Number.isFinite(value)) return value; |
| return Math.round(value * 100) / 100; |
| } |
|
|