// Energy bet templates (Phase 1 pilot / #5233 re-engine). // // Source feed: energy:eia-petroleum:v1, verified live shape // { wti, brent, production, inventory } each { current, previous, date, unit } // Each metric becomes a directional "will the latest move continue?" bet with a // concrete, resolvable threshold and a hard #4976 spec that reads back through // the resolver as `energy:eia-petroleum:v1|value(metric==)`. // // Pure: templates are declarative; generateBets (in _bet-templates.mjs) drives // them with an injected feed snapshot + nowMs. export const EIA_PETROLEUM_FEED = 'energy:eia-petroleum:v1'; const DAY_MS = 24 * 60 * 60 * 1000; // Keep a weekly question horizon. Resolution has a separate, feed-aware grace // because the report's observation date can trail its publication date. const EIA_HORIZON_MS = 7 * DAY_MS; // Floor the threshold move so a flat week (current==previous) still yields a // non-trivial bet instead of "value >= current" (~coin-flip, uninformative). const MIN_MOVE_FRACTION = 0.005; // EIA petroleum figures are reported in thousand barrels (stocks) / thousand // barrels per day (production); the feed leaves `unit` empty, so these // fallbacks must match the raw magnitude (e.g. 411357 = 411M bbl = 411357 kbbl) // — a fabricated "Mbbl" here would make the question absurd. const METRICS = [ { name: 'inventory', subject: 'US commercial crude oil inventories', fallbackUnit: 'kbbl' }, { name: 'production', subject: 'US crude oil production', fallbackUnit: 'kbbl/d' }, { name: 'wti', subject: 'the WTI crude oil price', fallbackUnit: 'USD/bbl' }, { name: 'brent', subject: 'the Brent crude oil price', fallbackUnit: 'USD/bbl' }, ]; function buildMetricTemplate({ name, subject, fallbackUnit }) { return { id: `energy:eia-${name}`, feedKey: EIA_PETROLEUM_FEED, domain: 'energy', extractMetric(feed) { const m = feed?.[name]; const current = Number(m?.current); // EIA stocks/production/prices are strictly positive; a 0/negative // reading is a broken feed. Skip it — otherwise a zero baseline yields // magnitude 0 → threshold==baseline → `value >= 0` → a guaranteed-YES bet. if (!m || !Number.isFinite(current) || current <= 0) return null; const previous = Number(m?.previous); return { subject, metricName: name, value: current, previous: Number.isFinite(previous) ? previous : null, unit: m.unit || fallbackUnit, asOf: m.date || null, }; }, horizonPolicy({ nowMs }) { return nowMs + EIA_HORIZON_MS; }, buildResolutionSpec({ metric, deadlineMs }) { const { value, previous } = metric; const lastMove = Number.isFinite(previous) ? value - previous : 0; const floor = Math.abs(value) * MIN_MOVE_FRACTION; const magnitude = Math.max(Math.abs(lastMove), floor); // Defense-in-depth against a degenerate (threshold==baseline) bet: a // non-positive magnitude has no resolvable YES criterion, so emit no bet. if (!(magnitude > 0)) return null; // Direction = continuation of the latest observed move (default up on a flat week). const wantUp = lastMove >= 0; const threshold = round(wantUp ? value + magnitude : value - magnitude); // operator 'crosses' is the resolver's direction-aware comparator: with // baselineValue<=threshold it resolves YES on value>=threshold, and with // baselineValue>threshold on value<=threshold — so the up/down direction // is carried by threshold-vs-baseline, matching the existing spec vocab. return { kind: 'hard', metricKey: `${EIA_PETROLEUM_FEED}|value(metric==${name})`, operator: 'crosses', threshold, baselineValue: round(value), // Read the metric AS OF the deadline (the resolver samples at/after it, // or falls back to the feed snapshot when resolving post-deadline). window: 'at-deadline', deadline: deadlineMs, sourceFeed: EIA_PETROLEUM_FEED, question: buildQuestion(metric, wantUp, threshold, deadlineMs), }; }, buildQuestion({ spec }) { return spec.question; }, buildTitle({ metric, spec }) { const dir = spec.threshold >= spec.baselineValue ? 'rise' : 'fall'; return `${capitalize(metric.subject)}: ${dir} to ${spec.threshold} ${metric.unit}?`; }, userValueScore() { // Prices are higher-interest than raw stock levels; nudge accordingly. return name === 'wti' || name === 'brent' ? 0.75 : 0.6; }, }; } function buildQuestion(metric, wantUp, threshold, deadlineMs) { const dir = wantUp ? 'rise to at least' : 'fall to at most'; return `Will ${metric.subject} ${dir} ${threshold} ${metric.unit} by ${isoDate(deadlineMs)}?`; } export const ENERGY_BET_TEMPLATES = METRICS.map(buildMetricTemplate); function isoDate(ms) { return new Date(ms).toISOString().slice(0, 10); } function capitalize(text) { const s = String(text || ''); return s.charAt(0).toUpperCase() + s.slice(1); } function round(value) { if (!Number.isFinite(value)) return value; return Math.round(value * 1_000_000) / 1_000_000; }