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.
This commit is contained in:
Elie Habib
2026-03-12 14:02:58 +04:00
committed by GitHub
parent 9c104f413c
commit 7d72c6844b
6 changed files with 517 additions and 81 deletions

View File

@@ -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);
}

View File

@@ -1,6 +1,10 @@
#!/usr/bin/env node #!/usr/bin/env node
import { loadEnvFile, CHROME_UA, sleep, runSeed } from './_seed-utils.mjs'; 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); loadEnvFile(import.meta.url);
@@ -22,37 +26,11 @@ const TECH_TAGS = [
'elon-musk', 'business', 'economy', 'elon-musk', 'business', 'economy',
]; ];
const EXCLUDE_KEYWORDS = [ const FINANCE_TAGS = [
'nba', 'nfl', 'mlb', 'nhl', 'fifa', 'world cup', 'super bowl', 'championship', 'economy', 'fed', 'inflation', 'interest-rates', 'recession',
'playoffs', 'oscar', 'grammy', 'emmy', 'box office', 'movie', 'album', 'song', 'trade', 'tariffs', 'debt-ceiling',
'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',
]; ];
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) { async function fetchEventsByTag(tag, limit = 20) {
const params = new URLSearchParams({ const params = new URLSearchParams({
tag_slug: tag, tag_slug: tag,
@@ -78,7 +56,7 @@ async function fetchEventsByTag(tag, limit = 20) {
} }
async function fetchAllPredictions() { 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 seen = new Set();
const markets = []; const markets = [];
@@ -105,23 +83,19 @@ async function fetchAllPredictions() {
return vol > bestVol ? m : best; return vol > bestVol ? m : best;
}); });
const yesPrice = parseYesPrice(topMarket);
if (yesPrice === null) continue;
markets.push({ markets.push({
title: topMarket.question || event.title, title: topMarket.question || event.title,
yesPrice: parseYesPrice(topMarket), yesPrice,
volume: eventVolume, volume: eventVolume,
url: `https://polymarket.com/event/${event.slug}`, url: `https://polymarket.com/event/${event.slug}`,
endDate: topMarket.endDate ?? event.endDate ?? undefined, endDate: topMarket.endDate ?? event.endDate ?? undefined,
tags: (event.tags ?? []).map(t => t.slug), tags: (event.tags ?? []).map(t => t.slug),
}); });
} else { } else {
markets.push({ continue; // no markets = no price signal, skip
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),
});
} }
} }
} catch (err) { } catch (err) {
@@ -130,28 +104,18 @@ async function fetchAllPredictions() {
await sleep(TAG_DELAY_MS); await sleep(TAG_DELAY_MS);
} }
const geopolitical = markets console.log(` total raw markets: ${markets.length}`);
.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);
const tech = markets const geopolitical = filterAndScore(markets, null);
.filter(m => !isExpired(m.endDate)) const tech = filterAndScore(markets, m => m.tags?.some(t => TECH_TAGS.includes(t)));
.filter(m => m.tags?.some(t => TECH_TAGS.includes(t))) const finance = filterAndScore(markets, m => m.tags?.some(t => FINANCE_TAGS.includes(t)));
.filter(m => {
const discrepancy = Math.abs(m.yesPrice - 50); console.log(` geopolitical: ${geopolitical.length}, tech: ${tech.length}, finance: ${finance.length}`);
return discrepancy > 5 || (m.volume > 50000);
})
.sort((a, b) => b.volume - a.volume)
.slice(0, 25);
return { return {
geopolitical, geopolitical,
tech, tech,
finance,
fetchedAt: Date.now(), fetchedAt: Date.now(),
}; };
} }
@@ -159,5 +123,5 @@ async function fetchAllPredictions() {
await runSeed('prediction', 'markets', CANONICAL_KEY, fetchAllPredictions, { await runSeed('prediction', 'markets', CANONICAL_KEY, fetchAllPredictions, {
ttlSeconds: CACHE_TTL, ttlSeconds: CACHE_TTL,
lockTtlMs: 60_000, 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,
}); });

View File

@@ -24,6 +24,9 @@ const BOOTSTRAP_KEY = 'prediction:markets-bootstrap:v1';
const GAMMA_BASE = 'https://gamma-api.polymarket.com'; const GAMMA_BASE = 'https://gamma-api.polymarket.com';
const FETCH_TIMEOUT = 8000; 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 ---------- // ---------- Internal Gamma API types ----------
interface GammaMarket { interface GammaMarket {
@@ -104,13 +107,19 @@ export const listPredictionMarkets: PredictionServiceHandler['listPredictionMark
req: ListPredictionMarketsRequest, req: ListPredictionMarketsRequest,
): Promise<ListPredictionMarketsResponse> => { ): Promise<ListPredictionMarketsResponse> => {
try { 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) // Try Railway-seeded bootstrap data first (no Gamma API call needed)
if (!req.query) { if (!query) {
try { 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) { if (bootstrap) {
const variant = req.category && ['ai', 'tech', 'crypto', 'science'].includes(req.category) const isTech = category && TECH_CATEGORY_TAGS.includes(category);
? bootstrap.tech : bootstrap.geopolitical; 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) { if (variant && variant.length > 0) {
const limit = Math.max(1, Math.min(100, req.pageSize || 50)); const limit = Math.max(1, Math.min(100, req.pageSize || 50));
const markets: PredictionMarket[] = variant.slice(0, limit).map((m: PredictionMarket & { endDate?: string }) => ({ 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, volume: m.volume ?? 0,
url: m.url || '', url: m.url || '',
closesAt: m.endDate ? Date.parse(m.endDate) : 0, closesAt: m.endDate ? Date.parse(m.endDate) : 0,
category: req.category || '', category: category || '',
})); }));
return { markets, pagination: undefined }; 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) // 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<ListPredictionMarketsResponse>( const result = await cachedFetchJson<ListPredictionMarketsResponse>(
cacheKey, cacheKey,
REDIS_CACHE_TTL, REDIS_CACHE_TTL,
async () => { async () => {
const useEvents = !!req.category; const useEvents = !!category;
const endpoint = useEvents ? 'events' : 'markets'; const endpoint = useEvents ? 'events' : 'markets';
const limit = Math.max(1, Math.min(100, req.pageSize || 50)); const limit = Math.max(1, Math.min(100, req.pageSize || 50));
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -147,7 +156,7 @@ export const listPredictionMarkets: PredictionServiceHandler['listPredictionMark
limit: String(limit), limit: String(limit),
}); });
if (useEvents) { if (useEvents) {
params.set('tag_slug', req.category); params.set('tag_slug', category);
} }
const response = await fetch( const response = await fetch(
@@ -162,13 +171,13 @@ export const listPredictionMarkets: PredictionServiceHandler['listPredictionMark
const data: unknown = await response.json(); const data: unknown = await response.json();
let markets: PredictionMarket[]; let markets: PredictionMarket[];
if (useEvents) { if (useEvents) {
markets = (data as GammaEvent[]).map((e) => mapEvent(e, req.category)); markets = (data as GammaEvent[]).map((e) => mapEvent(e, category));
} else { } else {
markets = (data as GammaMarket[]).map(mapMarket); markets = (data as GammaMarket[]).map(mapMarket);
} }
if (req.query) { if (query) {
const q = req.query.toLowerCase(); const q = query.toLowerCase();
markets = markets.filter((m) => m.title.toLowerCase().includes(q)); markets = markets.filter((m) => m.title.toLowerCase().includes(q));
} }

View File

@@ -1338,7 +1338,7 @@ export class DataLoaderManager implements AppModule {
async loadPredictions(): Promise<void> { async loadPredictions(): Promise<void> {
try { try {
const predictions = await fetchPredictions(); const predictions = await fetchPredictions({ region: this.ctx.resolvedLocation });
this.ctx.latestPredictions = predictions; this.ctx.latestPredictions = predictions;
(this.ctx.panels['polymarket'] as PredictionPanel | undefined)?.renderPredictions(predictions); (this.ctx.panels['polymarket'] as PredictionPanel | undefined)?.renderPredictions(predictions);

View File

@@ -10,6 +10,7 @@ export interface PredictionMarket {
volume?: number; volume?: number;
url?: string; url?: string;
endDate?: string; endDate?: string;
regions?: string[];
} }
function isExpired(endDate?: string): boolean { function isExpired(endDate?: string): boolean {
@@ -33,12 +34,34 @@ const TECH_TAGS = [
'elon-musk', 'business', 'economy', 'elon-musk', 'business', 'economy',
]; ];
const FINANCE_TAGS = [
'economy', 'fed', 'inflation', 'interest-rates', 'recession',
'trade', 'tariffs', 'debt-ceiling',
];
interface BootstrapPredictionData { interface BootstrapPredictionData {
geopolitical: PredictionMarket[]; geopolitical: PredictionMarket[];
tech: PredictionMarket[]; tech: PredictionMarket[];
finance?: PredictionMarket[];
fetchedAt: number; fetchedAt: number;
} }
const REGION_PATTERNS: Record<string, RegExp> = {
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 { function protoToMarket(m: { title: string; yesPrice: number; volume: number; url: string; closesAt: number; category: string }): PredictionMarket {
return { return {
title: m.title, title: m.title,
@@ -46,22 +69,25 @@ function protoToMarket(m: { title: string; yesPrice: number; volume: number; url
volume: m.volume, volume: m.volume,
url: m.url || undefined, url: m.url || undefined,
endDate: m.closesAt ? new Date(m.closesAt).toISOString() : undefined, endDate: m.closesAt ? new Date(m.closesAt).toISOString() : undefined,
regions: tagRegions(m.title),
}; };
} }
export async function fetchPredictions(): Promise<PredictionMarket[]> { export async function fetchPredictions(opts?: { region?: string }): Promise<PredictionMarket[]> {
return breaker.execute(async () => { const markets = await breaker.execute(async () => {
// Strategy 1: Bootstrap hydration (zero network cost — data arrived with page load)
const hydrated = getHydratedData('predictions') as BootstrapPredictionData | undefined; const hydrated = getHydratedData('predictions') as BootstrapPredictionData | undefined;
if (hydrated && hydrated.fetchedAt && Date.now() - hydrated.fetchedAt < 20 * 60 * 1000) { 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) { 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
const tags = SITE_VARIANT === 'tech' ? TECH_TAGS : GEOPOLITICAL_TAGS; : SITE_VARIANT === 'finance' ? FINANCE_TAGS
: GEOPOLITICAL_TAGS;
const rpcResults = await client.listPredictionMarkets({ const rpcResults = await client.listPredictionMarkets({
category: tags[0] ?? '', category: tags[0] ?? '',
query: '', query: '',
@@ -72,16 +98,28 @@ export async function fetchPredictions(): Promise<PredictionMarket[]> {
return rpcResults.markets return rpcResults.markets
.map(protoToMarket) .map(protoToMarket)
.filter(m => !isExpired(m.endDate)) .filter(m => !isExpired(m.endDate))
.filter(m => { .filter(m => m.yesPrice >= 10 && m.yesPrice <= 90)
const discrepancy = Math.abs(m.yesPrice - 50); .sort((a, b) => {
return discrepancy > 5 || (m.volume && m.volume > 50000); 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, 25);
.slice(0, 15);
} }
throw new Error('No markets returned — upstream may be down'); 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<PredictionMarket[]> { export async function fetchCountryMarkets(country: string): Promise<PredictionMarket[]> {

View File

@@ -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');
});
});