alphax/app/web/routes_content.py
2026-06-01 00:16:01 +08:00

365 lines
14 KiB
Python

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 id, 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