Dmitry Beresnev
commited on
Commit
·
93bc4a9
1
Parent(s):
650204f
add Kalshi prediction market
Browse files
app/services/prediction_markets.py
CHANGED
|
@@ -34,6 +34,12 @@ class PredictionMarketsScraper:
|
|
| 34 |
'weight': 1.8,
|
| 35 |
'enabled': True
|
| 36 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
'metaculus': {
|
| 38 |
'name': 'Metaculus',
|
| 39 |
'base_url': 'https://www.metaculus.com/api',
|
|
@@ -71,12 +77,15 @@ class PredictionMarketsScraper:
|
|
| 71 |
seen_titles = set()
|
| 72 |
|
| 73 |
# Parallel fetching
|
| 74 |
-
with ThreadPoolExecutor(max_workers=
|
| 75 |
futures = []
|
| 76 |
|
| 77 |
if self.SOURCES['polymarket']['enabled']:
|
| 78 |
futures.append((executor.submit(self._fetch_polymarket), 'polymarket'))
|
| 79 |
|
|
|
|
|
|
|
|
|
|
| 80 |
if self.SOURCES['metaculus']['enabled']:
|
| 81 |
futures.append((executor.submit(self._fetch_metaculus), 'metaculus'))
|
| 82 |
|
|
@@ -310,6 +319,96 @@ class PredictionMarketsScraper:
|
|
| 310 |
logger.error(f"Error fetching Metaculus: {e}")
|
| 311 |
return []
|
| 312 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
def _fetch_cme_fedwatch(self) -> List[Dict]:
|
| 314 |
"""
|
| 315 |
Fetch Fed rate probabilities from CME FedWatch Tool
|
|
@@ -387,6 +486,74 @@ class PredictionMarketsScraper:
|
|
| 387 |
scores = {'macro': macro_score, 'markets': market_score, 'geopolitical': geo_score}
|
| 388 |
return max(scores, key=scores.get) if max(scores.values()) > 0 else 'markets'
|
| 389 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
def _assess_impact(self, volume: float, category: str) -> str:
|
| 391 |
"""Assess market impact based on volume and category"""
|
| 392 |
# Macro predictions are inherently high impact
|
|
|
|
| 34 |
'weight': 1.8,
|
| 35 |
'enabled': True
|
| 36 |
},
|
| 37 |
+
'kalshi': {
|
| 38 |
+
'name': 'Kalshi',
|
| 39 |
+
'base_url': 'https://api.elections.kalshi.com/trade-api/v2',
|
| 40 |
+
'weight': 1.7,
|
| 41 |
+
'enabled': True
|
| 42 |
+
},
|
| 43 |
'metaculus': {
|
| 44 |
'name': 'Metaculus',
|
| 45 |
'base_url': 'https://www.metaculus.com/api',
|
|
|
|
| 77 |
seen_titles = set()
|
| 78 |
|
| 79 |
# Parallel fetching
|
| 80 |
+
with ThreadPoolExecutor(max_workers=4) as executor:
|
| 81 |
futures = []
|
| 82 |
|
| 83 |
if self.SOURCES['polymarket']['enabled']:
|
| 84 |
futures.append((executor.submit(self._fetch_polymarket), 'polymarket'))
|
| 85 |
|
| 86 |
+
if self.SOURCES['kalshi']['enabled']:
|
| 87 |
+
futures.append((executor.submit(self._fetch_kalshi), 'kalshi'))
|
| 88 |
+
|
| 89 |
if self.SOURCES['metaculus']['enabled']:
|
| 90 |
futures.append((executor.submit(self._fetch_metaculus), 'metaculus'))
|
| 91 |
|
|
|
|
| 319 |
logger.error(f"Error fetching Metaculus: {e}")
|
| 320 |
return []
|
| 321 |
|
| 322 |
+
def _fetch_kalshi(self) -> List[Dict]:
|
| 323 |
+
"""Fetch predictions from Kalshi public API (financial events only)"""
|
| 324 |
+
try:
|
| 325 |
+
base_url = self.SOURCES['kalshi']['base_url']
|
| 326 |
+
url = f"{base_url}/events"
|
| 327 |
+
params = {
|
| 328 |
+
'limit': 200,
|
| 329 |
+
'with_nested_markets': True,
|
| 330 |
+
'status': 'open'
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
predictions = []
|
| 334 |
+
cursor = None
|
| 335 |
+
pages = 0
|
| 336 |
+
|
| 337 |
+
while pages < 3:
|
| 338 |
+
if cursor:
|
| 339 |
+
params['cursor'] = cursor
|
| 340 |
+
|
| 341 |
+
response = self.session.get(url, params=params, timeout=15)
|
| 342 |
+
response.raise_for_status()
|
| 343 |
+
data = response.json()
|
| 344 |
+
|
| 345 |
+
events = data.get('events', [])
|
| 346 |
+
for event in events:
|
| 347 |
+
if not self._is_kalshi_financial_event(event):
|
| 348 |
+
continue
|
| 349 |
+
|
| 350 |
+
event_title = event.get('title', '')
|
| 351 |
+
category = self._categorize_prediction(event_title)
|
| 352 |
+
markets = event.get('markets', []) or []
|
| 353 |
+
|
| 354 |
+
for market in markets:
|
| 355 |
+
try:
|
| 356 |
+
if market.get('market_type') and market.get('market_type') != 'binary':
|
| 357 |
+
continue
|
| 358 |
+
|
| 359 |
+
title = market.get('title') or event_title
|
| 360 |
+
if not title or len(title) < 8:
|
| 361 |
+
continue
|
| 362 |
+
|
| 363 |
+
yes_prob = self._kalshi_yes_probability(market)
|
| 364 |
+
if yes_prob is None:
|
| 365 |
+
continue
|
| 366 |
+
|
| 367 |
+
no_prob = 100 - yes_prob
|
| 368 |
+
volume = float(market.get('volume', 0) or 0)
|
| 369 |
+
impact = self._assess_impact(volume, category)
|
| 370 |
+
sentiment = 'positive' if yes_prob > 60 else ('negative' if yes_prob < 40 else 'neutral')
|
| 371 |
+
|
| 372 |
+
close_time_str = market.get('close_time') or market.get('expiration_time')
|
| 373 |
+
end_date = self._parse_iso_datetime(close_time_str)
|
| 374 |
+
|
| 375 |
+
market_ticker = market.get('ticker', '')
|
| 376 |
+
|
| 377 |
+
predictions.append({
|
| 378 |
+
'id': hash(market_ticker or title),
|
| 379 |
+
'title': title,
|
| 380 |
+
'summary': f"Kalshi market: {yes_prob:.1f}% YES, {no_prob:.1f}% NO",
|
| 381 |
+
'source': 'Kalshi',
|
| 382 |
+
'category': category,
|
| 383 |
+
'timestamp': datetime.now(),
|
| 384 |
+
'url': f"{base_url}/markets/{market_ticker}" if market_ticker else base_url,
|
| 385 |
+
'yes_probability': round(yes_prob, 1),
|
| 386 |
+
'no_probability': round(no_prob, 1),
|
| 387 |
+
'volume': volume,
|
| 388 |
+
'end_date': end_date,
|
| 389 |
+
'impact': impact,
|
| 390 |
+
'sentiment': sentiment,
|
| 391 |
+
'is_breaking': False,
|
| 392 |
+
'source_weight': self.SOURCES['kalshi']['weight'],
|
| 393 |
+
'likes': int(volume / 1000),
|
| 394 |
+
'retweets': 0
|
| 395 |
+
})
|
| 396 |
+
|
| 397 |
+
except Exception as e:
|
| 398 |
+
logger.debug(f"Error parsing Kalshi market: {e}")
|
| 399 |
+
continue
|
| 400 |
+
|
| 401 |
+
cursor = data.get('cursor')
|
| 402 |
+
pages += 1
|
| 403 |
+
if not cursor:
|
| 404 |
+
break
|
| 405 |
+
|
| 406 |
+
return predictions
|
| 407 |
+
|
| 408 |
+
except Exception as e:
|
| 409 |
+
logger.error(f"Error fetching Kalshi: {e}")
|
| 410 |
+
return []
|
| 411 |
+
|
| 412 |
def _fetch_cme_fedwatch(self) -> List[Dict]:
|
| 413 |
"""
|
| 414 |
Fetch Fed rate probabilities from CME FedWatch Tool
|
|
|
|
| 486 |
scores = {'macro': macro_score, 'markets': market_score, 'geopolitical': geo_score}
|
| 487 |
return max(scores, key=scores.get) if max(scores.values()) > 0 else 'markets'
|
| 488 |
|
| 489 |
+
def _is_kalshi_financial_event(self, event: Dict) -> bool:
|
| 490 |
+
"""Filter Kalshi events to financial/macro/markets categories"""
|
| 491 |
+
category = (event.get('category') or '').lower()
|
| 492 |
+
title = (event.get('title') or '').lower()
|
| 493 |
+
series_ticker = (event.get('series_ticker') or '').lower()
|
| 494 |
+
|
| 495 |
+
financial_keywords = [
|
| 496 |
+
'econ', 'economic', 'economy', 'finance', 'financial', 'market',
|
| 497 |
+
'inflation', 'cpi', 'ppi', 'gdp', 'jobs', 'employment', 'unemployment',
|
| 498 |
+
'rate', 'interest', 'fed', 'fomc', 'treasury', 'bond', 'recession',
|
| 499 |
+
'stock', 's&p', 'nasdaq', 'dow', 'crypto', 'bitcoin', 'oil', 'fx',
|
| 500 |
+
'usd', 'dollar'
|
| 501 |
+
]
|
| 502 |
+
|
| 503 |
+
if any(kw in category for kw in financial_keywords):
|
| 504 |
+
return True
|
| 505 |
+
|
| 506 |
+
if any(kw in title for kw in financial_keywords):
|
| 507 |
+
return True
|
| 508 |
+
|
| 509 |
+
if any(kw in series_ticker for kw in financial_keywords):
|
| 510 |
+
return True
|
| 511 |
+
|
| 512 |
+
return self._categorize_prediction(event.get('title', '')) in {'macro', 'markets'}
|
| 513 |
+
|
| 514 |
+
def _kalshi_yes_probability(self, market: Dict) -> Optional[float]:
|
| 515 |
+
"""Return YES probability (0-100) from Kalshi market pricing."""
|
| 516 |
+
def to_float(value):
|
| 517 |
+
if value is None or value == '':
|
| 518 |
+
return None
|
| 519 |
+
try:
|
| 520 |
+
return float(value)
|
| 521 |
+
except Exception:
|
| 522 |
+
return None
|
| 523 |
+
|
| 524 |
+
yes_bid_d = to_float(market.get('yes_bid_dollars'))
|
| 525 |
+
yes_ask_d = to_float(market.get('yes_ask_dollars'))
|
| 526 |
+
last_d = to_float(market.get('last_price_dollars'))
|
| 527 |
+
|
| 528 |
+
price = None
|
| 529 |
+
if yes_bid_d is not None and yes_ask_d is not None:
|
| 530 |
+
price = (yes_bid_d + yes_ask_d) / 2
|
| 531 |
+
elif last_d is not None:
|
| 532 |
+
price = last_d
|
| 533 |
+
else:
|
| 534 |
+
yes_bid = to_float(market.get('yes_bid'))
|
| 535 |
+
yes_ask = to_float(market.get('yes_ask'))
|
| 536 |
+
last = to_float(market.get('last_price'))
|
| 537 |
+
if yes_bid is not None and yes_ask is not None:
|
| 538 |
+
price = (yes_bid + yes_ask) / 2 / 100
|
| 539 |
+
elif last is not None:
|
| 540 |
+
price = last / 100
|
| 541 |
+
|
| 542 |
+
if price is None:
|
| 543 |
+
return None
|
| 544 |
+
|
| 545 |
+
price = max(min(price, 1.0), 0.0)
|
| 546 |
+
return price * 100
|
| 547 |
+
|
| 548 |
+
def _parse_iso_datetime(self, value: Optional[str]) -> datetime:
|
| 549 |
+
"""Parse ISO timestamps from Kalshi API with fallback."""
|
| 550 |
+
if not value:
|
| 551 |
+
return datetime.now() + timedelta(days=30)
|
| 552 |
+
try:
|
| 553 |
+
return datetime.fromisoformat(value.replace('Z', '+00:00'))
|
| 554 |
+
except Exception:
|
| 555 |
+
return datetime.now() + timedelta(days=30)
|
| 556 |
+
|
| 557 |
def _assess_impact(self, volume: float, category: str) -> str:
|
| 558 |
"""Assess market impact based on volume and category"""
|
| 559 |
# Macro predictions are inherently high impact
|