| """ |
| Bloomberg Terminal-style Web Dashboard — FastAPI backend |
| Run: python server.py |
| Then open: http://localhost:8000 |
| """ |
|
|
| import io |
| import json |
| import logging |
| import os |
| import sys |
| import threading |
| import time |
| from datetime import datetime, timezone |
| from pathlib import Path |
|
|
| |
| if sys.platform == "win32": |
| sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") |
| sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace") |
|
|
| from dotenv import load_dotenv |
| load_dotenv() |
|
|
| import uvicorn |
| import secrets |
| from fastapi import FastAPI, Request, Header |
| from fastapi.responses import HTMLResponse, JSONResponse |
| from fastapi.staticfiles import StaticFiles |
|
|
| import mailer |
|
|
| from collectors import ( |
| RSSCollector, RedditCollector, CoinGeckoCollector, FearGreedCollector, |
| NewsDataCollector, OnChainCollector, dedupe_articles, cluster_consensus, |
| ) |
| from collectors.dedupe import _normalize_title |
| from analyzers import ( |
| NarrativeDetector, EntityExtractor, TrendScorer, EnsembleAnalyzer, |
| SentimentAggregator, SpikeDetector, Alerter, Scorecard, |
| ) |
| from analyzers.velocity import compute_velocity, top_accelerating |
| from analyzers.meta_model import MetaModel, features_from_item |
| from analyzers.relevance import RelevanceClassifier |
| from analyzers.liquidation import LiquidationHeatmap |
| from analyzers.magnet_tracker import MagnetTracker |
| from analyzers.forecast import MarketImpactForecast |
| from collectors.economic_calendar import EconomicCalendar, fmp_configured |
| from analyzers.market_reaction import MarketReaction |
| from analyzers.embedding_narratives import EmbeddingNarratives |
| from monitoring import monitor |
| from storage import Database |
| import persistence |
| from config import DB_PATH |
|
|
| logging.basicConfig(level=logging.WARNING) |
| logger = logging.getLogger(__name__) |
|
|
| |
| _meta_model = MetaModel() |
| _embed_narr = EmbeddingNarratives() |
|
|
| app = FastAPI(title="Crypto Narrative Terminal") |
| |
| |
| Path("static").mkdir(exist_ok=True) |
| app.mount("/static", StaticFiles(directory="static"), name="static") |
|
|
| _HTML = Path("templates/index.html").read_text(encoding="utf-8") |
| _ADMIN_HTML = Path("templates/admin.html").read_text(encoding="utf-8") |
|
|
| |
| persistence.restore_db(DB_PATH) |
| db = Database() |
| alerter = Alerter() |
|
|
| |
| ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin") |
| _admin_tokens: set[str] = set() |
|
|
|
|
| def _check_admin(token: str | None) -> bool: |
| return bool(token and token in _admin_tokens) |
|
|
| |
| |
| |
| _cache: dict = {} |
| _cache_lock = threading.Lock() |
| _last_update: str = "" |
| _is_fetching: bool = False |
|
|
|
|
| def _maybe_train_meta_model() -> None: |
| """#2 Train the meta-model on stories that now have a 24h reaction label.""" |
| try: |
| labeled = db.labeled_reactions(5000) |
| samples = [] |
| for r in labeled: |
| lab = r.get("label_24h") |
| if lab not in ("bullish", "bearish"): |
| continue |
| art = db.get_article(r["url"]) |
| if not art: |
| continue |
| samples.append((features_from_item(art), 1 if lab == "bullish" else 0)) |
| if samples: |
| _meta_model.train(samples) |
| except Exception as exc: |
| logger.debug(f"[MetaModel] training skipped: {exc}") |
|
|
|
|
| def run_pipeline() -> dict: |
| global _is_fetching |
| _is_fetching = True |
| try: |
| rss_items = RSSCollector().fetch_all() |
| newsdata_items = NewsDataCollector().fetch_all() |
| reddit_items = RedditCollector().fetch_all() |
|
|
| cg = CoinGeckoCollector() |
| trending_coins = cg.fetch_trending() |
| global_market = cg.fetch_global() |
| coin_prices = cg.fetch_prices() |
| trending_ids = cg.get_trending_ids() |
| btc_now = next((c.get("price_usd") for c in coin_prices if c.get("id") == "bitcoin"), None) |
| eth_now = next((c.get("price_usd") for c in coin_prices if c.get("id") == "ethereum"), None) |
|
|
| fg = FearGreedCollector() |
| fear_greed_history = fg.fetch() |
| fear_greed_current = fg.current() |
|
|
| db.save_fear_greed(fear_greed_history) |
| db.save_prices(coin_prices) |
|
|
| |
| onchain_raw = OnChainCollector().fetch(prev_oi_btc=db.prev_oi_btc()) |
| if onchain_raw: |
| db.save_onchain(onchain_raw) |
| onchain = OnChainCollector.context_summary(onchain_raw) |
|
|
| all_items = rss_items + newsdata_items + reddit_items |
| if not all_items: |
| monitor.component_failure("collectors", "no items collected") |
| return {} |
|
|
| |
| |
| before = len(all_items) |
| all_items = dedupe_articles(all_items) |
| logger.info(f"[Dedupe] {before} -> {len(all_items)} items") |
|
|
| |
| |
| |
| all_items = RelevanceClassifier().classify_batch(all_items) |
| all_items, discarded = RelevanceClassifier.filter_relevant(all_items) |
| if discarded: |
| monitor.component_failure("relevance_filter", f"discarded {discarded} irrelevant") |
| logger.info(f"[Relevance] kept {len(all_items)}, discarded {discarded}") |
| relevance_counts = { |
| "crypto": sum(1 for i in all_items if i.get("relevance") == "crypto"), |
| "macro": sum(1 for i in all_items if i.get("relevance") == "macro"), |
| "discarded_irrelevant": discarded, |
| } |
|
|
| sa = EnsembleAnalyzer() |
| all_items = sa.analyze_batch(all_items) |
|
|
| nd = NarrativeDetector() |
| all_items = nd.detect_batch(all_items) |
|
|
| ee = EntityExtractor() |
| all_items = ee.extract_batch(all_items) |
| coin_mentions = ee.coin_mention_frequency(all_items) |
|
|
| |
| consensus = cluster_consensus(rss_items + newsdata_items + reddit_items) |
| for it in all_items: |
| c = consensus.get(_normalize_title(it.get("title", ""))) |
| if c: |
| it["consensus_strength"] = c["consensus_strength"] |
| it["sentiment_variance"] = c["sentiment_variance"] |
| it["cluster_size"] = c["cluster_size"] |
| if it.get("url"): |
| db.save_consensus(it["url"], c["cluster_size"], |
| c["sentiment_variance"], c["consensus_strength"]) |
|
|
| |
| db.save_mentions(coin_mentions, kind="coin") |
| velocity_map = compute_velocity(db.mention_series("coin", 400), coin_mentions) |
| for it in all_items: |
| coins = it.get("entities", {}).get("coins", []) |
| vs = max((velocity_map.get(c, {}).get("velocity_score", 0.0) for c in coins), |
| default=0.0) |
| it["velocity_score"] = vs |
| accelerating = top_accelerating(velocity_map, 8) |
|
|
| |
| for it in all_items: |
| it["bull_prob"] = round(_meta_model.predict_proba(it), 4) |
|
|
| |
| emerging_narratives = _embed_narr.discover(all_items, top_n=12) |
| if not emerging_narratives: |
| monitor.model_fallback("embeddings", "using TF-IDF keywords") |
| nd.fit_corpus(all_items) |
| emerging_narratives = [ |
| {"label": kw, "size": 0, "terms": [kw], "known_narrative": None} |
| for kw, _ in nd.top_emerging_keywords(all_items, top_n=12) |
| ] |
|
|
| scorer = TrendScorer(trending_coin_ids=trending_ids) |
| scored_narratives = scorer.score_narratives(all_items) |
|
|
| |
| |
| prior_snaps = db.get_snapshots(limit=12) |
| spike_alerts = SpikeDetector(prior_snaps).detect(scored_narratives) |
| if spike_alerts: |
| alerter.push(spike_alerts) |
|
|
| |
| |
| db.save_articles(all_items) |
| db.save_snapshot(scored_narratives) |
|
|
| |
| mr = MarketReaction() |
| mr.seed(db, all_items, btc_now, eth_now) |
| mr.fill(db) |
|
|
| |
| _maybe_train_meta_model() |
|
|
| recent_cached = db.get_recent_articles(hours=48) |
| |
| prior_snapshot = db.get_index_near(hours_ago=24, tolerance_hours=8) |
| sentiment_24h = SentimentAggregator().aggregate(recent_cached or all_items, prior_snapshot) |
| |
| if onchain.get("liq_proxy") is not None: |
| ov = sentiment_24h.get("overall", {}) |
| extra = min(0.3, onchain["liq_proxy"] / 40.0) |
| ov["vol_prob"] = round(min(1.0, ov.get("vol_prob", 0.0) + extra), 4) |
| db.save_sentiment_history(sentiment_24h) |
| sentiment_history = db.get_sentiment_history(limit=96) |
| |
| btc_history = db.get_price_history("bitcoin", limit=200) |
| scorecard = Scorecard().compute(sentiment_history, btc_history) |
|
|
| |
| |
| liq_price = btc_now or (btc_history[-1]["price"] if btc_history else None) |
| liquidation = LiquidationHeatmap().compute( |
| liq_price, btc_history, oi_btc=(onchain_raw or {}).get("oi_btc")) |
|
|
| |
| |
| _mt = MagnetTracker() |
| _mt.update(db, liquidation, liq_price) |
| magnet_scorecard = _mt.compute(db) |
|
|
| |
| calendar = EconomicCalendar().fetch(days_ahead=7) |
| forecast = MarketImpactForecast().compute( |
| sentiment_24h=sentiment_24h, onchain=onchain, liquidation=liquidation, |
| calendar=calendar, fear_greed=fear_greed_current) |
|
|
| |
| monitor.flush(db) |
| health = db.health_summary(24) |
| db.purge_old() |
|
|
| |
| persistence.backup_db(db) |
|
|
| |
| news_feed = sorted( |
| [i for i in all_items if i.get("title")], |
| key=lambda x: x.get("published", ""), |
| reverse=True, |
| )[:60] |
|
|
| result = { |
| "narratives": scored_narratives, |
| "global_market": global_market, |
| "fear_greed": fear_greed_current, |
| "fear_greed_history": fear_greed_history, |
| "trending_coins": trending_coins, |
| "coin_prices": coin_prices, |
| "coin_mentions": coin_mentions, |
| "emerging_narratives": emerging_narratives, |
| "accelerating": accelerating, |
| "onchain": onchain, |
| "liquidation": liquidation, |
| "magnet_scorecard": magnet_scorecard, |
| "calendar": calendar, |
| "forecast": forecast, |
| "fmp_configured": fmp_configured(), |
| "health": health, |
| "relevance_counts": relevance_counts, |
| "meta_model_trained": _meta_model.trained, |
| "article_count": len(all_items), |
| "sentiment_24h": sentiment_24h, |
| "sentiment_history": sentiment_history, |
| "spike_alerts": spike_alerts, |
| "scorecard": scorecard, |
| "news_feed": [ |
| { |
| "title": n.get("title", "")[:120], |
| "source": n.get("source", ""), |
| "url": n.get("url", ""), |
| "published": n.get("published", ""), |
| "sentiment": n.get("sentiment", {}).get("label", "neutral"), |
| "compound": n.get("sentiment", {}).get("compound", 0), |
| "confidence": n.get("sentiment", {}).get("confidence", 0), |
| "engine": n.get("sentiment", {}).get("engine", "vader"), |
| "bull_prob": n.get("bull_prob", 0.5), |
| "consensus_strength": n.get("consensus_strength"), |
| "narratives": n.get("narratives", []), |
| "is_macro": bool(n.get("is_macro")), |
| "relevance": n.get("relevance", "crypto"), |
| } |
| for n in news_feed |
| ], |
| "fetch_time": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"), |
| } |
| return result |
| except Exception as exc: |
| logger.exception("Pipeline failed") |
| return {"error": str(exc)} |
| finally: |
| _is_fetching = False |
|
|
|
|
| REFRESH_MINUTES = int(os.getenv("REFRESH_INTERVAL_MINUTES", "10")) |
|
|
|
|
| def refresh_loop(): |
| while True: |
| data = run_pipeline() |
| if data: |
| with _cache_lock: |
| _cache.update(data) |
| global _last_update |
| _last_update = datetime.now(timezone.utc).strftime("%H:%M:%S") |
| time.sleep(max(3, REFRESH_MINUTES) * 60) |
|
|
|
|
| def _send_brief_now(slot_label: str = "manual") -> tuple[bool, str]: |
| """Build the current brief and email it to all active clients.""" |
| with _cache_lock: |
| data = dict(_cache) |
| if not data or not data.get("sentiment_24h"): |
| |
| |
| if not _is_fetching: |
| threading.Thread(target=lambda: _cache.update(run_pipeline()), daemon=True).start() |
| return False, "Data still loading — wait ~2 min after restart, then try again" |
| settings = db.get_email_settings() |
| recipients = [c["email"] for c in db.get_clients(active_only=True)] |
| if not recipients: |
| db.log_email(slot_label, 0, "skipped", "no active clients") |
| return False, "No active clients" |
| html = mailer.build_brief_html(data) |
| idx = data["sentiment_24h"]["overall"].get("index", "") |
| label = data["sentiment_24h"]["overall"].get("label", "") |
| subject = f"{settings.get('subject_prefix','Crypto Narrative Brief')} — {label} ({idx}/100)" |
| ok, detail = mailer.send_brief(recipients, subject, html) |
| db.log_email(slot_label, len(recipients), "sent" if ok else "failed", detail) |
| return ok, detail |
|
|
|
|
| def email_scheduler(): |
| """Checks every 30s whether a scheduled send time has arrived (UTC).""" |
| while True: |
| try: |
| settings = db.get_email_settings() |
| if settings.get("enabled"): |
| now = datetime.now(timezone.utc) |
| hhmm = now.strftime("%H:%M") |
| today = now.strftime("%Y-%m-%d") |
| for slot_num, key in ((1, "send_time_1"), (2, "send_time_2")): |
| if settings.get(key) == hhmm: |
| slot_id = f"{today}#{slot_num}" |
| if settings.get("last_sent_slot") != slot_id: |
| ok, detail = _send_brief_now(slot_id) |
| db.update_email_settings(last_sent_slot=slot_id) |
| logger.info(f"[Scheduler] slot {slot_id}: {ok} {detail}") |
| except Exception as exc: |
| logger.warning(f"[Scheduler] error: {exc}") |
| time.sleep(30) |
|
|
|
|
| |
| |
| |
|
|
| @app.get("/health") |
| async def health(): |
| return {"status": "ok"} |
|
|
| from contextlib import asynccontextmanager |
|
|
| @asynccontextmanager |
| async def lifespan(a: FastAPI): |
| threading.Thread(target=refresh_loop, daemon=True).start() |
| threading.Thread(target=email_scheduler, daemon=True).start() |
| yield |
|
|
| app.router.lifespan_context = lifespan |
|
|
|
|
| @app.get("/", response_class=HTMLResponse) |
| async def index(): |
| return HTMLResponse(_HTML) |
|
|
|
|
| @app.get("/api/data") |
| async def get_data(): |
| with _cache_lock: |
| if not _cache: |
| return JSONResponse({"status": "loading", "is_fetching": _is_fetching}) |
| return JSONResponse({**_cache, "last_update": _last_update, "is_fetching": _is_fetching}) |
|
|
|
|
| @app.get("/api/refresh") |
| async def trigger_refresh(): |
| """Manually trigger a data refresh in background.""" |
| if not _is_fetching: |
| t = threading.Thread(target=lambda: _cache.update(run_pipeline()), daemon=True) |
| t.start() |
| return {"status": "refreshing"} |
|
|
|
|
| |
| |
| |
|
|
| @app.get("/admin", response_class=HTMLResponse) |
| async def admin_page(): |
| return HTMLResponse(_ADMIN_HTML) |
|
|
|
|
| @app.post("/api/admin/login") |
| async def admin_login(payload: dict): |
| if payload.get("password") == ADMIN_PASSWORD: |
| token = secrets.token_urlsafe(24) |
| _admin_tokens.add(token) |
| return {"ok": True, "token": token} |
| return JSONResponse({"ok": False, "error": "Wrong password"}, status_code=401) |
|
|
|
|
| def _auth_or_401(token: str | None): |
| if not _check_admin(token): |
| return JSONResponse({"ok": False, "error": "Unauthorized"}, status_code=401) |
| return None |
|
|
|
|
| @app.get("/api/admin/state") |
| async def admin_state(x_token: str | None = Header(default=None)): |
| err = _auth_or_401(x_token) |
| if err: return err |
| return { |
| "ok": True, |
| "clients": db.get_clients(), |
| "settings": db.get_email_settings(), |
| "log": db.get_email_log(20), |
| "smtp_configured": mailer.email_configured(), |
| "email_provider": "Brevo (HTTP)" if mailer.brevo_configured() else ("SMTP" if mailer.smtp_configured() else "none"), |
| "has_data": bool(_cache.get("sentiment_24h")), |
| } |
|
|
|
|
| @app.post("/api/admin/clients") |
| async def admin_add_client(payload: dict, x_token: str | None = Header(default=None)): |
| err = _auth_or_401(x_token) |
| if err: return err |
| email = (payload.get("email") or "").strip() |
| if not email or "@" not in email: |
| return JSONResponse({"ok": False, "error": "Invalid email"}, status_code=400) |
| ok = db.add_client(email, payload.get("name", "")) |
| return {"ok": ok, "error": None if ok else "Already exists"} |
|
|
|
|
| @app.delete("/api/admin/clients/{client_id}") |
| async def admin_remove_client(client_id: int, x_token: str | None = Header(default=None)): |
| err = _auth_or_401(x_token) |
| if err: return err |
| db.remove_client(client_id) |
| return {"ok": True} |
|
|
|
|
| @app.post("/api/admin/clients/{client_id}/toggle") |
| async def admin_toggle_client(client_id: int, payload: dict, x_token: str | None = Header(default=None)): |
| err = _auth_or_401(x_token) |
| if err: return err |
| db.set_client_active(client_id, bool(payload.get("active"))) |
| return {"ok": True} |
|
|
|
|
| @app.post("/api/admin/settings") |
| async def admin_settings(payload: dict, x_token: str | None = Header(default=None)): |
| err = _auth_or_401(x_token) |
| if err: return err |
| db.update_email_settings( |
| enabled=1 if payload.get("enabled") else 0, |
| send_time_1=payload.get("send_time_1", "09:00"), |
| send_time_2=payload.get("send_time_2", "21:00"), |
| subject_prefix=payload.get("subject_prefix", "Crypto Narrative Brief"), |
| ) |
| return {"ok": True, "settings": db.get_email_settings()} |
|
|
|
|
| @app.post("/api/admin/send-now") |
| async def admin_send_now(x_token: str | None = Header(default=None)): |
| err = _auth_or_401(x_token) |
| if err: return err |
| ok, detail = _send_brief_now("manual") |
| return {"ok": ok, "detail": detail} |
|
|
|
|
| |
| |
| |
| |
| |
| CRON_SECRET = os.getenv("CRON_SECRET", "") |
|
|
|
|
| @app.get("/api/cron/ping") |
| async def cron_ping(key: str = ""): |
| """Keep-alive + ensure data is fresh. Safe to call frequently.""" |
| if CRON_SECRET and key != CRON_SECRET: |
| return JSONResponse({"ok": False, "error": "bad key"}, status_code=403) |
| if not _is_fetching and not _cache: |
| threading.Thread(target=lambda: _cache.update(run_pipeline()), daemon=True).start() |
| return {"ok": True, "awake": True, "has_data": bool(_cache.get("sentiment_24h"))} |
|
|
|
|
| @app.get("/api/cron/send-brief") |
| async def cron_send_brief(key: str = ""): |
| """Send the brief now to all active clients (called at scheduled times).""" |
| if CRON_SECRET and key != CRON_SECRET: |
| return JSONResponse({"ok": False, "error": "bad key"}, status_code=403) |
| ok, detail = _send_brief_now("cron") |
| return {"ok": ok, "detail": detail} |
|
|
|
|
| if __name__ == "__main__": |
| print("\n Crypto Narrative Terminal") |
| print(" Open http://localhost:8000 in your browser\n") |
| uvicorn.run(app, host="0.0.0.0", port=8000, log_level="warning") |
|
|