File size: 8,158 Bytes
ee888e1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 | // Prediction-market settlement loader for the forecast resolver (#5525 KTD2).
//
// The bootstrap feed only ever contains open markets, so due market bets query
// their venue for an adjudicated outcome and append it to the dedicated
// settlement feed. Live I/O remains injectable so the parser, failure, dedupe,
// and health-meta paths stay hermetic in tests.
import { CHROME_UA } from './_seed-utils.mjs';
import { unwrapEnvelope } from './_seed-envelope-source.mjs';
import { MARKET_SETTLEMENT_FEED_KEY } from './_forecast-resolution-eval.mjs';
const GAMMA_SETTLEMENT_BASE = 'https://gamma-api.polymarket.com';
const KALSHI_SETTLEMENT_BASE = 'https://api.elections.kalshi.com/trade-api/v2';
const SETTLEMENT_TTL_SECONDS = 45 * 24 * 60 * 60;
const SETTLEMENT_FETCH_CAP_PER_RUN = 10;
// Health-monitoring companion for the settlement feed (AGENTS.md: Redis seed
// scripts MUST write seed-meta:<key>). Registered in api/health.js SEED_META.
export const MARKET_SETTLEMENT_META_KEY = 'seed-meta:prediction:markets-resolution';
// Pure: extract a settled yesPrice (0-100) from a Gamma events-by-slug reply.
// Returns null while unsettled/ambiguous — never a guess. A multi-market event
// whose children don't title-match the bet is ambiguous: settling on the first
// closed child would grade the bet with another market's outcome.
export function parseGammaSettlement(eventsJson, title) {
const events = Array.isArray(eventsJson) ? eventsJson : [];
const wanted = normalizeTitle(title);
for (const event of events) {
const markets = Array.isArray(event?.markets) ? event.markets : [];
const byTitle = markets.find((market) => normalizeTitle(market?.question) === wanted);
const candidate = byTitle ?? (markets.length === 1 ? markets[0] : null);
if (!candidate || !candidate.closed) continue;
const outcomes = parseJsonArray(candidate.outcomes);
const prices = parseJsonArray(candidate.outcomePrices);
if (!outcomes.length || outcomes.length !== prices.length) continue;
const yesIndex = outcomes.findIndex((outcome) => String(outcome).trim().toLowerCase() === 'yes');
if (yesIndex < 0) continue;
const price = Number(prices[yesIndex]);
if (!Number.isFinite(price)) continue;
return Math.round(price * 100);
}
return null;
}
// Pure: extract a settled yesPrice (0-100) from a Kalshi market reply.
export function parseKalshiSettlement(marketJson) {
const market = marketJson?.market ?? marketJson;
const status = String(market?.status || '').toLowerCase();
if (status !== 'settled' && status !== 'finalized') return null;
const result = String(market?.result || '').toLowerCase();
if (result === 'yes') return 100;
if (result === 'no') return 0;
return null;
}
function normalizeTitle(value) {
// Trailing '?' is stripped because the bet title is normalizeQuestion(venue
// title) — a '?' appended to statement-form titles.
return String(value ?? '').trim().toLowerCase().replace(/\s+/g, ' ').replace(/[?\s]+$/, '');
}
function parseJsonArray(value) {
if (Array.isArray(value)) return value;
if (typeof value !== 'string') return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
async function fetchVenueSettlement(entry) {
const headers = { 'User-Agent': CHROME_UA };
if (entry.marketSource === 'kalshi') {
const resp = await fetch(`${KALSHI_SETTLEMENT_BASE}/markets/${encodeURIComponent(entry.marketSlug)}`, {
headers,
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) throw new Error(`kalshi ${entry.marketSlug}: HTTP ${resp.status}`);
return parseKalshiSettlement(await resp.json());
}
const resp = await fetch(`${GAMMA_SETTLEMENT_BASE}/events?slug=${encodeURIComponent(entry.marketSlug)}`, {
headers,
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) throw new Error(`gamma ${entry.marketSlug}: HTTP ${resp.status}`);
return parseGammaSettlement(await resp.json(), entry.title);
}
async function writeRedisJson(key, value, ttlSeconds) {
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) throw new Error('Missing UPSTASH_REDIS_REST_URL or UPSTASH_REDIS_REST_TOKEN');
const resp = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'User-Agent': CHROME_UA },
body: JSON.stringify(['SET', key, JSON.stringify(value), 'EX', ttlSeconds]),
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) throw new Error(`Redis SET ${key} failed: HTTP ${resp.status}`);
}
// Best-effort: fetch adjudicated outcomes for due market bets and append them
// to the settlement feed. Failures warn and skip — bets stay pending inside the
// settlement grace, so a missed run self-heals on the next cycle.
export async function updateMarketSettlements(ledger, nowMs, options = {}) {
const fetchSettlement = options.fetchSettlement || fetchVenueSettlement;
const readJson = options.readJson;
const writeJson = options.writeJson || writeRedisJson;
if (typeof readJson !== 'function') throw new TypeError('updateMarketSettlements requires readJson');
// Write the seed-meta companion on every run, including zero-due and
// fail-closed cycles, so health tracks the writer rather than market cadence.
const finalize = async (stats, recordCount) => {
await Promise.resolve(writeJson(MARKET_SETTLEMENT_META_KEY, {
fetchedAt: nowMs,
recordCount,
...stats,
}, SETTLEMENT_TTL_SECONDS))
.catch((err) => console.warn(` [forecast-resolutions] settlement seed-meta write failed: ${err?.message || err}`));
return stats;
};
const due = Object.values(normalizeLedger(ledger)).filter((entry) => entry?.status === 'pending'
&& entry.spec?.sourceFeed === MARKET_SETTLEMENT_FEED_KEY
&& Number(entry.deadline) <= nowMs
&& typeof entry.marketSlug === 'string' && entry.marketSlug)
// Oldest deadline first: a backlog above the cap must drain the entries
// nearest their VOID grace.
.sort((a, b) => Number(a.deadline) - Number(b.deadline));
if (!due.length) return finalize({ fetched: 0, settled: 0 }, null);
// Fail closed on read errors: rebuilding from [] could wipe adjudications.
let existing;
try {
existing = await Promise.resolve(readJson(MARKET_SETTLEMENT_FEED_KEY));
} catch (err) {
console.warn(` [forecast-resolutions] settlement feed read failed — skipping settlement cycle: ${err?.message || err}`);
return finalize({ fetched: 0, settled: 0 }, null);
}
const records = Array.isArray(existing?.records) ? [...existing.records] : [];
const have = new Set(records.map((record) => record?.slug).filter(Boolean));
// A venue-moved endDate can create multiple ledger windows for one slug.
const targets = [...new Map(
due.filter((entry) => !have.has(entry.marketSlug)).map((entry) => [entry.marketSlug, entry]),
).values()].slice(0, SETTLEMENT_FETCH_CAP_PER_RUN);
let settled = 0;
for (const entry of targets) {
try {
const yesPrice = await fetchSettlement(entry);
if (yesPrice == null) continue;
records.push({ market: entry.title, slug: entry.marketSlug, yesPrice, asOf: nowMs });
settled += 1;
} catch (err) {
console.warn(` [forecast-resolutions] settlement fetch failed for ${entry.marketSlug}: ${err?.message || err}`);
}
}
if (settled > 0) {
await Promise.resolve(writeJson(
MARKET_SETTLEMENT_FEED_KEY,
{ records, updatedAt: nowMs },
SETTLEMENT_TTL_SECONDS,
)).catch((err) => console.warn(` [forecast-resolutions] settlement write failed: ${err?.message || err}`));
}
return finalize({ fetched: targets.length, settled }, records.length);
}
function normalizeLedger(ledger) {
const data = unwrapEnvelope(ledger).data;
if (!data) return {};
if (Array.isArray(data)) {
return Object.fromEntries(data.filter(Boolean).map((entry) => [
entry.key || `${entry.id}@${entry.deadline}`,
entry,
]));
}
return typeof data === 'object' ? data : {};
}
|