feat(polymarket): add Polymarket prediction markets as 6th research source

Search Polymarket's free Gamma API for relevant prediction markets on any
topic. Uses smart multi-query expansion to cast a wider net (e.g., "Arizona
Basketball" also searches "Arizona"), merges and dedupes by event ID, and
shows price movement context ("up 22.5% this week"). No API key required.

Also hides sources with zero results from the stats output (all sources).

54 new tests, all passing. Full pipeline integration with scoring, dedupe,
cross-source linking, and rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-02-25 22:27:19 -08:00
parent 52503f8e68
commit 994a4ab2ca
17 changed files with 1699 additions and 43 deletions
+84
View File
@@ -22,6 +22,10 @@ class Engagement:
# YouTube fields
views: Optional[int] = None
# Polymarket fields
volume: Optional[float] = None
liquidity: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
d = {}
if self.score is not None:
@@ -40,6 +44,10 @@ class Engagement:
d['quotes'] = self.quotes
if self.views is not None:
d['views'] = self.views
if self.volume is not None:
d['volume'] = self.volume
if self.liquidity is not None:
d['liquidity'] = self.liquidity
return d if d else None
@@ -264,6 +272,49 @@ class HackerNewsItem:
return d
@dataclass
class PolymarketItem:
"""Normalized Polymarket prediction market item."""
id: str # "PM1", "PM2", ...
title: str # Event title
question: str # Top market question
url: str # Event page URL
outcome_prices: List[tuple] = field(default_factory=list) # [(name, price), ...]
outcomes_remaining: int = 0
price_movement: Optional[str] = None # "down 11.7% this month"
date: Optional[str] = None
date_confidence: str = "high" # API provides exact timestamps
engagement: Optional[Engagement] = None # volume + liquidity
end_date: Optional[str] = None
relevance: float = 0.5
why_relevant: str = ""
subs: SubScores = field(default_factory=SubScores)
score: int = 0
cross_refs: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
d = {
'id': self.id,
'title': self.title,
'question': self.question,
'url': self.url,
'outcome_prices': self.outcome_prices,
'outcomes_remaining': self.outcomes_remaining,
'price_movement': self.price_movement,
'date': self.date,
'date_confidence': self.date_confidence,
'engagement': self.engagement.to_dict() if self.engagement else None,
'end_date': self.end_date,
'relevance': self.relevance,
'why_relevant': self.why_relevant,
'subs': self.subs.to_dict(),
'score': self.score,
}
if self.cross_refs:
d['cross_refs'] = self.cross_refs
return d
@dataclass
class Report:
"""Full research report."""
@@ -279,6 +330,7 @@ class Report:
web: List[WebSearchItem] = field(default_factory=list)
youtube: List[YouTubeItem] = field(default_factory=list)
hackernews: List[HackerNewsItem] = field(default_factory=list)
polymarket: List[PolymarketItem] = field(default_factory=list)
best_practices: List[str] = field(default_factory=list)
prompt_pack: List[str] = field(default_factory=list)
context_snippet_md: str = ""
@@ -288,6 +340,7 @@ class Report:
web_error: Optional[str] = None
youtube_error: Optional[str] = None
hackernews_error: Optional[str] = None
polymarket_error: Optional[str] = None
# Handle resolution
resolved_x_handle: Optional[str] = None
# Cache info
@@ -310,6 +363,7 @@ class Report:
'web': [w.to_dict() for w in self.web],
'youtube': [y.to_dict() for y in self.youtube],
'hackernews': [h.to_dict() for h in self.hackernews],
'polymarket': [p.to_dict() for p in self.polymarket],
'best_practices': self.best_practices,
'prompt_pack': self.prompt_pack,
'context_snippet_md': self.context_snippet_md,
@@ -326,6 +380,8 @@ class Report:
d['youtube_error'] = self.youtube_error
if self.hackernews_error:
d['hackernews_error'] = self.hackernews_error
if self.polymarket_error:
d['polymarket_error'] = self.polymarket_error
if self.from_cache:
d['from_cache'] = self.from_cache
if self.cache_age_hours is not None:
@@ -455,6 +511,32 @@ class Report:
cross_refs=h.get('cross_refs', []),
))
# Reconstruct Polymarket items (backward compat: key may not exist)
pm_items = []
for p in data.get('polymarket', []):
eng = None
if p.get('engagement'):
eng = Engagement(**p['engagement'])
subs = SubScores(**p.get('subs', {})) if p.get('subs') else SubScores()
pm_items.append(PolymarketItem(
id=p['id'],
title=p['title'],
question=p.get('question', ''),
url=p['url'],
outcome_prices=p.get('outcome_prices', []),
outcomes_remaining=p.get('outcomes_remaining', 0),
price_movement=p.get('price_movement'),
date=p.get('date'),
date_confidence=p.get('date_confidence', 'high'),
engagement=eng,
end_date=p.get('end_date'),
relevance=p.get('relevance', 0.5),
why_relevant=p.get('why_relevant', ''),
subs=subs,
score=p.get('score', 0),
cross_refs=p.get('cross_refs', []),
))
return cls(
topic=data['topic'],
range_from=range_from,
@@ -468,6 +550,7 @@ class Report:
web=web_items,
youtube=youtube_items,
hackernews=hn_items,
polymarket=pm_items,
best_practices=data.get('best_practices', []),
prompt_pack=data.get('prompt_pack', []),
context_snippet_md=data.get('context_snippet_md', ''),
@@ -476,6 +559,7 @@ class Report:
web_error=data.get('web_error'),
youtube_error=data.get('youtube_error'),
hackernews_error=data.get('hackernews_error'),
polymarket_error=data.get('polymarket_error'),
resolved_x_handle=data.get('resolved_x_handle'),
from_cache=data.get('from_cache', False),
cache_age_hours=data.get('cache_age_hours'),