alphax/app/web/routes_content.py
2026-05-16 14:52:10 +08:00

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