alphax/sentiment_monitor.py
2026-05-13 22:32:50 +08:00

339 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
山寨币舆情监控模块 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()