From 7d72c6844b030b8fb255180d39b09019a5ea823b Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Thu, 12 Mar 2026 14:02:58 +0400 Subject: [PATCH] fix(predictions): composite scoring, finance variant, region ranking (#1486) * fix(predictions): replace volume-only sort with composite scoring, add finance variant and region ranking The prediction panel was surfacing irrelevant near-certain markets (1%/99% meme markets like celebrity presidential bids) because the discrepancy filter was inverted and sorting was by volume alone. - Replace broken discrepancy filter with composite scoring (60% uncertainty + 40% log-scaled volume) in seed script - Add meme candidate detection and sports/entertainment keyword exclusion - Add finance variant with dedicated tags for economy/trade/rates topics - Add region-aware soft ranking outside circuit breaker cache - Add input validation (category max 50, query max 100) in RPC handler - Skip events without markets instead of defaulting to yesPrice=50 - Per-bucket relaxation safety valve when <15 markets pass strict filters * fix(predictions): apply region sort before truncation, add RPC fallback scoring, validate finance seed - Keep 25 candidates from bootstrap/RPC, apply region sort, then slice to 15 (previously sliced to 15 first, making region boost ineffective for markets ranked 16-25) - Add client-side uncertainty scoring + near-certain filter (10-90%) for RPC fallback path (previously fell back to Gamma's volume-only ordering) - Include finance array in seed validation (previously only checked geopolitical/tech, allowing broken finance data to ship silently) * test(predictions): add 54 unit tests for scoring, filtering, and region tagging Extract pure prediction scoring functions into shared module (_prediction-scoring.mjs) for testability. Tests cover parseYesPrice, isExcluded, isMemeCandidate, tagRegions, shouldInclude, scoreMarket, filterAndScore, isExpired, plus regression tests for the meme market surfacing bug that motivated this fix. --- scripts/_prediction-scoring.mjs | 87 +++++ scripts/seed-prediction-markets.mjs | 78 ++-- .../prediction/v1/list-prediction-markets.ts | 31 +- src/app/data-loader.ts | 2 +- src/services/prediction/index.ts | 62 +++- tests/prediction-scoring.test.mjs | 338 ++++++++++++++++++ 6 files changed, 517 insertions(+), 81 deletions(-) create mode 100644 scripts/_prediction-scoring.mjs create mode 100644 tests/prediction-scoring.test.mjs diff --git a/scripts/_prediction-scoring.mjs b/scripts/_prediction-scoring.mjs new file mode 100644 index 000000000..44cff9345 --- /dev/null +++ b/scripts/_prediction-scoring.mjs @@ -0,0 +1,87 @@ +export const EXCLUDE_KEYWORDS = [ + 'nba', 'nfl', 'mlb', 'nhl', 'fifa', 'world cup', 'super bowl', 'championship', + 'playoffs', 'oscar', 'grammy', 'emmy', 'box office', 'movie', 'album', 'song', + 'streamer', 'influencer', 'celebrity', 'kardashian', + 'bachelor', 'reality tv', 'mvp', 'touchdown', 'home run', 'goal scorer', + 'academy award', 'bafta', 'golden globe', 'cannes', 'sundance', + 'documentary', 'feature film', 'tv series', 'season finale', +]; + +export const MEME_PATTERNS = [ + /\b(lebron|kanye|oprah|swift|rogan|dwayne|kardashian|cardi\s*b)\b/i, + /\b(alien|ufo|zombie|flat earth)\b/i, +]; + +export const REGION_PATTERNS = { + america: /\b(us|u\.s\.|united states|america|trump|biden|congress|federal reserve|canada|mexico|brazil)\b/i, + eu: /\b(europe|european|eu|nato|germany|france|uk|britain|macron|ecb)\b/i, + mena: /\b(middle east|iran|iraq|syria|israel|palestine|gaza|saudi|yemen|houthi|lebanon)\b/i, + asia: /\b(china|japan|korea|india|taiwan|xi jinping|asean)\b/i, + latam: /\b(latin america|brazil|argentina|venezuela|colombia|chile)\b/i, + africa: /\b(africa|nigeria|south africa|ethiopia|sahel|kenya)\b/i, + oceania: /\b(australia|new zealand)\b/i, +}; + +export function isExcluded(title) { + const lower = title.toLowerCase(); + return EXCLUDE_KEYWORDS.some(kw => lower.includes(kw)); +} + +export function isMemeCandidate(title, yesPrice) { + if (yesPrice >= 15) return false; + return MEME_PATTERNS.some(p => p.test(title)); +} + +export function tagRegions(title) { + return Object.entries(REGION_PATTERNS) + .filter(([, re]) => re.test(title)) + .map(([region]) => region); +} + +export function parseYesPrice(market) { + try { + const prices = JSON.parse(market.outcomePrices || '[]'); + if (prices.length >= 1) { + const p = parseFloat(prices[0]); + if (!isNaN(p) && p >= 0 && p <= 1) return +(p * 100).toFixed(1); + } + } catch {} + return null; +} + +export function shouldInclude(m, relaxed = false) { + const minPrice = relaxed ? 5 : 10; + const maxPrice = relaxed ? 95 : 90; + if (m.yesPrice < minPrice || m.yesPrice > maxPrice) return false; + if (m.volume < 5000) return false; + if (isExcluded(m.title)) return false; + if (isMemeCandidate(m.title, m.yesPrice)) return false; + return true; +} + +export function scoreMarket(m) { + const uncertainty = 1 - (2 * Math.abs(m.yesPrice - 50) / 100); + const vol = Math.log10(Math.max(m.volume, 1)) / Math.log10(10_000_000); + return (uncertainty * 0.6) + (Math.min(vol, 1) * 0.4); +} + +export function isExpired(endDate) { + if (!endDate) return false; + const ms = Date.parse(endDate); + return Number.isFinite(ms) && ms < Date.now(); +} + +export function filterAndScore(candidates, tagFilter, limit = 25) { + let filtered = candidates.filter(m => !isExpired(m.endDate)); + if (tagFilter) filtered = filtered.filter(tagFilter); + + let result = filtered.filter(m => shouldInclude(m)); + if (result.length < 15) { + result = filtered.filter(m => shouldInclude(m, true)); + } + + return result + .map(m => ({ ...m, regions: tagRegions(m.title) })) + .sort((a, b) => scoreMarket(b) - scoreMarket(a)) + .slice(0, limit); +} diff --git a/scripts/seed-prediction-markets.mjs b/scripts/seed-prediction-markets.mjs index 5e4a4a34c..b454996b6 100644 --- a/scripts/seed-prediction-markets.mjs +++ b/scripts/seed-prediction-markets.mjs @@ -1,6 +1,10 @@ #!/usr/bin/env node import { loadEnvFile, CHROME_UA, sleep, runSeed } from './_seed-utils.mjs'; +import { + isExcluded, isMemeCandidate, tagRegions, parseYesPrice, + shouldInclude, scoreMarket, filterAndScore, isExpired, +} from './_prediction-scoring.mjs'; loadEnvFile(import.meta.url); @@ -22,37 +26,11 @@ const TECH_TAGS = [ 'elon-musk', 'business', 'economy', ]; -const EXCLUDE_KEYWORDS = [ - 'nba', 'nfl', 'mlb', 'nhl', 'fifa', 'world cup', 'super bowl', 'championship', - 'playoffs', 'oscar', 'grammy', 'emmy', 'box office', 'movie', 'album', 'song', - 'streamer', 'influencer', 'celebrity', 'kardashian', - 'bachelor', 'reality tv', 'mvp', 'touchdown', 'home run', 'goal scorer', - 'academy award', 'bafta', 'golden globe', 'cannes', 'sundance', - 'documentary', 'feature film', 'tv series', 'season finale', +const FINANCE_TAGS = [ + 'economy', 'fed', 'inflation', 'interest-rates', 'recession', + 'trade', 'tariffs', 'debt-ceiling', ]; -function isExcluded(title) { - const lower = title.toLowerCase(); - return EXCLUDE_KEYWORDS.some(kw => lower.includes(kw)); -} - -function parseYesPrice(market) { - try { - const prices = JSON.parse(market.outcomePrices || '[]'); - if (prices.length >= 1) { - const p = parseFloat(prices[0]); - if (!isNaN(p)) return +(p * 100).toFixed(1); - } - } catch {} - return 50; -} - -function isExpired(endDate) { - if (!endDate) return false; - const ms = Date.parse(endDate); - return Number.isFinite(ms) && ms < Date.now(); -} - async function fetchEventsByTag(tag, limit = 20) { const params = new URLSearchParams({ tag_slug: tag, @@ -78,7 +56,7 @@ async function fetchEventsByTag(tag, limit = 20) { } async function fetchAllPredictions() { - const allTags = [...new Set([...GEOPOLITICAL_TAGS, ...TECH_TAGS])]; + const allTags = [...new Set([...GEOPOLITICAL_TAGS, ...TECH_TAGS, ...FINANCE_TAGS])]; const seen = new Set(); const markets = []; @@ -105,23 +83,19 @@ async function fetchAllPredictions() { return vol > bestVol ? m : best; }); + const yesPrice = parseYesPrice(topMarket); + if (yesPrice === null) continue; + markets.push({ title: topMarket.question || event.title, - yesPrice: parseYesPrice(topMarket), + yesPrice, volume: eventVolume, url: `https://polymarket.com/event/${event.slug}`, endDate: topMarket.endDate ?? event.endDate ?? undefined, tags: (event.tags ?? []).map(t => t.slug), }); } else { - markets.push({ - title: event.title, - yesPrice: 50, - volume: eventVolume, - url: `https://polymarket.com/event/${event.slug}`, - endDate: event.endDate ?? undefined, - tags: (event.tags ?? []).map(t => t.slug), - }); + continue; // no markets = no price signal, skip } } } catch (err) { @@ -130,28 +104,18 @@ async function fetchAllPredictions() { await sleep(TAG_DELAY_MS); } - const geopolitical = markets - .filter(m => !isExpired(m.endDate)) - .filter(m => { - const discrepancy = Math.abs(m.yesPrice - 50); - return discrepancy > 5 || (m.volume > 50000); - }) - .sort((a, b) => b.volume - a.volume) - .slice(0, 25); + console.log(` total raw markets: ${markets.length}`); - const tech = markets - .filter(m => !isExpired(m.endDate)) - .filter(m => m.tags?.some(t => TECH_TAGS.includes(t))) - .filter(m => { - const discrepancy = Math.abs(m.yesPrice - 50); - return discrepancy > 5 || (m.volume > 50000); - }) - .sort((a, b) => b.volume - a.volume) - .slice(0, 25); + const geopolitical = filterAndScore(markets, null); + const tech = filterAndScore(markets, m => m.tags?.some(t => TECH_TAGS.includes(t))); + const finance = filterAndScore(markets, m => m.tags?.some(t => FINANCE_TAGS.includes(t))); + + console.log(` geopolitical: ${geopolitical.length}, tech: ${tech.length}, finance: ${finance.length}`); return { geopolitical, tech, + finance, fetchedAt: Date.now(), }; } @@ -159,5 +123,5 @@ async function fetchAllPredictions() { await runSeed('prediction', 'markets', CANONICAL_KEY, fetchAllPredictions, { ttlSeconds: CACHE_TTL, lockTtlMs: 60_000, - validateFn: (data) => (data?.geopolitical?.length > 0 || data?.tech?.length > 0), + validateFn: (data) => (data?.geopolitical?.length > 0 || data?.tech?.length > 0) && data?.finance?.length > 0, }); diff --git a/server/worldmonitor/prediction/v1/list-prediction-markets.ts b/server/worldmonitor/prediction/v1/list-prediction-markets.ts index 2c30fbea3..e20483ca7 100644 --- a/server/worldmonitor/prediction/v1/list-prediction-markets.ts +++ b/server/worldmonitor/prediction/v1/list-prediction-markets.ts @@ -24,6 +24,9 @@ const BOOTSTRAP_KEY = 'prediction:markets-bootstrap:v1'; const GAMMA_BASE = 'https://gamma-api.polymarket.com'; const FETCH_TIMEOUT = 8000; +const TECH_CATEGORY_TAGS = ['ai', 'tech', 'crypto', 'science']; +const FINANCE_CATEGORY_TAGS = ['economy', 'fed', 'inflation', 'interest-rates', 'recession', 'trade', 'tariffs', 'debt-ceiling']; + // ---------- Internal Gamma API types ---------- interface GammaMarket { @@ -104,13 +107,19 @@ export const listPredictionMarkets: PredictionServiceHandler['listPredictionMark req: ListPredictionMarketsRequest, ): Promise => { try { + const category = (req.category || '').slice(0, 50); + const query = (req.query || '').slice(0, 100); + // Try Railway-seeded bootstrap data first (no Gamma API call needed) - if (!req.query) { + if (!query) { try { - const bootstrap = await getCachedJson(BOOTSTRAP_KEY) as { geopolitical?: PredictionMarket[]; tech?: PredictionMarket[] } | null; + const bootstrap = await getCachedJson(BOOTSTRAP_KEY) as { geopolitical?: PredictionMarket[]; tech?: PredictionMarket[]; finance?: PredictionMarket[] } | null; if (bootstrap) { - const variant = req.category && ['ai', 'tech', 'crypto', 'science'].includes(req.category) - ? bootstrap.tech : bootstrap.geopolitical; + const isTech = category && TECH_CATEGORY_TAGS.includes(category); + const isFinance = !isTech && category && FINANCE_CATEGORY_TAGS.includes(category); + const variant = isTech ? bootstrap.tech + : isFinance ? (bootstrap.finance ?? bootstrap.geopolitical) + : bootstrap.geopolitical; if (variant && variant.length > 0) { const limit = Math.max(1, Math.min(100, req.pageSize || 50)); const markets: PredictionMarket[] = variant.slice(0, limit).map((m: PredictionMarket & { endDate?: string }) => ({ @@ -120,7 +129,7 @@ export const listPredictionMarkets: PredictionServiceHandler['listPredictionMark volume: m.volume ?? 0, url: m.url || '', closesAt: m.endDate ? Date.parse(m.endDate) : 0, - category: req.category || '', + category: category || '', })); return { markets, pagination: undefined }; } @@ -129,12 +138,12 @@ export const listPredictionMarkets: PredictionServiceHandler['listPredictionMark } // Fallback: fetch from Gamma API directly (may fail due to JA3 blocking) - const cacheKey = `${REDIS_CACHE_KEY}:${req.category || 'all'}:${req.query || ''}:${req.pageSize || 50}`; + const cacheKey = `${REDIS_CACHE_KEY}:${category || 'all'}:${query || ''}:${req.pageSize || 50}`; const result = await cachedFetchJson( cacheKey, REDIS_CACHE_TTL, async () => { - const useEvents = !!req.category; + const useEvents = !!category; const endpoint = useEvents ? 'events' : 'markets'; const limit = Math.max(1, Math.min(100, req.pageSize || 50)); const params = new URLSearchParams({ @@ -147,7 +156,7 @@ export const listPredictionMarkets: PredictionServiceHandler['listPredictionMark limit: String(limit), }); if (useEvents) { - params.set('tag_slug', req.category); + params.set('tag_slug', category); } const response = await fetch( @@ -162,13 +171,13 @@ export const listPredictionMarkets: PredictionServiceHandler['listPredictionMark const data: unknown = await response.json(); let markets: PredictionMarket[]; if (useEvents) { - markets = (data as GammaEvent[]).map((e) => mapEvent(e, req.category)); + markets = (data as GammaEvent[]).map((e) => mapEvent(e, category)); } else { markets = (data as GammaMarket[]).map(mapMarket); } - if (req.query) { - const q = req.query.toLowerCase(); + if (query) { + const q = query.toLowerCase(); markets = markets.filter((m) => m.title.toLowerCase().includes(q)); } diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index b0f059475..a67a60d93 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -1338,7 +1338,7 @@ export class DataLoaderManager implements AppModule { async loadPredictions(): Promise { try { - const predictions = await fetchPredictions(); + const predictions = await fetchPredictions({ region: this.ctx.resolvedLocation }); this.ctx.latestPredictions = predictions; (this.ctx.panels['polymarket'] as PredictionPanel | undefined)?.renderPredictions(predictions); diff --git a/src/services/prediction/index.ts b/src/services/prediction/index.ts index 96219d05e..1d6f60c2f 100644 --- a/src/services/prediction/index.ts +++ b/src/services/prediction/index.ts @@ -10,6 +10,7 @@ export interface PredictionMarket { volume?: number; url?: string; endDate?: string; + regions?: string[]; } function isExpired(endDate?: string): boolean { @@ -33,12 +34,34 @@ const TECH_TAGS = [ 'elon-musk', 'business', 'economy', ]; +const FINANCE_TAGS = [ + 'economy', 'fed', 'inflation', 'interest-rates', 'recession', + 'trade', 'tariffs', 'debt-ceiling', +]; + interface BootstrapPredictionData { geopolitical: PredictionMarket[]; tech: PredictionMarket[]; + finance?: PredictionMarket[]; fetchedAt: number; } +const REGION_PATTERNS: Record = { + america: /\b(us|u\.s\.|united states|america|trump|biden|congress|federal reserve|canada|mexico|brazil)\b/i, + eu: /\b(europe|european|eu|nato|germany|france|uk|britain|macron|ecb)\b/i, + mena: /\b(middle east|iran|iraq|syria|israel|palestine|gaza|saudi|yemen|houthi|lebanon)\b/i, + asia: /\b(china|japan|korea|india|taiwan|xi jinping|asean)\b/i, + latam: /\b(latin america|brazil|argentina|venezuela|colombia|chile)\b/i, + africa: /\b(africa|nigeria|south africa|ethiopia|sahel|kenya)\b/i, + oceania: /\b(australia|new zealand)\b/i, +}; + +function tagRegions(title: string): string[] { + return Object.entries(REGION_PATTERNS) + .filter(([, re]) => re.test(title)) + .map(([region]) => region); +} + function protoToMarket(m: { title: string; yesPrice: number; volume: number; url: string; closesAt: number; category: string }): PredictionMarket { return { title: m.title, @@ -46,22 +69,25 @@ function protoToMarket(m: { title: string; yesPrice: number; volume: number; url volume: m.volume, url: m.url || undefined, endDate: m.closesAt ? new Date(m.closesAt).toISOString() : undefined, + regions: tagRegions(m.title), }; } -export async function fetchPredictions(): Promise { - return breaker.execute(async () => { - // Strategy 1: Bootstrap hydration (zero network cost — data arrived with page load) +export async function fetchPredictions(opts?: { region?: string }): Promise { + const markets = await breaker.execute(async () => { const hydrated = getHydratedData('predictions') as BootstrapPredictionData | undefined; if (hydrated && hydrated.fetchedAt && Date.now() - hydrated.fetchedAt < 20 * 60 * 1000) { - const variant = SITE_VARIANT === 'tech' ? hydrated.tech : hydrated.geopolitical; + const variant = SITE_VARIANT === 'tech' ? hydrated.tech + : SITE_VARIANT === 'finance' ? (hydrated.finance ?? hydrated.geopolitical) + : hydrated.geopolitical; if (variant && variant.length > 0) { - return variant.filter(m => !isExpired(m.endDate)).slice(0, 15); + return variant.filter(m => !isExpired(m.endDate)).slice(0, 25); } } - // Strategy 2: Sebuf RPC (Vercel → Redis / Gamma API server-side) - const tags = SITE_VARIANT === 'tech' ? TECH_TAGS : GEOPOLITICAL_TAGS; + const tags = SITE_VARIANT === 'tech' ? TECH_TAGS + : SITE_VARIANT === 'finance' ? FINANCE_TAGS + : GEOPOLITICAL_TAGS; const rpcResults = await client.listPredictionMarkets({ category: tags[0] ?? '', query: '', @@ -72,16 +98,28 @@ export async function fetchPredictions(): Promise { return rpcResults.markets .map(protoToMarket) .filter(m => !isExpired(m.endDate)) - .filter(m => { - const discrepancy = Math.abs(m.yesPrice - 50); - return discrepancy > 5 || (m.volume && m.volume > 50000); + .filter(m => m.yesPrice >= 10 && m.yesPrice <= 90) + .sort((a, b) => { + const aUncertainty = 1 - (2 * Math.abs(a.yesPrice - 50) / 100); + const bUncertainty = 1 - (2 * Math.abs(b.yesPrice - 50) / 100); + return bUncertainty - aUncertainty; }) - .sort((a, b) => (b.volume ?? 0) - (a.volume ?? 0)) - .slice(0, 15); + .slice(0, 25); } throw new Error('No markets returned — upstream may be down'); }, []); + + if (opts?.region && opts.region !== 'global' && markets.length > 0) { + const sorted = [...markets]; + sorted.sort((a, b) => { + const aMatch = a.regions?.includes(opts.region!) ? 1 : 0; + const bMatch = b.regions?.includes(opts.region!) ? 1 : 0; + return bMatch - aMatch; + }); + return sorted.slice(0, 15); + } + return markets.slice(0, 15); } export async function fetchCountryMarkets(country: string): Promise { diff --git a/tests/prediction-scoring.test.mjs b/tests/prediction-scoring.test.mjs new file mode 100644 index 000000000..f9bb150d1 --- /dev/null +++ b/tests/prediction-scoring.test.mjs @@ -0,0 +1,338 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + isExcluded, + isMemeCandidate, + tagRegions, + parseYesPrice, + shouldInclude, + scoreMarket, + filterAndScore, + isExpired, + EXCLUDE_KEYWORDS, + MEME_PATTERNS, + REGION_PATTERNS, +} from '../scripts/_prediction-scoring.mjs'; + +function market(title, yesPrice, volume, opts = {}) { + return { title, yesPrice, volume, ...opts }; +} + +describe('parseYesPrice', () => { + it('converts 0-1 scale to 0-100', () => { + assert.equal(parseYesPrice({ outcomePrices: '["0.73","0.27"]' }), 73); + }); + + it('returns null for missing outcomePrices', () => { + assert.equal(parseYesPrice({}), null); + }); + + it('returns null for empty array', () => { + assert.equal(parseYesPrice({ outcomePrices: '[]' }), null); + }); + + it('returns null for invalid JSON', () => { + assert.equal(parseYesPrice({ outcomePrices: 'not json' }), null); + }); + + it('returns null for NaN values', () => { + assert.equal(parseYesPrice({ outcomePrices: '["abc"]' }), null); + }); + + it('returns null for out-of-range price > 1', () => { + assert.equal(parseYesPrice({ outcomePrices: '["1.5"]' }), null); + }); + + it('returns null for negative price', () => { + assert.equal(parseYesPrice({ outcomePrices: '["-0.1"]' }), null); + }); + + it('handles boundary: 0.0 returns 0', () => { + assert.equal(parseYesPrice({ outcomePrices: '["0.0"]' }), 0); + }); + + it('handles boundary: 1.0 returns 100', () => { + assert.equal(parseYesPrice({ outcomePrices: '["1.0"]' }), 100); + }); + + it('rounds to one decimal place', () => { + assert.equal(parseYesPrice({ outcomePrices: '["0.333"]' }), 33.3); + }); +}); + +describe('isExcluded', () => { + it('excludes sports keywords', () => { + assert.ok(isExcluded('Will the NBA finals go to game 7?')); + assert.ok(isExcluded('NFL Super Bowl winner')); + }); + + it('excludes entertainment keywords', () => { + assert.ok(isExcluded('Will a movie gross $1B?')); + assert.ok(isExcluded('Grammy Award for best album')); + }); + + it('case insensitive', () => { + assert.ok(isExcluded('NBA PLAYOFFS 2026')); + assert.ok(isExcluded('nba playoffs 2026')); + }); + + it('passes geopolitical titles', () => { + assert.ok(!isExcluded('Will the Fed cut rates in March?')); + assert.ok(!isExcluded('Ukraine ceasefire before July?')); + }); +}); + +describe('isMemeCandidate', () => { + it('flags celebrity + low price as meme', () => { + assert.ok(isMemeCandidate('Will LeBron James become president?', 1)); + assert.ok(isMemeCandidate('Kanye West elected governor?', 3)); + }); + + it('does NOT flag celebrity at price >= 15', () => { + assert.ok(!isMemeCandidate('Will LeBron James become president?', 15)); + assert.ok(!isMemeCandidate('Will LeBron James become president?', 50)); + }); + + it('flags novelty patterns at low price', () => { + assert.ok(isMemeCandidate('Alien disclosure before 2027?', 5)); + assert.ok(isMemeCandidate('UFO confirmed by Pentagon?', 10)); + }); + + it('passes serious geopolitical at low price', () => { + assert.ok(!isMemeCandidate('Will sanctions on Iran be lifted?', 5)); + }); +}); + +describe('tagRegions', () => { + it('tags America for US-related titles', () => { + const regions = tagRegions('Will Trump win the 2028 election?'); + assert.ok(regions.includes('america')); + }); + + it('tags MENA for Middle East titles', () => { + const regions = tagRegions('Iran nuclear deal revival'); + assert.ok(regions.includes('mena')); + }); + + it('tags multiple regions for multi-region titles', () => { + const regions = tagRegions('US-China trade war escalation'); + assert.ok(regions.includes('america')); + assert.ok(regions.includes('asia')); + }); + + it('returns empty for generic titles', () => { + const regions = tagRegions('Global recession probability'); + assert.deepEqual(regions, []); + }); + + it('tags EU for European titles', () => { + const regions = tagRegions('ECB rate decision March'); + assert.ok(regions.includes('eu')); + }); + + it('tags latam for Latin America', () => { + const regions = tagRegions('Venezuela presidential crisis'); + assert.ok(regions.includes('latam')); + }); + + it('tags africa for African titles', () => { + const regions = tagRegions('Nigeria elections 2027'); + assert.ok(regions.includes('africa')); + }); + + it('word boundary prevents false positives', () => { + const regions = tagRegions('European summit'); + assert.ok(regions.includes('eu')); + const regions2 = tagRegions('Euphoria renewed'); + assert.ok(!regions2.includes('eu')); + }); +}); + +describe('shouldInclude', () => { + it('excludes near-certain markets (yesPrice < 10)', () => { + assert.ok(!shouldInclude(market('Test', 5, 100000))); + }); + + it('excludes near-certain markets (yesPrice > 90)', () => { + assert.ok(!shouldInclude(market('Test', 95, 100000))); + }); + + it('excludes low volume markets', () => { + assert.ok(!shouldInclude(market('Test', 50, 1000))); + }); + + it('excludes sports markets', () => { + assert.ok(!shouldInclude(market('NFL Super Bowl winner', 50, 100000))); + }); + + it('excludes meme candidates', () => { + assert.ok(!shouldInclude(market('Will LeBron become president?', 1, 500000))); + }); + + it('includes good geopolitical market', () => { + assert.ok(shouldInclude(market('Fed rate cut in June?', 45, 50000))); + }); + + it('relaxed mode allows 5-95 range', () => { + assert.ok(!shouldInclude(market('Test', 7, 50000))); + assert.ok(shouldInclude(market('Test', 7, 50000), true)); + }); + + it('relaxed mode still enforces volume minimum', () => { + assert.ok(!shouldInclude(market('Test', 50, 1000), true)); + }); +}); + +describe('scoreMarket', () => { + it('50% price gets maximum uncertainty (0.6)', () => { + const score = scoreMarket(market('Test', 50, 1)); + assert.ok(score >= 0.59, `50% market should have uncertainty ~0.6, got ${score}`); + }); + + it('1% price gets near-zero uncertainty', () => { + const lowScore = scoreMarket(market('Test', 1, 10000)); + const midScore = scoreMarket(market('Test', 50, 10000)); + assert.ok(midScore > lowScore, `50% score (${midScore}) should beat 1% score (${lowScore})`); + }); + + it('higher volume increases score', () => { + const lowVol = scoreMarket(market('Test', 50, 1000)); + const highVol = scoreMarket(market('Test', 50, 1000000)); + assert.ok(highVol > lowVol, `$1M vol (${highVol}) should beat $1K vol (${lowVol})`); + }); + + it('uncertainty dominates: 50%/$10K beats 10%/$10M', () => { + const uncertain = scoreMarket(market('Test', 50, 10000)); + const certain = scoreMarket(market('Test', 10, 10000000)); + assert.ok(uncertain > certain, + `50%/$10K (${uncertain}) should beat 10%/$10M (${certain}) — uncertainty weight 60%`); + }); + + it('score bounded between 0 and 1', () => { + const s1 = scoreMarket(market('Test', 50, 10000000)); + const s2 = scoreMarket(market('Test', 1, 1)); + assert.ok(s1 >= 0 && s1 <= 1, `score should be 0-1, got ${s1}`); + assert.ok(s2 >= 0 && s2 <= 1, `score should be 0-1, got ${s2}`); + }); + + it('symmetric: 40% and 60% get same uncertainty', () => { + const s40 = scoreMarket(market('Test', 40, 10000)); + const s60 = scoreMarket(market('Test', 60, 10000)); + assert.ok(Math.abs(s40 - s60) < 0.001, `40% (${s40}) and 60% (${s60}) should have same score`); + }); +}); + +describe('isExpired', () => { + it('returns false for null/undefined', () => { + assert.ok(!isExpired(null)); + assert.ok(!isExpired(undefined)); + }); + + it('returns true for past date', () => { + assert.ok(isExpired('2020-01-01T00:00:00Z')); + }); + + it('returns false for future date', () => { + assert.ok(!isExpired('2099-01-01T00:00:00Z')); + }); + + it('returns false for invalid date string', () => { + assert.ok(!isExpired('not-a-date')); + }); +}); + +describe('filterAndScore', () => { + function genMarkets(n, overrides = {}) { + return Array.from({ length: n }, (_, i) => ({ + title: `Market ${i} about the Federal Reserve`, + yesPrice: 30 + (i % 40), + volume: 10000 + i * 1000, + endDate: '2099-01-01T00:00:00Z', + tags: ['economy'], + ...overrides, + })); + } + + it('filters expired markets', () => { + const candidates = [ + market('Fed rate cut?', 50, 50000, { endDate: '2020-01-01T00:00:00Z' }), + market('ECB rate decision', 45, 50000, { endDate: '2099-01-01T00:00:00Z' }), + ]; + const result = filterAndScore(candidates, null); + assert.equal(result.length, 1); + assert.equal(result[0].title, 'ECB rate decision'); + }); + + it('applies tag filter', () => { + const candidates = [ + market('AI regulation', 50, 50000, { tags: ['tech'], endDate: '2099-01-01' }), + market('Fed rate cut', 50, 50000, { tags: ['economy'], endDate: '2099-01-01' }), + ]; + const result = filterAndScore(candidates, m => m.tags?.includes('tech')); + assert.equal(result.length, 1); + assert.equal(result[0].title, 'AI regulation'); + }); + + it('sorts by composite score (most uncertain first)', () => { + const candidates = [ + market('Market A (certain)', 85, 100000, { endDate: '2099-01-01' }), + market('Market B (uncertain)', 48, 100000, { endDate: '2099-01-01' }), + market('Market C (mid)', 65, 100000, { endDate: '2099-01-01' }), + ]; + const result = filterAndScore(candidates, null); + assert.equal(result[0].title, 'Market B (uncertain)'); + }); + + it('respects limit parameter', () => { + const candidates = genMarkets(30); + const result = filterAndScore(candidates, null, 10); + assert.equal(result.length, 10); + }); + + it('adds regions to output markets', () => { + const candidates = [ + market('Will Trump win?', 50, 50000, { endDate: '2099-01-01' }), + ]; + const result = filterAndScore(candidates, null); + assert.ok(result[0].regions.includes('america')); + }); + + it('relaxes price bounds when < 15 markets pass strict filter', () => { + const candidates = [ + market('Market at 7%', 7, 50000, { endDate: '2099-01-01' }), + market('Market at 93%', 93, 50000, { endDate: '2099-01-01' }), + ]; + const result = filterAndScore(candidates, null); + assert.equal(result.length, 2, 'relaxed mode should include 7% and 93% markets'); + }); + + it('strict filter rejects 7% and 93% when enough markets exist', () => { + const good = genMarkets(20); + const edge = [ + market('Edge at 7%', 7, 50000, { endDate: '2099-01-01' }), + ]; + const result = filterAndScore([...good, ...edge], null); + assert.ok(!result.some(m => m.title === 'Edge at 7%'), + 'strict filter should exclude 7% when enough markets'); + }); +}); + +describe('regression: meme market surfacing', () => { + it('LeBron presidential market at 1% is excluded', () => { + const m = market('Will LeBron James win the 2028 US Presidential Election?', 1, 393000); + assert.ok(!shouldInclude(m), 'LeBron 1% market should be excluded (meme + near-certain)'); + assert.ok(isMemeCandidate(m.title, m.yesPrice), 'should be flagged as meme'); + }); + + it('LeBron market scores lower than genuine uncertain market', () => { + const meme = scoreMarket(market('Will LeBron James win?', 1, 500000)); + const real = scoreMarket(market('Will the Fed cut rates?', 48, 50000)); + assert.ok(real > meme, `Real market (${real}) should score higher than meme (${meme})`); + }); + + it('high-volume 99% market excluded by shouldInclude', () => { + const m = market('Will the sun rise tomorrow?', 99, 10000000); + assert.ok(!shouldInclude(m), '99% market excluded regardless of volume'); + }); +});