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