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 : {};
}