- Fix 71 invalid-syntax files (class-body newline-broken assignments) - Add from/None chain to 307 B904 raise-without-from sites - Add B008 ignore to ruff.toml (already in pyproject.toml) - Noqa F401 on __init__.py re-exports (137 sites) - Noqa E402 on deferred imports (63 sites) - Bulk-add stdlib/FastAPI/project imports for F821 (127 sites) - Replace ×→x, –→-, …→... in docstrings (4093 chars) - Manual refactor of 5 SIM103/SIM116 patterns Tests: 791 passed (66 deselected due to pre-existing Redis issues in test_rag.py) Co-authored-by: opencode <opencode@rugmunch.io>
295 lines
10 KiB
Python
295 lines
10 KiB
Python
"""
|
|
RugCharts News & Market Data Provider
|
|
======================================
|
|
Wires ALL free data sources into DataBus:
|
|
- CoinGecko (prices, trending)
|
|
- Alternative.me (Fear & Greed Index)
|
|
- Polymarket (prediction markets)
|
|
- CryptoPanic (news sentiment - if key)
|
|
- CoinMarketCap (free tier)
|
|
- Our 200+ RSS feeds from news_service
|
|
- Existing MCP servers (feargreed, prediction-market, jupiter)
|
|
|
|
Every endpoint accessible via DataBus. Every response enriched.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import time
|
|
from datetime import UTC, datetime
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger("news_provider")
|
|
|
|
# ── Free API endpoints (no keys needed) ────────────────────────────
|
|
|
|
COINGECKO_BASE = "https://api.coingecko.com/api/v3"
|
|
FEAR_GREED_URL = "https://api.alternative.me/fng/?limit=1"
|
|
POLYMARKET_URL = "https://gamma-api.polymarket.com/events"
|
|
COINGECKO_KEY = os.getenv("COINGECKO_API_KEY", "")
|
|
|
|
CACHE = {} # Simple in-memory cache with TTL
|
|
CACHE_TTL = 60
|
|
|
|
|
|
def _cached(key: str, ttl: int = 60) -> dict | None:
|
|
if key in CACHE:
|
|
data, ts = CACHE[key]
|
|
if time.time() - ts < ttl:
|
|
return data
|
|
return None
|
|
|
|
|
|
def _cache_set(key: str, data: dict):
|
|
CACHE[key] = (data, time.time())
|
|
|
|
|
|
# ── 1. COINGECKO - Prices, trending, market data ───────────────────
|
|
|
|
|
|
async def get_market_prices(coins: str = "bitcoin,ethereum,solana", **kw) -> dict | None:
|
|
"""Live crypto prices from CoinGecko. Free tier, no key needed."""
|
|
cache_key = f"prices:{coins}"
|
|
cached = _cached(cache_key, 30)
|
|
if cached:
|
|
return cached
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
headers = {}
|
|
if COINGECKO_KEY:
|
|
headers["x-cg-demo-api-key"] = COINGECKO_KEY
|
|
r = await c.get(
|
|
f"{COINGECKO_BASE}/simple/price",
|
|
params={
|
|
"ids": coins,
|
|
"vs_currencies": "usd",
|
|
"include_24hr_change": "true",
|
|
"include_market_cap": "true",
|
|
},
|
|
headers=headers,
|
|
)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
result = {
|
|
"prices": data,
|
|
"timestamp": datetime.now(UTC).isoformat(),
|
|
"source": "coingecko",
|
|
"free": True,
|
|
}
|
|
_cache_set(cache_key, result)
|
|
return result
|
|
except Exception as e:
|
|
logger.warning(f"CoinGecko failed: {e}")
|
|
return None
|
|
|
|
|
|
async def get_trending_coins(**kw) -> dict | None:
|
|
"""Trending coins from CoinGecko search."""
|
|
cache_key = "trending"
|
|
cached = _cached(cache_key, 120)
|
|
if cached:
|
|
return cached
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
headers = {}
|
|
if COINGECKO_KEY:
|
|
headers["x-cg-demo-api-key"] = COINGECKO_KEY
|
|
r = await c.get(f"{COINGECKO_BASE}/search/trending", headers=headers)
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
coins = data.get("coins", [])[:10]
|
|
result = {
|
|
"trending": [
|
|
{
|
|
"name": c["item"]["name"],
|
|
"symbol": c["item"]["symbol"],
|
|
"market_cap_rank": c["item"].get("market_cap_rank"),
|
|
"score": c["item"].get("score"),
|
|
}
|
|
for c in coins
|
|
],
|
|
"source": "coingecko",
|
|
"free": True,
|
|
}
|
|
_cache_set(cache_key, result)
|
|
return result
|
|
except Exception as e:
|
|
logger.warning(f"Trending failed: {e}")
|
|
return None
|
|
|
|
|
|
# ── 2. FEAR & GREED INDEX - Alternative.me ─────────────────────────
|
|
|
|
|
|
async def get_fear_greed(**kw) -> dict | None:
|
|
"""Crypto Fear & Greed Index. Completely free, no key."""
|
|
cache_key = "fear_greed"
|
|
cached = _cached(cache_key, 300)
|
|
if cached:
|
|
return cached
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
r = await c.get(FEAR_GREED_URL)
|
|
if r.status_code == 200:
|
|
data = r.json().get("data", [{}])[0]
|
|
value = int(data.get("value", 50))
|
|
classification = data.get("value_classification", "Neutral")
|
|
|
|
# Map to color and sentiment
|
|
if value <= 25:
|
|
color, sentiment = "#ff0044", "Extreme Fear"
|
|
elif value <= 45:
|
|
color, sentiment = "#ff8800", "Fear"
|
|
elif value <= 55:
|
|
color, sentiment = "#ffd700", "Neutral"
|
|
elif value <= 75:
|
|
color, sentiment = "#88ff00", "Greed"
|
|
else:
|
|
color, sentiment = "#00ff88", "Extreme Greed"
|
|
|
|
result = {
|
|
"value": value,
|
|
"classification": classification,
|
|
"sentiment": sentiment,
|
|
"color": color,
|
|
"timestamp": data.get("timestamp"),
|
|
"source": "alternative.me",
|
|
"free": True,
|
|
}
|
|
_cache_set(cache_key, result)
|
|
return result
|
|
except Exception as e:
|
|
logger.warning(f"Fear & Greed failed: {e}")
|
|
return None
|
|
|
|
|
|
# ── 3. POLYMARKET - Prediction markets ─────────────────────────────
|
|
|
|
|
|
async def get_prediction_markets(limit: int = 5, tag: str = "crypto", **kw) -> dict | None:
|
|
"""Prediction market events from Polymarket. Free, no key."""
|
|
cache_key = f"polymarket:{tag}:{limit}"
|
|
cached = _cached(cache_key, 120)
|
|
if cached:
|
|
return cached
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as c:
|
|
params = {"limit": limit, "active": "true", "closed": "false"}
|
|
if tag:
|
|
params["tag"] = tag
|
|
r = await c.get(POLYMARKET_URL, params=params)
|
|
if r.status_code == 200:
|
|
events = r.json()
|
|
result = {
|
|
"markets": [
|
|
{
|
|
"title": e.get("title", ""),
|
|
"volume": e.get("volume", 0),
|
|
"liquidity": e.get("liquidity", 0),
|
|
"end_date": e.get("endDate", ""),
|
|
"slug": e.get("slug", ""),
|
|
}
|
|
for e in events[:limit]
|
|
],
|
|
"total": len(events),
|
|
"source": "polymarket",
|
|
"free": True,
|
|
}
|
|
_cache_set(cache_key, result)
|
|
return result
|
|
except Exception as e:
|
|
logger.warning(f"Polymarket failed: {e}")
|
|
return None
|
|
|
|
|
|
# ── 4. COMBINED MARKET BRIEF - All sources in one call ─────────────
|
|
|
|
|
|
async def get_market_brief(**kw) -> dict | None:
|
|
"""One-call market overview: prices + fear/greed + trending + prediction markets."""
|
|
prices_task = get_market_prices()
|
|
fear_task = get_fear_greed()
|
|
trending_task = get_trending_coins()
|
|
poly_task = get_prediction_markets(limit=3)
|
|
|
|
prices = await prices_task
|
|
fear = await fear_task
|
|
trending = await trending_task
|
|
polymarket = await poly_task
|
|
|
|
# Build natural-language brief
|
|
brief_parts = []
|
|
if fear:
|
|
brief_parts.append(f"Market sentiment: {fear['classification']} ({fear['value']}/100)")
|
|
if prices:
|
|
for coin, data in prices.get("prices", {}).items():
|
|
change = data.get("usd_24h_change", 0) or 0
|
|
emoji = "🔴" if change < -3 else "🟠" if change < 0 else "🟢"
|
|
brief_parts.append(f"{emoji} {coin.title()}: ${data.get('usd', 0):,.0f} ({change:+.1f}%)")
|
|
|
|
return {
|
|
"brief": " | ".join(brief_parts),
|
|
"prices": prices,
|
|
"fear_greed": fear,
|
|
"trending": trending,
|
|
"prediction_markets": polymarket,
|
|
"generated_at": datetime.now(UTC).isoformat(),
|
|
"sources": ["coingecko", "alternative.me", "polymarket"],
|
|
"source": "market_brief",
|
|
"free": True,
|
|
}
|
|
|
|
|
|
# ── 5. NEWS AGGREGATION - from our existing 200+ feeds ─────────────
|
|
|
|
|
|
async def get_aggregated_news(limit: int = 20, category: str = "", **kw) -> dict | None:
|
|
"""Pull news from our existing news_service.py aggregator."""
|
|
try:
|
|
from app.news_service import fetch_all_news
|
|
|
|
articles = await fetch_all_news()
|
|
if articles:
|
|
if category:
|
|
articles = [
|
|
a for a in articles if category.lower() in (a.get("category", "") + a.get("source", "")).lower()
|
|
]
|
|
result = {
|
|
"articles": articles[:limit],
|
|
"total": len(articles),
|
|
"filtered": len(articles[:limit]),
|
|
"source": "rmi_news_aggregator",
|
|
"free": True,
|
|
}
|
|
return result
|
|
except Exception as e:
|
|
logger.warning(f"News aggregation failed: {e}")
|
|
return None
|
|
|
|
|
|
# ── 6. COMBINED NEWS + MARKET - The full picture ───────────────────
|
|
|
|
|
|
async def get_full_news_feed(limit: int = 15, **kw) -> dict | None:
|
|
"""Complete news page data: headlines + prices + fear/greed + trending + polymarket."""
|
|
brief = await get_market_brief()
|
|
news = await get_aggregated_news(limit=limit)
|
|
|
|
return {
|
|
"market_brief": brief,
|
|
"headlines": news,
|
|
"generated_at": datetime.now(UTC).isoformat(),
|
|
"data_sources": [
|
|
"CoinGecko (prices, trending)",
|
|
"Alternative.me (Fear & Greed Index)",
|
|
"Polymarket (prediction markets)",
|
|
"RMI News Aggregator (200+ RSS feeds, 15 tiers)",
|
|
],
|
|
"source": "full_news_feed",
|
|
"free": True,
|
|
}
|