""" 山寨币舆情监控模块 v1.1 数据源:CoinGecko Trending API + Google News RSS (均免费) 用途:检测山寨币的消息面热度+具体新闻内容,与PA技术面共振加权 """ import os import sys import json import time import sqlite3 import requests import xml.etree.ElementTree as ET from datetime import datetime, timedelta from collections import defaultdict sys.path.insert(0, os.path.dirname(__file__)) COINGECKO_TRENDING_URL = "https://api.coingecko.com/api/v3/search/trending" GOOGLE_NEWS_RSS = "https://news.google.com/rss/search?q={query}&hl=en-US&gl=US&ceid=US:en" DB_PATH = os.getenv("ALPHAX_DB_PATH", os.path.join(os.path.dirname(__file__), "data", "altcoin_monitor.db")) def _get_conn(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def fetch_trending_coins(): """获取 CoinGecko Trending 榜单, 含价格+涨跌幅""" try: r = requests.get(COINGECKO_TRENDING_URL, timeout=15) if r.status_code != 200: return [] data = r.json() coins = [] for idx, c in enumerate(data.get("coins", [])): item = c.get("item", {}) symbol = (item.get("symbol", "") or "").upper() price_data = item.get("data", {}) or {} price_usd = price_data.get("price", 0) or 0 change_pct_dict = price_data.get("price_change_percentage_24h", {}) or {} change_usd = change_pct_dict.get("usd", 0) or 0 coins.append({ "symbol": symbol, "name": item.get("name", ""), "coingecko_id": item.get("id", ""), "trend_rank": idx + 1, "trend_score": item.get("score", 0), "market_cap_rank": item.get("market_cap_rank", 0) or 0, "price_usd": round(float(price_usd), 6), "change_24h_pct": round(float(change_usd), 2), "thumb": item.get("thumb", ""), }) return coins except Exception as e: print(f"[sentiment] fetch_trending error: {e}") return [] def fetch_news_for_coin(symbol, max_results=3): """ 从 Google News RSS 抓取币种相关新闻标题 返回: list of {title, source, published} """ try: query = f"{symbol}+crypto+coin" url = GOOGLE_NEWS_RSS.format(query=query) r = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"}) if r.status_code != 200: return [] root = ET.fromstring(r.text) items = root.findall(".//item") news = [] for item in items[:max_results]: title_el = item.find("title") source_el = item.find("source") pubdate_el = item.find("pubDate") title = title_el.text if title_el is not None else "" source = source_el.text if source_el is not None else "" published = pubdate_el.text if pubdate_el is not None else "" # 清理标题: 去掉末尾的 " - SourceName" if " - " in title: parts = title.rsplit(" - ", 1) if len(parts) == 2 and len(parts[1]) < 30: title = parts[0] news.append({ "title": title[:200], "source": source, "published": published, }) return news except Exception as e: print(f"[sentiment] fetch_news error for {symbol}: {e}") return [] def get_sentiment_scores(symbols=None, max_bonus=2): """获取指定币种的舆情评分""" trending = fetch_trending_coins() if not trending: return {} trending_map = {} for t in trending: if t["symbol"]: trending_map[t["symbol"]] = t previous_trending = _get_previous_trending() prev_symbols = {r["symbol"] for r in previous_trending} scores = {} for t in trending: sym = t["symbol"] full_symbol = f"{sym}/USDT" if symbols and full_symbol not in symbols: continue bonus = 0.0 details = [] if t["trend_rank"] <= 5: bonus += 2.0 details.append(f"Trending Top5(#{t['trend_rank']})") elif t["trend_rank"] <= 10: bonus += 1.0 details.append(f"Trending Top10(#{t['trend_rank']})") elif t["trend_rank"] <= 15: bonus += 0.5 details.append(f"Trending(#{t['trend_rank']})") if sym not in prev_symbols: bonus += 1.0 details.append("new_entry") consecutive_hours = _get_consecutive_trending_hours(sym) if consecutive_hours > 6: decay = max(0.3, 1.0 - (consecutive_hours - 6) * 0.1) bonus *= decay bonus = min(bonus, max_bonus) bonus = round(bonus, 1) if bonus > 0: scores[full_symbol] = { "trending": True, "trend_rank": t["trend_rank"], "bonus": bonus, "details": ", ".join(details), "market_cap_rank": t["market_cap_rank"], "name": t["name"], "price_usd": t.get("price_usd", 0), "change_24h_pct": t.get("change_24h_pct", 0), "coingecko_id": t.get("coingecko_id", ""), } return scores def get_sentiment_alert(holdings=None): """检测舆情异动""" trending = fetch_trending_coins() if not trending: return [] previous_trending = _get_previous_trending() prev_symbols = {r["symbol"] for r in previous_trending} alerts = [] holdings_set = set(holdings) if holdings else set() for t in trending[:10]: sym = t["symbol"] full_symbol = f"{sym}/USDT" if full_symbol in holdings_set and t["trend_rank"] <= 3: alerts.append({ "type": "holding_trending", "symbol": full_symbol, "name": t["name"], "trend_rank": t["trend_rank"], "alert": f"持仓币 {sym} 进入 CoinGecko Trending Top{t['trend_rank']}", }) elif sym not in prev_symbols and t["trend_rank"] <= 10: alerts.append({ "type": "new_trending", "symbol": full_symbol, "name": t["name"], "trend_rank": t["trend_rank"], "alert": f"{sym}({t['name']}) 新进 Trending #{t['trend_rank']}", }) return alerts def collect_and_store(): """采集舆情数据+新闻,写入DB""" trending = fetch_trending_coins() if not trending: return {"status": "error", "message": "Failed to fetch trending"} conn = _get_conn() now = datetime.now().isoformat() # 获取活跃持仓币种,用于新闻优先级 active_recs = conn.execute( "SELECT DISTINCT symbol FROM recommendation WHERE status='active'" ).fetchall() active_bases = {r["symbol"].split("/")[0].upper() for r in active_recs} stored = 0 for i, t in enumerate(trending): # 只对 Top10 + 持仓重叠的币拉新闻 (控制请求量) should_fetch_news = (t["trend_rank"] <= 10 or t["symbol"] in active_bases) news = [] if should_fetch_news: news = fetch_news_for_coin(t["symbol"], max_results=3) if i < 9: # 控制频率,每个请求间隔0.5秒 time.sleep(0.5) try: conn.execute(""" INSERT INTO sentiment_events (symbol, name, source, event_type, trend_rank, trend_score, market_cap_rank, extra_json, detected_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( t["symbol"], t["name"], "coingecko", "trending", t["trend_rank"], t["trend_score"], t["market_cap_rank"], json.dumps({ "price_usd": t.get("price_usd", 0), "change_24h_pct": t.get("change_24h_pct", 0), "thumb": t.get("thumb", ""), "news": news, }), now, )) stored += 1 except Exception as e: print(f"[sentiment] DB insert error for {t['symbol']}: {e}") conn.commit() conn.close() print(f"[sentiment] Stored {stored} trending coins at {now}") return {"status": "ok", "stored": stored, "time": now} def _get_previous_trending(): """获取上一次采集的 trending 记录""" try: conn = _get_conn() row = conn.execute( "SELECT MAX(detected_at) FROM sentiment_events WHERE source='coingecko'" ).fetchone() if not row or not row[0]: conn.close() return [] latest_time = row[0] rows = conn.execute( "SELECT symbol, trend_rank FROM sentiment_events WHERE detected_at = ?", (latest_time,) ).fetchall() conn.close() return [{"symbol": r["symbol"], "trend_rank": r["trend_rank"]} for r in rows] except Exception: return [] def _get_consecutive_trending_hours(symbol): """计算连续在榜小时数""" try: conn = _get_conn() rows = conn.execute(""" SELECT detected_at FROM sentiment_events WHERE symbol = ? AND source = 'coingecko' ORDER BY detected_at DESC LIMIT 20 """, (symbol,)).fetchall() conn.close() if not rows: return 0 consecutive = 1 max_gap = 3600 prev = datetime.fromisoformat(rows[0]["detected_at"]) for r in rows[1:]: curr = datetime.fromisoformat(r["detected_at"]) if (prev - curr).total_seconds() <= max_gap: consecutive += 1 prev = curr else: break return consecutive * 0.5 except Exception: return 0 def get_active_holdings(): """获取当前活跃推荐币种""" try: conn = _get_conn() rows = conn.execute( "SELECT DISTINCT symbol FROM recommendation WHERE status='active'" ).fetchall() conn.close() return [r["symbol"] for r in rows] except Exception: return [] def main(): import argparse parser = argparse.ArgumentParser(description="山寨币舆情监控") parser.add_argument("--collect", action="store_true", help="采集并存储") parser.add_argument("--check", action="store_true", help="检测异动") parser.add_argument("--scores", action="store_true", help="输出评分") args = parser.parse_args() if args.collect: result = collect_and_store() print(json.dumps(result, ensure_ascii=False)) elif args.scores: scores = get_sentiment_scores() print(json.dumps(scores, ensure_ascii=False, indent=2)) else: holdings = get_active_holdings() alerts = get_sentiment_alert(holdings=holdings) if args.check: print(json.dumps(alerts, ensure_ascii=False, indent=2)) else: scores = get_sentiment_scores() output = { "alerts": alerts, "sentiment_scores": scores, "holdings_count": len(holdings), "check_time": datetime.now().isoformat(), } print(json.dumps(output, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()