335 lines
11 KiB
Python
335 lines
11 KiB
Python
"""
|
||
山寨币舆情监控模块 v1.1
|
||
数据源:CoinGecko Trending API + Google News RSS (均免费)
|
||
用途:检测山寨币的消息面热度+具体新闻内容,与PA技术面共振加权
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
import json
|
||
import time
|
||
import requests
|
||
import xml.etree.ElementTree as ET
|
||
from datetime import datetime, timedelta
|
||
from collections import defaultdict
|
||
from pathlib import Path
|
||
from app.db.schema import get_conn
|
||
|
||
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"
|
||
def _get_conn():
|
||
return get_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 (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||
""", (
|
||
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 = %s",
|
||
(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 = %s 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()
|