mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
87
scripts/_prediction-scoring.mjs
Normal file
87
scripts/_prediction-scoring.mjs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
338
tests/prediction-scoring.test.mjs
Normal file
338
tests/prediction-scoring.test.mjs
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user