Dmitry Beresnev commited on
Commit
93bc4a9
·
1 Parent(s): 650204f

add Kalshi prediction market

Browse files
Files changed (1) hide show
  1. app/services/prediction_markets.py +168 -1
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=3) as executor:
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