import json 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 SOURCE_LABELS = { "binance_listing": "Binance上币", "binance_latest": "Binance公告", "wublock123": "吴说区块链", "panewslab": "PANews", "coingecko_trending": "CoinGecko热度", "llm_sentiment": "AI舆情", } def _source_label(source): source = str(source or "") if source in SOURCE_LABELS: return SOURCE_LABELS[source] if "binance" in source: return "Binance公告" if "coingecko" in source: return "CoinGecko热度" return source or "新闻源" def _parse_time_any(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")) return dt.astimezone(timezone.utc) if dt.tzinfo else dt except Exception: return None def _age_hours(value): dt = _parse_time_any(value) if not dt: return None now = datetime.now(timezone.utc) if dt.tzinfo else datetime.now() age = round((now - dt).total_seconds() / 3600, 1) return max(0, age) def _is_internal_sentiment_event(source, event_type, title): return ( event_type in ("market_heat", "theme_expansion", "theme_direct", "llm_sentiment_candidate") or source == "llm_sentiment" or str(title or "").startswith("[主题扩散:") ) def _event_news_items(hours=24, limit=80, include_internal=False): hours = max(1, min(int(hours or 24), 168)) limit = max(1, min(int(limit or 80), 200)) cutoff = (datetime.now() - timedelta(hours=hours)).isoformat() conn = get_conn() try: 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 %s """, (cutoff, limit), ).fetchall() finally: conn.close() items = [] seen = set() for r in rows: source = r["source"] or "event" event_type = r["event_type"] or "event" title = r["title"] or "" if not include_internal and _is_internal_sentiment_event(source, event_type, title): continue base = (r["symbol"] or "").split("/")[0].upper() key = (title.strip().lower(), base, source) if key in seen: continue seen.add(key) items.append({ "event_id": f"event_news:{r['id']}", "source": source, "source_label": _source_label(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"], "age_hours": _age_hours(r["published_at"] or 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"]), "lang": "cn" if source in ("wublock123", "panewslab") else "event", }) return items def _newsfeed_payload(): import requests as req result = {"fear_greed": None, "trending": [], "news": [], "news_sources": []} 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 events = _event_news_items(hours=48, limit=120, include_internal=False) result["news"] = events[:80] counts = {} for item in events: label = item.get("source_label") or item.get("source") or "新闻源" counts[label] = counts.get(label, 0) + 1 result["news_sources"] = [{"source": k, "count": v} for k, v in sorted(counts.items(), key=lambda x: x[1], reverse=True)] 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 = [] def _is_fresh_news(value, max_hours): dt = _parse_time_any(value) if not dt: return False now = datetime.now(timezone.utc) if dt.tzinfo else datetime.now() age_hours = (now - 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) for e in _event_news_items(hours=hours, limit=100, include_internal=False): base = e.get("related_base") or "" e.update({ "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, }) events.append(e) 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