332 lines
14 KiB
Python
332 lines
14 KiB
Python
import json
|
|
import os
|
|
from datetime import datetime, timezone, timedelta
|
|
from pathlib import Path
|
|
|
|
from fastapi import APIRouter, Cookie
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from app.web.shared import require_api_user_with_subscription
|
|
from app.services.llm_insights import attach_sentiment_insights, get_latest_sentiment_batch_analysis, get_latest_sentiment_batch_attempt
|
|
from app.db.schema import get_conn
|
|
|
|
|
|
def _newsfeed_payload():
|
|
import requests as req
|
|
import xml.etree.ElementTree as ET
|
|
from email.utils import parsedate_to_datetime
|
|
|
|
result = {"fear_greed": None, "trending": [], "news": []}
|
|
now = datetime.now(timezone.utc)
|
|
|
|
try:
|
|
r = req.get("https://api.alternative.me/fng/?limit=1", timeout=8)
|
|
if r.status_code == 200:
|
|
d0 = r.json().get("data", [{}])[0]
|
|
result["fear_greed"] = {"value": int(d0.get("value", 50)), "classification": d0.get("value_classification", "")}
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
r = req.get("https://api.coingecko.com/api/v3/search/trending", timeout=10)
|
|
if r.status_code == 200:
|
|
for c in r.json().get("coins", [])[:7]:
|
|
item = c.get("item", {})
|
|
result["trending"].append({
|
|
"name": item.get("name", ""),
|
|
"symbol": item.get("symbol", ""),
|
|
"market_cap_rank": item.get("market_cap_rank"),
|
|
"thumb": item.get("thumb", ""),
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
def fetch_google_news(query, hl, gl, ceid, label):
|
|
items = []
|
|
try:
|
|
url = f"https://news.google.com/rss/search?q={req.utils.quote(query)}&hl={hl}&gl={gl}&ceid={ceid}"
|
|
r = req.get(url, timeout=12, headers={"User-Agent": "Mozilla/5.0"})
|
|
if r.status_code != 200:
|
|
return items
|
|
root = ET.fromstring(r.text)
|
|
for el in root.findall(".//item")[:15]:
|
|
pub_str = el.findtext("pubDate", "")
|
|
dt = parsedate_to_datetime(pub_str) if pub_str else None
|
|
age_h = round((now - dt).total_seconds() / 3600, 1) if dt else None
|
|
if age_h is not None and age_h > 48:
|
|
continue
|
|
items.append({
|
|
"title": (el.findtext("title", "") or "")[:120],
|
|
"url": el.findtext("link", "") or "",
|
|
"source": (el.findtext("source", "") or "")[:30],
|
|
"age_hours": age_h,
|
|
"lang": label,
|
|
})
|
|
except Exception:
|
|
pass
|
|
return items
|
|
|
|
en_news = fetch_google_news("cryptocurrency OR bitcoin OR ethereum OR defi OR altcoin when:24h", "en-US", "US", "US:en", "en")
|
|
cn_news = fetch_google_news("加密货币 OR 比特币 OR 以太坊 OR DeFi OR Web3 when:24h", "zh-CN", "CN", "CN:zh-Hans", "cn")
|
|
result["news"] = sorted(en_news + cn_news, key=lambda x: x.get("age_hours") or 999)[:30]
|
|
return result
|
|
|
|
|
|
def build_router(repo_root: Path):
|
|
router = APIRouter()
|
|
|
|
@router.get("/api/sentiment")
|
|
async def api_sentiment(hours: int = 6, altcoin_session: str = Cookie(default="")):
|
|
require_api_user_with_subscription(altcoin_session)
|
|
conn = get_conn()
|
|
|
|
active_recs = conn.execute("SELECT DISTINCT symbol FROM recommendation WHERE status='active'").fetchall()
|
|
active_symbols = {r["symbol"].split("/")[0].upper() for r in active_recs}
|
|
|
|
recent_screened = conn.execute(
|
|
"""
|
|
SELECT DISTINCT symbol FROM screening_log
|
|
WHERE scan_time >= %s
|
|
""",
|
|
((datetime.now() - timedelta(hours=float(hours or 6))).isoformat(),),
|
|
).fetchall()
|
|
screened_bases = {r["symbol"].split("/")[0].upper() for r in recent_screened}
|
|
|
|
events = []
|
|
now_utc = datetime.now(timezone.utc)
|
|
|
|
def _parse_event_time(value):
|
|
if not value:
|
|
return None
|
|
text = str(value).strip()
|
|
for fmt in ("%a, %d %b %Y %H:%M:%S %Z", "%a, %d %b %Y %H:%M:%S GMT"):
|
|
try:
|
|
return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
dt = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return dt.astimezone(timezone.utc)
|
|
except Exception:
|
|
return None
|
|
|
|
def _is_fresh_news(value, max_hours):
|
|
dt = _parse_event_time(value)
|
|
if not dt:
|
|
return False
|
|
age_hours = (now_utc - dt).total_seconds() / 3600
|
|
return 0 <= age_hours <= max_hours
|
|
|
|
valuable_news_keywords = [
|
|
"listing", "listed", "launch", "launchpool", "megadrop", "airdrop", "mainnet", "upgrade",
|
|
"partnership", "integrat", "acquisition", "merge", "buyback", "burn", "token burn", "funding",
|
|
"raises", "investment", "sec", "etf", "approval", "lawsuit", "settlement", "hack", "exploit",
|
|
"delist", "suspend", "roadmap", "migration", "上线", "上币", "合约", "空投", "主网", "升级", "合作",
|
|
"收购", "回购", "销毁", "融资", "获投", "监管", "批准", "黑客", "漏洞", "下架", "暂停",
|
|
]
|
|
low_value_news_keywords = [
|
|
"price prediction", "price today", "live price", "marketcap and chart", "what is", "how to buy",
|
|
"good investment", "forecast", "prediction 2026", "prediction 2027", "prediction 2030",
|
|
"technical analysis", "is it time to buy", "价格预测", "今日价格", "实时价格", "怎么买", "是什么币",
|
|
]
|
|
|
|
def _is_valuable_news_title(title):
|
|
text = (title or "").lower()
|
|
if not text:
|
|
return False
|
|
if any(k in text for k in low_value_news_keywords):
|
|
return False
|
|
return any(k in text for k in valuable_news_keywords)
|
|
|
|
try:
|
|
event_cutoff = (datetime.now() - timedelta(hours=float(hours or 6))).isoformat()
|
|
event_rows = conn.execute(
|
|
"""
|
|
SELECT id, source, symbol, title, url, published_at, detected_at, importance,
|
|
event_type, decision, tech_score, rec_id, pushed
|
|
FROM event_news
|
|
WHERE detected_at >= %s
|
|
ORDER BY published_at::timestamp DESC, id DESC
|
|
LIMIT 80
|
|
""",
|
|
(event_cutoff,),
|
|
).fetchall()
|
|
for r in event_rows:
|
|
base = (r["symbol"] or "").split("/")[0].upper()
|
|
source = r["source"] or "event"
|
|
event_type = r["event_type"] or "event"
|
|
title = r["title"] or ""
|
|
if event_type in ("market_heat", "theme_expansion", "theme_direct", "llm_sentiment_candidate") or source == "llm_sentiment" or title.startswith("[主题扩散:"):
|
|
continue
|
|
events.append({
|
|
"event_id": f"event_news:{r['id']}",
|
|
"source": source,
|
|
"source_label": "Binance公告" if "binance" in source else "CoinGecko热度" if "coingecko" in source else source,
|
|
"event_type": event_type,
|
|
"importance": r["importance"] or "B",
|
|
"title": title,
|
|
"url": r["url"] or "",
|
|
"published_at": r["published_at"],
|
|
"detected_at": r["detected_at"],
|
|
"related_symbol": r["symbol"],
|
|
"related_base": base,
|
|
"related_name": "",
|
|
"decision": r["decision"] or "",
|
|
"tech_score": r["tech_score"] or 0,
|
|
"rec_id": r["rec_id"] or 0,
|
|
"pushed": bool(r["pushed"]),
|
|
"in_active": base in active_symbols,
|
|
"in_screened": base in screened_bases,
|
|
"price_usd": 0,
|
|
"change_24h_pct": 0,
|
|
"market_cap_rank": 0,
|
|
"trend_rank": None,
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT symbol, name, trend_rank, trend_score, market_cap_rank, detected_at, extra_json
|
|
FROM sentiment_events
|
|
WHERE detected_at = (SELECT MAX(detected_at) FROM sentiment_events WHERE source='coingecko')
|
|
ORDER BY trend_rank
|
|
"""
|
|
).fetchall()
|
|
for r in rows:
|
|
raw_extra = r["extra_json"]
|
|
if not raw_extra or not isinstance(raw_extra, str) or not raw_extra.strip():
|
|
extra = {}
|
|
else:
|
|
try:
|
|
extra = json.loads(raw_extra)
|
|
except Exception:
|
|
extra = {}
|
|
base = (r["symbol"] or "").upper()
|
|
name = r["name"] or base
|
|
price_usd = extra.get("price_usd", 0) or 0
|
|
change_24h_pct = extra.get("change_24h_pct", 0) or 0
|
|
news_items = extra.get("news", []) or []
|
|
|
|
for n in news_items[:3]:
|
|
published = n.get("published") or ""
|
|
if not _is_fresh_news(published, hours):
|
|
continue
|
|
title = n.get("title") or f"{name} 相关新闻"
|
|
if not _is_valuable_news_title(title):
|
|
continue
|
|
events.append({
|
|
"event_id": f"sentiment_event:{r['id']}:{n.get('url') or title}",
|
|
"source": n.get("source") or "news",
|
|
"source_label": n.get("source") or "新闻",
|
|
"event_type": "news",
|
|
"importance": "B",
|
|
"title": title,
|
|
"url": n.get("url") or "",
|
|
"published_at": published,
|
|
"detected_at": r["detected_at"],
|
|
"related_symbol": f"{base}/USDT",
|
|
"related_base": base,
|
|
"related_name": name,
|
|
"decision": "",
|
|
"tech_score": 0,
|
|
"rec_id": 0,
|
|
"pushed": False,
|
|
"in_active": base in active_symbols,
|
|
"in_screened": base in screened_bases,
|
|
"price_usd": price_usd,
|
|
"change_24h_pct": change_24h_pct,
|
|
"market_cap_rank": r["market_cap_rank"],
|
|
"trend_rank": r["trend_rank"],
|
|
})
|
|
|
|
conn.close()
|
|
|
|
deduped = []
|
|
seen = set()
|
|
for e in events:
|
|
key = ((e.get("title") or "").strip().lower(), e.get("related_base"), e.get("source"))
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
if e.get("in_active"):
|
|
e["relation_tag"] = "持仓/活跃推荐"
|
|
elif e.get("in_screened"):
|
|
e["relation_tag"] = "系统筛选中"
|
|
else:
|
|
e["relation_tag"] = "关联币种"
|
|
deduped.append(e)
|
|
|
|
deduped.sort(key=lambda item: (item.get("published_at") or item.get("detected_at") or "", {"RISK": 5, "S": 4, "A": 3, "B": 2, "C": 1}.get(item.get("importance"), 0)), reverse=True)
|
|
attach_sentiment_insights(deduped)
|
|
check_time = deduped[0]["detected_at"] if deduped else None
|
|
return {
|
|
"check_time": check_time,
|
|
"total_events": len(deduped),
|
|
"overlap_active": sum(1 for e in deduped if e["in_active"]),
|
|
"overlap_screened": sum(1 for e in deduped if e["in_screened"]),
|
|
"events": deduped[:80],
|
|
"trending": [],
|
|
"total_trending": 0,
|
|
}
|
|
|
|
@router.get("/api/sentiment/analysis")
|
|
async def api_sentiment_analysis(altcoin_session: str = Cookie(default="")):
|
|
require_api_user_with_subscription(altcoin_session)
|
|
insight = get_latest_sentiment_batch_analysis()
|
|
if not insight:
|
|
attempt = get_latest_sentiment_batch_attempt()
|
|
return {
|
|
"analysis": None,
|
|
"status": "empty" if not attempt else attempt.get("status"),
|
|
"updated_at": attempt.get("updated_at") if attempt else None,
|
|
"model": attempt.get("model") if attempt else "",
|
|
"error": attempt.get("error") if attempt else "",
|
|
"event_count": (attempt.get("input") or {}).get("event_count", 0) if attempt else 0,
|
|
"source_events": (attempt.get("input") or {}).get("events", []) if attempt else [],
|
|
}
|
|
content = insight.get("content") or {}
|
|
payload = insight.get("input") or {}
|
|
return {
|
|
"status": insight.get("status"),
|
|
"updated_at": insight.get("updated_at"),
|
|
"model": insight.get("model"),
|
|
"prompt_version": insight.get("prompt_version"),
|
|
"analysis": content,
|
|
"source_events": payload.get("events") or [],
|
|
"event_count": payload.get("event_count") or len(payload.get("events") or []),
|
|
"hours": payload.get("hours") or 24,
|
|
}
|
|
|
|
@router.get("/api/kline")
|
|
async def api_kline(symbol: str, interval: str = "1d", limit: int = 60, altcoin_session: str = Cookie(default="")):
|
|
require_api_user_with_subscription(altcoin_session)
|
|
import requests as req
|
|
|
|
try:
|
|
clean = symbol.replace("/", "")
|
|
r = req.get(
|
|
"https://api.binance.com/api/v3/klines",
|
|
params={"symbol": clean, "interval": interval, "limit": limit},
|
|
timeout=10,
|
|
)
|
|
if r.status_code != 200:
|
|
return JSONResponse({"error": f"Binance {r.status_code}"}, status_code=502)
|
|
data = r.json()
|
|
candles = [
|
|
{"time": d[0], "open": float(d[1]), "high": float(d[2]), "low": float(d[3]), "close": float(d[4]), "volume": float(d[5])}
|
|
for d in data
|
|
]
|
|
return {"symbol": symbol, "interval": interval, "candles": candles}
|
|
except Exception as e:
|
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
|
|
@router.get("/api/newsfeed")
|
|
async def api_newsfeed(altcoin_session: str = Cookie(default="")):
|
|
require_api_user_with_subscription(altcoin_session)
|
|
return _newsfeed_payload()
|
|
|
|
return router
|