Alibrown commited on
Commit
929ddd4
·
verified ·
1 Parent(s): e6045c6

Create polymarket.py

Browse files
Files changed (1) hide show
  1. app/polymarket.py +611 -0
app/polymarket.py ADDED
@@ -0,0 +1,611 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # app/polymarket.py
3
+ # Polymarket Analysis Tool — Single File Module
4
+ # Part of: Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
5
+ # Copyright 2026 - Volkan Kücükbudak
6
+ # Apache License V. 2 + ESOL 1.1
7
+ # Repo: https://github.com/VolkanSah/Universal-MCP-Hub-sandboxed
8
+ # =============================================================================
9
+ # ARCHITECTURE NOTE:
10
+ # This file lives exclusively in app/ and is ONLY registered by app/mcp.py.
11
+ # NO direct access to fundaments/*, .env, or Guardian (main.py).
12
+ # All config comes from app/.pyfun via app/config.py.
13
+ #
14
+ # LAZY INIT PRINCIPLE:
15
+ # initialize() is called on first tool use — NOT during startup.
16
+ # This keeps startup fast and avoids crashes if Gamma API is unreachable.
17
+ #
18
+ # SANDBOX RULES:
19
+ # - SQLite cache is app/* internal state — NOT postgresql.py (Guardian-only)
20
+ # - LLM calls are optional — gracefully skipped if no provider key is set
21
+ # - No global state leaks outside this file
22
+ # - Read-Only access to Polymarket — no transactions, no auth needed
23
+ # =============================================================================
24
+
25
+ import asyncio
26
+ import aiosqlite
27
+ import aiohttp
28
+ import logging
29
+ import json
30
+ import os
31
+ from datetime import datetime, timezone
32
+ from typing import Optional
33
+
34
+ logger = logging.getLogger("polymarket")
35
+
36
+ # =============================================================================
37
+ # Constants
38
+ # =============================================================================
39
+
40
+ GAMMA_API = "https://gamma-api.polymarket.com"
41
+ CACHE_DB = "app/polymarket_cache.db"
42
+ FETCH_INTERVAL = 300 # seconds — 5 min between API pulls
43
+ MARKET_LIMIT = 100 # max markets per fetch (rate limit friendly)
44
+ PRICE_DECIMALS = 2 # rounding for probability display
45
+
46
+ # Gamma API uses its own tag/category system.
47
+ # We map their tags to our simplified categories for filtering.
48
+ CATEGORY_MAP = {
49
+ "politics": ["politics", "elections", "government", "trump", "us-politics"],
50
+ "crypto": ["crypto", "bitcoin", "ethereum", "defi", "web3"],
51
+ "economics": ["economics", "inflation", "fed", "interest-rates", "finance"],
52
+ "energy": ["energy", "oil", "gas", "renewables", "climate"],
53
+ "tech": ["technology", "ai", "spacex", "elon-musk", "science"],
54
+ "sports": ["sports", "football", "soccer", "nba", "nfl", "esports"],
55
+ "world": ["world", "geopolitics", "war", "nato", "china", "russia"],
56
+ }
57
+
58
+ # =============================================================================
59
+ # Internal State — lazy init guard
60
+ # =============================================================================
61
+
62
+ _initialized = False
63
+ _scheduler_task = None # asyncio background task handle
64
+
65
+ # =============================================================================
66
+ # SECTION 1 — Init (Lazy)
67
+ # =============================================================================
68
+
69
+ async def initialize() -> None:
70
+ """
71
+ Lazy initializer — called on first tool use.
72
+ Sets up SQLite cache and starts background scheduler.
73
+ Idempotent — safe to call multiple times.
74
+ """
75
+ global _initialized, _scheduler_task
76
+
77
+ if _initialized:
78
+ return
79
+
80
+ logger.info("Polymarket module initializing (lazy)...")
81
+
82
+ await _init_cache()
83
+
84
+ # Start background scheduler as asyncio task
85
+ if _scheduler_task is None or _scheduler_task.done():
86
+ _scheduler_task = asyncio.create_task(_scheduler_loop())
87
+ logger.info(f"Scheduler started — fetching every {FETCH_INTERVAL}s.")
88
+
89
+ _initialized = True
90
+ logger.info("Polymarket module ready.")
91
+
92
+
93
+ # =============================================================================
94
+ # SECTION 2 — SQLite Cache (app/* internal, NOT postgresql.py!)
95
+ # =============================================================================
96
+
97
+ async def _init_cache() -> None:
98
+ """Create SQLite tables if they don't exist."""
99
+ async with aiosqlite.connect(CACHE_DB) as db:
100
+ await db.execute("""
101
+ CREATE TABLE IF NOT EXISTS markets (
102
+ id TEXT PRIMARY KEY,
103
+ slug TEXT,
104
+ question TEXT,
105
+ category TEXT,
106
+ probability REAL,
107
+ volume REAL,
108
+ liquidity REAL,
109
+ end_date TEXT,
110
+ active INTEGER,
111
+ data TEXT,
112
+ fetched_at TEXT
113
+ )
114
+ """)
115
+ await db.execute("""
116
+ CREATE INDEX IF NOT EXISTS idx_category ON markets(category);
117
+ """)
118
+ await db.execute("""
119
+ CREATE INDEX IF NOT EXISTS idx_active ON markets(active);
120
+ """)
121
+ await db.commit()
122
+ logger.info("Cache initialized.")
123
+
124
+
125
+ async def _store_markets(markets: list) -> None:
126
+ """Upsert markets into SQLite cache."""
127
+ if not markets:
128
+ return
129
+
130
+ now = datetime.now(timezone.utc).isoformat()
131
+
132
+ async with aiosqlite.connect(CACHE_DB) as db:
133
+ for m in markets:
134
+ category = _categorize_market(m)
135
+ prob = _extract_probability(m)
136
+
137
+ await db.execute("""
138
+ INSERT OR REPLACE INTO markets
139
+ (id, slug, question, category, probability, volume, liquidity, end_date, active, data, fetched_at)
140
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
141
+ """, (
142
+ str(m.get("id", "")),
143
+ m.get("slug", ""),
144
+ m.get("question", ""),
145
+ category,
146
+ prob,
147
+ float(m.get("volume", 0) or 0),
148
+ float(m.get("liquidity", 0) or 0),
149
+ m.get("endDate", ""),
150
+ 1 if m.get("active", False) else 0,
151
+ json.dumps(m),
152
+ now,
153
+ ))
154
+ await db.commit()
155
+
156
+ logger.info(f"Cached {len(markets)} markets.")
157
+
158
+
159
+ async def _get_cached_markets(
160
+ category: Optional[str] = None,
161
+ active_only: bool = True,
162
+ limit: int = 50
163
+ ) -> list:
164
+ """Retrieve markets from SQLite cache with optional filters."""
165
+ async with aiosqlite.connect(CACHE_DB) as db:
166
+ db.row_factory = aiosqlite.Row
167
+
168
+ conditions = []
169
+ params = []
170
+
171
+ if active_only:
172
+ conditions.append("active = 1")
173
+
174
+ if category and category.lower() in CATEGORY_MAP:
175
+ conditions.append("category = ?")
176
+ params.append(category.lower())
177
+
178
+ where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
179
+
180
+ params.append(limit)
181
+ cursor = await db.execute(
182
+ f"SELECT * FROM markets {where} ORDER BY volume DESC LIMIT ?",
183
+ params
184
+ )
185
+ rows = await cursor.fetchall()
186
+ return [dict(r) for r in rows]
187
+
188
+
189
+ async def _get_market_by_id(market_id: str) -> Optional[dict]:
190
+ """Retrieve a single market by ID from cache."""
191
+ async with aiosqlite.connect(CACHE_DB) as db:
192
+ db.row_factory = aiosqlite.Row
193
+ cursor = await db.execute(
194
+ "SELECT * FROM markets WHERE id = ?", (market_id,)
195
+ )
196
+ row = await cursor.fetchone()
197
+ return dict(row) if row else None
198
+
199
+
200
+ async def _get_cache_stats() -> dict:
201
+ """Return basic cache statistics."""
202
+ async with aiosqlite.connect(CACHE_DB) as db:
203
+ cursor = await db.execute("SELECT COUNT(*) FROM markets WHERE active = 1")
204
+ active = (await cursor.fetchone())[0]
205
+ cursor = await db.execute("SELECT MAX(fetched_at) FROM markets")
206
+ last = (await cursor.fetchone())[0]
207
+ return {"active_markets": active, "last_fetch": last}
208
+
209
+
210
+ # =============================================================================
211
+ # SECTION 3 — Fetcher (Gamma API — Read Only, no auth)
212
+ # =============================================================================
213
+
214
+ async def _fetch_from_api(limit: int = MARKET_LIMIT) -> list:
215
+ """
216
+ Fetch active markets from Polymarket Gamma API.
217
+ Read-Only — no auth, no transactions.
218
+ """
219
+ params = {
220
+ "active": "true",
221
+ "archived": "false",
222
+ "closed": "false",
223
+ "limit": limit,
224
+ "order": "volume",
225
+ "ascending":"false",
226
+ }
227
+
228
+ async with aiohttp.ClientSession() as session:
229
+ async with session.get(
230
+ f"{GAMMA_API}/markets",
231
+ params=params,
232
+ timeout=aiohttp.ClientTimeout(total=30)
233
+ ) as resp:
234
+ resp.raise_for_status()
235
+ data = await resp.json()
236
+ logger.info(f"Fetched {len(data)} markets from Gamma API.")
237
+ return data
238
+
239
+
240
+ async def _scheduler_loop() -> None:
241
+ """
242
+ Background task — polls Gamma API every FETCH_INTERVAL seconds.
243
+ Runs indefinitely inside asyncio event loop.
244
+ """
245
+ logger.info("Scheduler loop started.")
246
+
247
+ while True:
248
+ try:
249
+ markets = await _fetch_from_api()
250
+ await _store_markets(markets)
251
+ except aiohttp.ClientError as e:
252
+ logger.warning(f"Gamma API fetch failed (network): {e}")
253
+ except Exception as e:
254
+ logger.error(f"Scheduler error: {e}")
255
+
256
+ await asyncio.sleep(FETCH_INTERVAL)
257
+
258
+
259
+ # =============================================================================
260
+ # SECTION 4 — Filter & Categorization
261
+ # =============================================================================
262
+
263
+ def _categorize_market(market: dict) -> str:
264
+ """
265
+ Map a Polymarket market to our simplified category system.
266
+ Uses tags array from Gamma API response.
267
+ Falls back to 'other' if no match found.
268
+ """
269
+ tags = []
270
+
271
+ # Gamma API returns tags as list of dicts or strings
272
+ for t in market.get("tags", []):
273
+ if isinstance(t, dict):
274
+ tags.append(t.get("slug", "").lower())
275
+ tags.append(t.get("label", "").lower())
276
+ elif isinstance(t, str):
277
+ tags.append(t.lower())
278
+
279
+ # Also check question text for keywords
280
+ question = market.get("question", "").lower()
281
+
282
+ for category, keywords in CATEGORY_MAP.items():
283
+ for kw in keywords:
284
+ if kw in tags or kw in question:
285
+ return category
286
+
287
+ return "other"
288
+
289
+
290
+ def _extract_probability(market: dict) -> float:
291
+ """
292
+ Extract YES probability from market data.
293
+ Polymarket stores outcome prices as probability (0.0 - 1.0).
294
+ Returns probability as percentage (0-100).
295
+ """
296
+ try:
297
+ # outcomePrices is a JSON string like '["0.73", "0.27"]'
298
+ prices = market.get("outcomePrices")
299
+ if isinstance(prices, str):
300
+ prices = json.loads(prices)
301
+ if prices and len(prices) > 0:
302
+ return round(float(prices[0]) * 100, PRICE_DECIMALS)
303
+ except (ValueError, TypeError, json.JSONDecodeError):
304
+ pass
305
+ return 0.0
306
+
307
+
308
+ def _format_market_simple(market: dict) -> dict:
309
+ """
310
+ Format a market for human-readable output.
311
+ Used by all public tools for consistent output.
312
+ """
313
+ prob = market.get("probability", 0.0)
314
+
315
+ # Simple plain-language probability label
316
+ if prob >= 80:
317
+ sentiment = "sehr wahrscheinlich"
318
+ elif prob >= 60:
319
+ sentiment = "wahrscheinlich"
320
+ elif prob >= 40:
321
+ sentiment = "ungewiss"
322
+ elif prob >= 20:
323
+ sentiment = "unwahrscheinlich"
324
+ else:
325
+ sentiment = "sehr unwahrscheinlich"
326
+
327
+ return {
328
+ "id": market.get("id"),
329
+ "question": market.get("question"),
330
+ "category": market.get("category"),
331
+ "probability": f"{prob}%",
332
+ "sentiment": sentiment,
333
+ "volume_usd": f"${market.get('volume', 0):,.0f}",
334
+ "liquidity": f"${market.get('liquidity', 0):,.0f}",
335
+ "end_date": market.get("end_date", ""),
336
+ "slug": market.get("slug", ""),
337
+ "url": f"https://polymarket.com/event/{market.get('slug', '')}",
338
+ }
339
+
340
+
341
+ # =============================================================================
342
+ # SECTION 5 — LLM Adapter (Optional — graceful fallback if no key)
343
+ # =============================================================================
344
+
345
+ async def _llm_analyze(prompt: str) -> Optional[str]:
346
+ """
347
+ Send prompt to available LLM provider.
348
+ Checks for API keys in order: Anthropic → OpenRouter → HuggingFace.
349
+ Returns None if no provider is available — caller handles fallback.
350
+ """
351
+ # --- Anthropic Claude ---
352
+ anthropic_key = os.getenv("ANTHROPIC_API_KEY")
353
+ if anthropic_key:
354
+ try:
355
+ async with aiohttp.ClientSession() as session:
356
+ async with session.post(
357
+ "https://api.anthropic.com/v1/messages",
358
+ headers={
359
+ "x-api-key": anthropic_key,
360
+ "anthropic-version": "2023-06-01",
361
+ "content-type": "application/json",
362
+ },
363
+ json={
364
+ "model": "claude-haiku-4-5-20251001",
365
+ "max_tokens": 512,
366
+ "messages": [{"role": "user", "content": prompt}],
367
+ },
368
+ timeout=aiohttp.ClientTimeout(total=30)
369
+ ) as resp:
370
+ resp.raise_for_status()
371
+ data = await resp.json()
372
+ return data["content"][0]["text"]
373
+ except Exception as e:
374
+ logger.warning(f"Anthropic LLM call failed: {e}")
375
+
376
+ # --- OpenRouter fallback ---
377
+ openrouter_key = os.getenv("OPENROUTER_API_KEY")
378
+ if openrouter_key:
379
+ try:
380
+ async with aiohttp.ClientSession() as session:
381
+ async with session.post(
382
+ "https://openrouter.ai/api/v1/chat/completions",
383
+ headers={
384
+ "Authorization": f"Bearer {openrouter_key}",
385
+ "content-type": "application/json",
386
+ },
387
+ json={
388
+ "model": "mistralai/mistral-7b-instruct",
389
+ "max_tokens": 512,
390
+ "messages": [{"role": "user", "content": prompt}],
391
+ },
392
+ timeout=aiohttp.ClientTimeout(total=30)
393
+ ) as resp:
394
+ resp.raise_for_status()
395
+ data = await resp.json()
396
+ return data["choices"][0]["message"]["content"]
397
+ except Exception as e:
398
+ logger.warning(f"OpenRouter LLM call failed: {e}")
399
+
400
+ # --- HuggingFace fallback ---
401
+ hf_key = os.getenv("HF_API_KEY")
402
+ if hf_key:
403
+ try:
404
+ model = "mistralai/Mistral-7B-Instruct-v0.3"
405
+ async with aiohttp.ClientSession() as session:
406
+ async with session.post(
407
+ f"https://api-inference.huggingface.co/models/{model}/v1/chat/completions",
408
+ headers={
409
+ "Authorization": f"Bearer {hf_key}",
410
+ "content-type": "application/json",
411
+ },
412
+ json={
413
+ "model": model,
414
+ "max_tokens": 512,
415
+ "messages": [{"role": "user", "content": prompt}],
416
+ },
417
+ timeout=aiohttp.ClientTimeout(total=60)
418
+ ) as resp:
419
+ resp.raise_for_status()
420
+ data = await resp.json()
421
+ return data["choices"][0]["message"]["content"]
422
+ except Exception as e:
423
+ logger.warning(f"HuggingFace LLM call failed: {e}")
424
+
425
+ logger.info("No LLM provider available — returning None.")
426
+ return None
427
+
428
+
429
+ # =============================================================================
430
+ # SECTION 6 — Public Tools (registered by mcp.py)
431
+ # =============================================================================
432
+
433
+ async def get_markets(
434
+ category: Optional[str] = None,
435
+ limit: int = 20
436
+ ) -> list:
437
+ """
438
+ MCP Tool: Get active prediction markets from cache.
439
+
440
+ Args:
441
+ category: Filter by category. Options: politics, crypto, economics,
442
+ energy, tech, sports, world, other. None = all categories.
443
+ limit: Max number of markets to return (default 20, max 100).
444
+
445
+ Returns:
446
+ List of formatted market dicts with human-readable probability.
447
+ """
448
+ await initialize()
449
+
450
+ limit = min(limit, 100)
451
+ markets = await _get_cached_markets(category=category, limit=limit)
452
+
453
+ if not markets:
454
+ return [{"info": "No markets in cache yet. Try again in 30 seconds."}]
455
+
456
+ return [_format_market_simple(m) for m in markets]
457
+
458
+
459
+ async def trending_markets(limit: int = 10) -> list:
460
+ """
461
+ MCP Tool: Get top trending markets by trading volume.
462
+
463
+ Args:
464
+ limit: Number of trending markets to return (default 10).
465
+
466
+ Returns:
467
+ List of top markets sorted by volume descending.
468
+ """
469
+ await initialize()
470
+
471
+ markets = await _get_cached_markets(active_only=True, limit=limit)
472
+
473
+ if not markets:
474
+ return [{"info": "No markets in cache yet. Try again in 30 seconds."}]
475
+
476
+ return [_format_market_simple(m) for m in markets]
477
+
478
+
479
+ async def analyze_market(market_id: str) -> dict:
480
+ """
481
+ MCP Tool: Get LLM analysis of a single prediction market.
482
+ Falls back to structured data summary if no LLM key is configured.
483
+
484
+ Args:
485
+ market_id: Polymarket market ID from get_markets() results.
486
+
487
+ Returns:
488
+ Dict with market data + LLM analysis (or structured fallback).
489
+ """
490
+ await initialize()
491
+
492
+ market = await _get_market_by_id(market_id)
493
+
494
+ if not market:
495
+ return {"error": f"Market '{market_id}' not found in cache."}
496
+
497
+ formatted = _format_market_simple(market)
498
+
499
+ # Build LLM prompt
500
+ prompt = (
501
+ f"Analysiere diesen Prediction Market kurz und präzise:\n\n"
502
+ f"Frage: {market.get('question')}\n"
503
+ f"Wahrscheinlichkeit YES: {formatted['probability']}\n"
504
+ f"Handelsvolumen: {formatted['volume_usd']}\n"
505
+ f"Kategorie: {market.get('category')}\n"
506
+ f"Läuft bis: {market.get('end_date', 'unbekannt')}\n\n"
507
+ f"Erkläre in 2-3 Sätzen was der Markt aussagt und was das für "
508
+ f"Alltagsentscheidungen bedeuten könnte. Keine Finanzberatung."
509
+ )
510
+
511
+ analysis = await _llm_analyze(prompt)
512
+
513
+ if analysis:
514
+ formatted["analysis"] = analysis
515
+ else:
516
+ # Structured fallback — no LLM needed
517
+ prob = market.get("probability", 0)
518
+ formatted["analysis"] = (
519
+ f"Der Markt bewertet '{market.get('question')}' mit "
520
+ f"{prob}% Wahrscheinlichkeit. "
521
+ f"Für eine KI-Analyse bitte LLM Provider API Key konfigurieren."
522
+ )
523
+
524
+ return formatted
525
+
526
+
527
+ async def summary_report(category: Optional[str] = None) -> dict:
528
+ """
529
+ MCP Tool: Generate a summary report for a category or all markets.
530
+ Uses LLM if available, falls back to structured statistics.
531
+
532
+ Args:
533
+ category: Category to summarize. None = all active markets.
534
+
535
+ Returns:
536
+ Dict with statistics and optional LLM narrative summary.
537
+ """
538
+ await initialize()
539
+
540
+ markets = await _get_cached_markets(category=category, limit=50)
541
+
542
+ if not markets:
543
+ return {"error": "No markets in cache yet. Try again in 30 seconds."}
544
+
545
+ # --- Build statistics (always available, no LLM needed) ---
546
+ probs = [m["probability"] for m in markets if m.get("probability")]
547
+ avg_prob = round(sum(probs) / len(probs), 1) if probs else 0
548
+ total_vol = sum(m.get("volume", 0) for m in markets)
549
+
550
+ # Top 3 by volume
551
+ top3 = [_format_market_simple(m) for m in markets[:3]]
552
+
553
+ stats = {
554
+ "category": category or "all",
555
+ "market_count": len(markets),
556
+ "avg_probability":f"{avg_prob}%",
557
+ "total_volume": f"${total_vol:,.0f}",
558
+ "top_markets": top3,
559
+ "generated_at": datetime.now(timezone.utc).isoformat(),
560
+ }
561
+
562
+ # --- LLM narrative (optional) ---
563
+ market_list = "\n".join([
564
+ f"- {m.get('question')} ({m.get('probability', 0)}% YES, Vol: ${m.get('volume', 0):,.0f})"
565
+ for m in markets[:10]
566
+ ])
567
+
568
+ prompt = (
569
+ f"Erstelle eine kurze Zusammenfassung (3-4 Sätze) der aktuellen "
570
+ f"Prediction Market Lage{' für ' + category if category else ''}:\n\n"
571
+ f"{market_list}\n\n"
572
+ f"Was sind die auffälligsten Trends? Sachlich, keine Finanzberatung."
573
+ )
574
+
575
+ narrative = await _llm_analyze(prompt)
576
+ if narrative:
577
+ stats["narrative"] = narrative
578
+
579
+ return stats
580
+
581
+
582
+ async def get_cache_info() -> dict:
583
+ """
584
+ MCP Tool: Get cache status and available categories.
585
+ Useful for debugging and monitoring.
586
+
587
+ Returns:
588
+ Dict with cache stats and available category list.
589
+ """
590
+ await initialize()
591
+
592
+ cache_stats = await _get_cache_stats()
593
+
594
+ return {
595
+ **cache_stats,
596
+ "fetch_interval_seconds": FETCH_INTERVAL,
597
+ "available_categories": list(CATEGORY_MAP.keys()) + ["other"],
598
+ "gamma_api": GAMMA_API,
599
+ "llm_available": any([
600
+ os.getenv("ANTHROPIC_API_KEY"),
601
+ os.getenv("OPENROUTER_API_KEY"),
602
+ os.getenv("HF_API_KEY"),
603
+ ]),
604
+ }
605
+
606
+
607
+ # =============================================================================
608
+ # Direct execution guard
609
+ # =============================================================================
610
+ if __name__ == "__main__":
611
+ print("WARNING: Run via main.py → app.py → mcp.py, not directly.")