diff --git a/api/polymarket.js b/api/polymarket.js index c483a1285..cfb8b5758 100644 --- a/api/polymarket.js +++ b/api/polymarket.js @@ -19,8 +19,14 @@ function validateOrder(val) { return ALLOWED_ORDER.includes(val) ? val : 'volume'; } +function sanitizeTagSlug(val) { + if (!val) return null; + return val.replace(/[^a-z0-9-]/gi, '').slice(0, 100) || null; +} + export default async function handler(req) { const url = new URL(req.url); + const endpoint = url.searchParams.get('endpoint') || 'markets'; const closed = validateBoolean(url.searchParams.get('closed'), 'false'); const order = validateOrder(url.searchParams.get('order')); @@ -28,11 +34,24 @@ export default async function handler(req) { const limit = validateLimit(url.searchParams.get('limit')); try { - const polyUrl = `https://gamma-api.polymarket.com/markets?closed=${closed}&order=${order}&ascending=${ascending}&limit=${limit}`; + let polyUrl; + + if (endpoint === 'events') { + const tag = sanitizeTagSlug(url.searchParams.get('tag')); + const params = new URLSearchParams({ + closed: closed, + order: order, + ascending: ascending, + limit: String(limit), + }); + if (tag) params.set('tag_slug', tag); + polyUrl = `https://gamma-api.polymarket.com/events?${params}`; + } else { + polyUrl = `https://gamma-api.polymarket.com/markets?closed=${closed}&order=${order}&ascending=${ascending}&limit=${limit}`; + } + const response = await fetch(polyUrl, { - headers: { - 'Accept': 'application/json', - }, + headers: { 'Accept': 'application/json' }, }); const data = await response.text(); diff --git a/src/components/PredictionPanel.ts b/src/components/PredictionPanel.ts index 38a41f4f6..5cf95dd86 100644 --- a/src/components/PredictionPanel.ts +++ b/src/components/PredictionPanel.ts @@ -37,9 +37,13 @@ export class PredictionPanel extends Panel { const noPercent = 100 - yesPercent; const volumeStr = this.formatVolume(p.volume); + const titleHtml = p.url + ? `${escapeHtml(p.title)}` + : `
${escapeHtml(p.title)}
`; + return `
-
${escapeHtml(p.title)}
+ ${titleHtml} ${volumeStr ? `
Vol: ${volumeStr}
` : ''}
diff --git a/src/services/polymarket.ts b/src/services/polymarket.ts index 9cc2e9cbd..f209c157c 100644 --- a/src/services/polymarket.ts +++ b/src/services/polymarket.ts @@ -4,164 +4,161 @@ import { SITE_VARIANT } from '@/config'; interface PolymarketMarket { question: string; - outcomes?: string[]; + outcomes?: string; outcomePrices?: string; volume?: string; volumeNum?: number; closed?: boolean; + slug?: string; +} + +interface PolymarketEvent { + id: string; + title: string; + slug: string; + volume?: number; + liquidity?: number; + markets?: PolymarketMarket[]; tags?: Array<{ slug: string }>; + closed?: boolean; } const breaker = createCircuitBreaker({ name: 'Polymarket' }); -// Tech/AI/Startup keywords for tech variant -const TECH_KEYWORDS = [ - // AI & ML - 'ai', 'artificial intelligence', 'openai', 'chatgpt', 'gpt', 'claude', 'anthropic', 'google ai', 'gemini', - 'machine learning', 'neural', 'llm', 'agi', 'deepmind', 'midjourney', 'stable diffusion', 'copilot', - // Tech Companies - 'apple', 'google', 'microsoft', 'amazon', 'meta', 'facebook', 'nvidia', 'tesla', 'spacex', - 'twitter', 'x.com', 'tiktok', 'bytedance', 'alibaba', 'tencent', 'samsung', 'intel', 'amd', 'tsmc', - // Startups & VC - 'startup', 'ipo', 'unicorn', 'valuation', 'funding', 'series a', 'series b', 'y combinator', 'vc', - 'venture capital', 'acquisition', 'merger', 'layoff', 'layoffs', - // Tech Topics - 'crypto', 'bitcoin', 'ethereum', 'blockchain', 'web3', 'nft', - 'autonomous', 'self-driving', 'robotics', 'drone', 'ev', 'electric vehicle', - 'quantum', 'chip', 'semiconductor', 'gpu', 'processor', - 'cybersecurity', 'hack', 'breach', 'ransomware', - 'social media', 'app store', 'cloud', 'saas', 'software', - // Tech Regulation - 'antitrust', 'ftc', 'eu commission', 'tech regulation', 'data privacy', 'gdpr', - // Tech Leaders - 'elon musk', 'sam altman', 'mark zuckerberg', 'sundar pichai', 'satya nadella', 'tim cook', 'jensen huang', +const GEOPOLITICAL_TAGS = [ + 'politics', 'geopolitics', 'elections', 'world', + 'ukraine', 'china', 'middle-east', 'europe', + 'economy', 'fed', 'inflation', ]; -// Geopolitical keywords for filtering relevant markets -const GEOPOLITICAL_KEYWORDS = [ - // Conflicts & Military - 'war', 'military', 'invasion', 'attack', 'strike', 'troops', 'nato', 'nuclear', - 'missile', 'drone', 'ceasefire', 'peace', 'conflict', 'terrorist', 'hamas', 'hezbollah', - // Countries & Leaders - 'russia', 'ukraine', 'china', 'taiwan', 'iran', 'israel', 'gaza', 'palestine', - 'north korea', 'syria', 'putin', 'zelensky', 'xi jinping', 'netanyahu', 'kim jong', - // Politics & Elections - 'president', 'election', 'elections', 'congress', 'senate', 'parliament', 'government', 'minister', - 'trump', 'biden', 'administration', 'democrat', 'republican', 'vote', 'impeach', - // Economics & Trade - 'fed', 'interest rate', 'interest rates', 'inflation', 'recession', 'gdp', 'tariff', 'tariffs', 'sanction', 'sanctions', - 'oil', 'opec', 'economy', 'trade war', 'currency', 'debt', 'default', - // Global Issues - 'climate', 'pandemic', 'who', 'un ', 'united nations', 'eu ', 'european union', - 'summit', 'treaty', 'alliance', 'coup', 'protest', 'protests', 'uprising', 'refugee', 'refugees', +const TECH_TAGS = [ + 'ai', 'tech', 'crypto', 'science', + 'elon-musk', 'business', 'economy', ]; -// Sports/Entertainment to exclude const EXCLUDE_KEYWORDS = [ 'nba', 'nfl', 'mlb', 'nhl', 'fifa', 'world cup', 'super bowl', 'championship', 'playoffs', 'oscar', 'grammy', 'emmy', 'box office', 'movie', 'album', 'song', - 'tiktok', 'youtube', 'streamer', 'influencer', 'celebrity', 'kardashian', + 'streamer', 'influencer', 'celebrity', 'kardashian', 'bachelor', 'reality tv', 'mvp', 'touchdown', 'home run', 'goal scorer', - // Awards / film / music / TV - 'academy award', 'academy awards', 'oscars', 'bafta', 'golden globe', 'cannes', 'sundance', 'tony', - 'documentary', 'feature film', 'film', 'filmmaker', 'tv', 'series', 'season', 'episode', - 'actor', 'actress', 'director', 'album', 'song', 'soundtrack', + 'academy award', 'bafta', 'golden globe', 'cannes', 'sundance', + 'documentary', 'feature film', 'tv series', 'season finale', ]; -// Tag slugs from Polymarket that clearly indicate non-geopolitical categories -const EXCLUDE_TAGS = [ - 'entertainment', 'sports', 'culture', 'film', 'movie', 'music', 'awards', 'tv', 'celebrity' -]; - -function normalizeText(input: string): string { - return input.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); +function isExcluded(title: string): boolean { + const lower = title.toLowerCase(); + return EXCLUDE_KEYWORDS.some(kw => lower.includes(kw)); } -function escapeRegExp(input: string): string { - return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +function parseMarketPrice(market: PolymarketMarket): number { + try { + const pricesStr = market.outcomePrices; + if (pricesStr) { + const prices: string[] = JSON.parse(pricesStr); + if (prices.length >= 1) { + const parsed = parseFloat(prices[0]!); + if (!isNaN(parsed)) return parsed * 100; + } + } + } catch { /* keep default */ } + return 50; } -function containsKeyword(normalized: string, keyword: string): boolean { - const kw = normalizeText(keyword); - if (!kw) return false; - if (kw.includes(' ')) { - const padded = ` ${normalized} `; - const phrase = ` ${kw} `; - return padded.includes(phrase); - } - const re = new RegExp(`\\b${escapeRegExp(kw)}(s|es)?\\b`, 'i'); - return re.test(normalized); +function buildMarketUrl(eventSlug?: string, marketSlug?: string): string | undefined { + if (eventSlug) return `https://polymarket.com/event/${eventSlug}`; + if (marketSlug) return `https://polymarket.com/market/${marketSlug}`; + return undefined; } -function tagsAreExcluded(tags: Array<{ slug: string }> | undefined): boolean { - if (!tags || tags.length === 0) return false; - return tags.some(tag => { - const tagNorm = normalizeText(tag.slug.replace(/-/g, ' ')); - return EXCLUDE_TAGS.some(ex => containsKeyword(tagNorm, ex) || tagNorm.includes(normalizeText(ex))); - }); +async function fetchEventsByTag(tag: string, limit = 30): Promise { + const response = await fetch( + `/api/polymarket?endpoint=events&tag=${tag}&closed=false&order=volume&ascending=false&limit=${limit}` + ); + if (!response.ok) return []; + return response.json(); } -function isRelevant(title: string, tags?: Array<{ slug: string }>): boolean { - const normalized = normalizeText(title); - if (!normalized) return false; +async function fetchTopMarkets(): Promise { + const response = await fetch('/api/polymarket?closed=false&order=volume&ascending=false&limit=100'); + if (!response.ok) return []; + const data: PolymarketMarket[] = await response.json(); - if (tagsAreExcluded(tags)) return false; - - // Exclude sports/entertainment - if (EXCLUDE_KEYWORDS.some(kw => containsKeyword(normalized, kw))) { - return false; - } - - // Use variant-specific keywords - const keywords = SITE_VARIANT === 'tech' ? TECH_KEYWORDS : GEOPOLITICAL_KEYWORDS; - return keywords.some(kw => containsKeyword(normalized, kw)); + return data + .filter(m => m.question && !isExcluded(m.question)) + .map(m => { + const yesPrice = parseMarketPrice(m); + const volume = m.volumeNum ?? (m.volume ? parseFloat(m.volume) : 0); + return { + title: m.question, + yesPrice, + volume, + url: buildMarketUrl(undefined, m.slug), + }; + }); } export async function fetchPredictions(): Promise { return breaker.execute(async () => { - const response = await fetch('/api/polymarket?closed=false&order=volume&ascending=false&limit=100'); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const data: PolymarketMarket[] = await response.json(); + const tags = SITE_VARIANT === 'tech' ? TECH_TAGS : GEOPOLITICAL_TAGS; - const parsed = data - .map((market) => { - let yesPrice = 50; - try { - const pricesStr = market.outcomePrices; - if (pricesStr) { - const prices: string[] = JSON.parse(pricesStr); - if (Array.isArray(prices) && prices.length >= 1 && prices[0]) { - const parsed = parseFloat(prices[0]); - if (!isNaN(parsed)) yesPrice = parsed * 100; - } - } - } catch { /* Keep default */ } + const eventResults = await Promise.all(tags.map(tag => fetchEventsByTag(tag, 20))); - const volume = market.volumeNum ?? (market.volume ? parseFloat(market.volume) : 0); - return { - title: market.question || '', - yesPrice, - volume, - tags: market.tags || [], - }; - }); + const seen = new Set(); + const markets: PredictionMarket[] = []; - return parsed - .filter((p) => { - if (!p.title || isNaN(p.yesPrice)) return false; + for (const events of eventResults) { + for (const event of events) { + if (event.closed || seen.has(event.id)) continue; + seen.add(event.id); - // Must be relevant to variant (tech or geopolitical) - if (!isRelevant(p.title, p.tags)) return false; + if (isExcluded(event.title)) continue; - // Must have meaningful signal (not 50/50) or high volume - const discrepancy = Math.abs(p.yesPrice - 50); - return discrepancy > 5 || (p.volume && p.volume > 50000); + const eventVolume = event.volume ?? 0; + if (eventVolume < 1000) continue; + + if (event.markets && event.markets.length > 0) { + const topMarket = event.markets.reduce((best, m) => { + const vol = m.volumeNum ?? (m.volume ? parseFloat(m.volume) : 0); + const bestVol = best.volumeNum ?? (best.volume ? parseFloat(best.volume) : 0); + return vol > bestVol ? m : best; + }); + + const yesPrice = parseMarketPrice(topMarket); + markets.push({ + title: topMarket.question || event.title, + yesPrice, + volume: eventVolume, + url: buildMarketUrl(event.slug), + }); + } else { + markets.push({ + title: event.title, + yesPrice: 50, + volume: eventVolume, + url: buildMarketUrl(event.slug), + }); + } + } + } + + // Fallback: only fetch top markets if tag queries didn't yield enough + if (markets.length < 15) { + const fallbackMarkets = await fetchTopMarkets(); + for (const m of fallbackMarkets) { + if (markets.length >= 20) break; + if (!markets.some(existing => existing.title === m.title)) { + markets.push(m); + } + } + } + + // Sort by volume descending, then filter for meaningful signal + return markets + .filter(m => { + const discrepancy = Math.abs(m.yesPrice - 50); + return discrepancy > 5 || (m.volume && m.volume > 50000); }) - .map((p) => ({ - title: p.title, - yesPrice: p.yesPrice, - volume: p.volume, - })) + .sort((a, b) => (b.volume ?? 0) - (a.volume ?? 0)) .slice(0, 15); }, []); } diff --git a/src/styles/main.css b/src/styles/main.css index 7ff6439a3..53ba3ce53 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -3173,6 +3173,17 @@ body.playback-mode .status-dot { line-height: 1.4; } +a.prediction-link { + text-decoration: none; + color: var(--text); + display: block; +} + +a.prediction-link:hover { + color: var(--accent, #60a5fa); + text-decoration: underline; +} + .prediction-volume { font-size: 9px; color: var(--muted); diff --git a/src/types/index.ts b/src/types/index.ts index c4952ffe3..e5749bd75 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -491,6 +491,7 @@ export interface PredictionMarket { title: string; yesPrice: number; volume?: number; + url?: string; } export interface AppState {