""" 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 import hashlib import hmac from datetime import datetime, timezone from pathlib import Path # UTF-8 safety on Windows 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 import requests from fastapi import FastAPI, Request, Header from fastapi.responses import HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles import mailer import ai_assistant from analyzers import psychology from collectors import google_trends as google_trends_mod 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, EmailsDB import persistence from config import DB_PATH logging.basicConfig(level=logging.WARNING) logger = logging.getLogger(__name__) # Persistent analyzers (loaded once, reused across cycles) _meta_model = MetaModel() _embed_narr = EmbeddingNarratives() # Docs/schema disabled: no need to advertise the API surface (incl. the gated # /terminal and admin routes) on a pre-launch public site. app = FastAPI(title="Crypto Narrative Terminal", docs_url=None, redoc_url=None, openapi_url=None) # Ensure the static dir exists (git doesn't track empty folders, so it may be # absent on a fresh clone / cloud deploy). 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") _TERMS_HTML = Path("templates/terms.html").read_text(encoding="utf-8") _JOIN_HTML = Path("templates/join.html").read_text(encoding="utf-8") # SEO content layer (Articles / FAQ / About) — data-driven, see content/site_content.py from content import site_content # Live execution layer (optional). Reads the published signal ONLY; it never # takes part in computing it. Completely inert unless TRADE_ENABLED=true. from trading import executor as trader # Restore the last DB backup (if HF persistence is configured) BEFORE opening it persistence.restore_db(DB_PATH) db = Database() alerter = Alerter() # Subscribers & email settings live in their own tiny DB so they can be backed # up to HF immediately on every signup (see persistence.backup_emails). EMAILS_DB_PATH = str(Path(DB_PATH).resolve().parent / "emails.db") persistence.restore_emails(EMAILS_DB_PATH) edb = EmailsDB(EMAILS_DB_PATH) # Trade state lives beside the DB so a restart can't double-open a position. trader.init(DB_PATH) # One-time: pull any existing subscribers from the old combined DB so they # aren't stranded after the split (no-op once emails.db has them). try: edb.migrate_from(db) except Exception as _exc: logger.warning(f"[EmailsDB] migrate_from skipped: {_exc}") # Admin auth, simple token issued on password login (set ADMIN_PASSWORD in .env) ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin") _admin_tokens: set[str] = set() # Pre-launch preview gate: the real terminal + full data are hidden behind a # password so the public only sees the waitlist. Defaults to the admin password. PREVIEW_PASSWORD = os.getenv("PREVIEW_PASSWORD") or ADMIN_PASSWORD _PREVIEW_TOKEN = secrets.token_urlsafe(24) # rotates on restart (re-login needed) def _check_admin(token: str | None) -> bool: return bool(token and token in _admin_tokens) def _persist_now(): """Back up the small emails DB immediately (in the background) after a signup/confirm so a Space rebuild can never lose a subscriber. No-op if persistence isn't configured.""" if not persistence.persistence_configured(): return threading.Thread( target=lambda: persistence.backup_emails(edb, min_interval=0), daemon=True ).start() def _has_preview(request: Request) -> bool: return request.cookies.get("crx_preview") == _PREVIEW_TOKEN # --------------------------------------------------------------------------- # User accounts (email + password). scrypt hashing (stdlib), in-memory sessions. # Login does NOT unlock the terminal yet (that stays preview-gated), this is the # SaaS account foundation; flip _login_gates_terminal later when plans/payments # are wired. # --------------------------------------------------------------------------- _sessions: dict[str, str] = {} # token -> email _login_fails: dict[str, list] = {} # email -> [timestamps] (basic rate limit) def _hash_pw(password: str, salt: bytes | None = None) -> tuple[str, str]: salt = salt or os.urandom(16) dk = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=16384, r=8, p=1, dklen=32) return dk.hex(), salt.hex() def _verify_pw(password: str, salt_hex: str, hash_hex: str) -> bool: try: dk, _ = _hash_pw(password, bytes.fromhex(salt_hex)) return hmac.compare_digest(dk, hash_hex) except Exception: return False def _rate_limited(email: str) -> bool: now = time.time() fails = [t for t in _login_fails.get(email, []) if now - t < 900] # 15 min window _login_fails[email] = fails return len(fails) >= 6 def _record_fail(email: str): _login_fails.setdefault(email, []).append(time.time()) def _current_user(request: Request) -> str | None: return _sessions.get(request.cookies.get("crx_session", "")) def _has_access(request: Request) -> bool: """Terminal access = a logged-in account OR the owner preview cookie.""" return _current_user(request) is not None or _has_preview(request) # --------------------------------------------------------------------------- # In-memory cache, updated by background thread # --------------------------------------------------------------------------- _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) # no-ops if below the min sample threshold except Exception as exc: logger.debug(f"[MetaModel] training skipped: {exc}") def _build_chart_series(price_hist: list[dict], sent_hist: list[dict], liq_hist: list[dict], hours: int = 168, max_points: int = 420) -> list[dict]: """Align BTC price with the sentiment index (and the strongest liquidation magnet) on one timeline so the UI can overlay them. Returns [{t, p, i, l}], time, price, index (0-100), liq strength (0-100). Downsampled to `max_points` so the payload stays small. """ def _ts(iso): try: dt = datetime.fromisoformat(iso) return (dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt).timestamp() except Exception: return None cutoff = datetime.now(timezone.utc).timestamp() - hours * 3600 pts = [(t, p["price"]) for p in price_hist if (t := _ts(p.get("snapshot_at", ""))) and t >= cutoff and p.get("price")] if not pts: return [] pts.sort() idx = sorted((t, s.get("index_val")) for s in sent_hist if (t := _ts(s.get("snapshot_at", ""))) and s.get("index_val") is not None) liq = sorted((t, s.get("strength")) for s in liq_hist if (t := _ts(s.get("snapshot_at", ""))) and s.get("strength") is not None) def _nearest(series, target, tol=1800): """Nearest value within tol seconds (series must be sorted by time).""" best, best_d = None, tol + 1 for t, v in series: d = abs(t - target) if d < best_d: best, best_d = v, d elif t > target + tol: break return best if best_d <= tol else None step = max(1, -(-len(pts) // max_points)) # ceil -> never exceed max_points out = [] for i in range(0, len(pts), step): t, price = pts[i] out.append({ "t": datetime.fromtimestamp(t, tz=timezone.utc).isoformat(), "p": round(price, 2), "i": _nearest(idx, t), "l": _nearest(liq, t), }) return out def _compute_divergence(sentiment_24h: dict, price_hist: list[dict], hours: int = 24) -> dict: """Sentiment-vs-price divergence read (educational). Compares how the sentiment index moved vs how BTC moved over the window. The thesis: news mood often LEADS price, so a gap between them is a signal. - index up while price flat/down -> bullish divergence (price may catch up) - index down while price flat/up -> bearish divergence (pullback risk) - both same direction -> confirmed / in agreement NOT a prediction, framed as an observation. """ ov = (sentiment_24h or {}).get("overall", {}) or {} idx_now = ov.get("index") idx_delta = sentiment_24h.get("trend_delta_index") # points vs ~24h ago if idx_now is None or idx_delta is None: return {} def _ts(iso): try: dt = datetime.fromisoformat(iso) return (dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt).timestamp() except Exception: return None pts = sorted((t, p["price"]) for p in price_hist if (t := _ts(p.get("snapshot_at", ""))) and p.get("price")) if len(pts) < 2: return {} price_now = pts[-1][1] target = pts[-1][0] - hours * 3600 then = min(pts, key=lambda x: abs(x[0] - target)) if abs(then[0] - target) > 8 * 3600 or then[1] == 0: return {} price_change = (price_now - then[1]) / then[1] * 100 IDX_TH, PX_TH = 4.0, 1.0 idx_up, idx_dn = idx_delta >= IDX_TH, idx_delta <= -IDX_TH px_up, px_dn = price_change >= PX_TH, price_change <= -PX_TH if idx_up and not px_up: state, tone = "bullish_divergence", "bull" head = "Bullish divergence" note = ("Sentiment is rising but price hasn't followed yet. News mood " "often leads price, so this can precede a catch-up move up, " "not a guarantee.") elif idx_dn and not px_dn: state, tone = "bearish_divergence", "bear" head = "Bearish divergence" note = ("Sentiment is cooling while price holds up. Mood tends to lead, " "so pullback risk is building, watch closely.") elif idx_up and px_up: state, tone = "confirmed_bull", "bull" head = "Confirmed bullish" note = "Sentiment and price are both rising, the move up agrees with the news mood." elif idx_dn and px_dn: state, tone = "confirmed_bear", "bear" head = "Confirmed bearish" note = "Sentiment and price are both falling, the decline agrees with the news mood." else: state, tone = "in_sync", "flat" head = "In step" note = "Sentiment and price are roughly in step right now, no notable divergence." return { "state": state, "tone": tone, "headline": head, "note": note, "index_change": round(idx_delta, 1), "price_change": round(price_change, 2), "window_h": hours, } 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) # #5 On-chain + derivatives signals 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 {} # Collapse the same story arriving from multiple feeds (keeps the most # credible copy), cleaner feed + no double-counting in sentiment. before = len(all_items) all_items = dedupe_articles(all_items) logger.info(f"[Dedupe] {before} -> {len(all_items)} items") # Crypto Relevance Filter, classify CRYPTO / MACRO / IRRELEVANT and # DISCARD irrelevant noise (sports, entertainment, off-topic) BEFORE # any scoring so it never reaches the sentiment index. 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) # #3 Consensus / contradiction across duplicate sources 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"]) # #6 Social attention velocity (acceleration of mentions over time) 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) # #2 Meta-model probability of bullish reaction (per item) for it in all_items: it["bull_prob"] = round(_meta_model.predict_proba(it), 4) # #4 Embedding-based narrative discovery (replaces TF-IDF); fallback below 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) # Market-psychology signals (heuristic, from the scored items). hype = psychology.hype_signals(all_items) retail = psychology.retail_excitement(all_items) # Google Trends search-interest (best-effort; may be empty on cloud IPs). try: _trend_kw = ["bitcoin", "crypto"] + [n.get("name", "") for n in scored_narratives[:3]] google_trends = google_trends_mod.fetch_trends([k for k in _trend_kw if k]) except Exception: google_trends = {} # Spike detection vs prior snapshots (before saving the new one, so # the current run is compared against history, not itself). prior_snaps = db.get_snapshots(limit=12) spike_alerts = SpikeDetector(prior_snaps).detect(scored_narratives) if spike_alerts: alerter.push(spike_alerts) # external channels if configured # 24h aggregate sentiment, merge fresh items with cached recent ones # so the prior-24h trend comparison has history to work with. db.save_articles(all_items) db.save_snapshot(scored_narratives) # #1 Market reaction labeling: seed new stories, fill matured ones mr = MarketReaction() mr.seed(db, all_items, btc_now, eth_now) mr.fill(db) # #2 Retrain meta-model on accumulated 24h-labelled reactions _maybe_train_meta_model() recent_cached = db.get_recent_articles(hours=48) # Reliable "vs ~24h ago" using the real stored snapshot, not stale articles prior_snapshot = db.get_index_near(hours_ago=24, tolerance_hours=8) # ~30 days of past net scores -> baseline for the RELATIVE index (so the # gauge can read bearish when mood sours vs its own recent normal). baseline_hist = db.get_sentiment_history(limit=4320) # ~30d at 10-min baseline_nets = [h.get("net_score") for h in baseline_hist if h.get("net_score") is not None] sentiment_24h = SentimentAggregator().aggregate( recent_cached or all_items, prior_snapshot, baseline_nets) # #7 nudge volatility probability with the derivatives liquidation proxy 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) # recent, for the UI sparkline # Accuracy scorecard needs the FULL history (many days), not just the # recent window, otherwise it can only ever find ~1 independent call. # btc_history (large) is reused by the liquidation heatmap below, which # only slices its most recent 300 points. # ~45 days is plenty for the track record; loading tens of thousands of # rows every cycle was heavy on the small Space and could OOM. btc_history = db.get_price_history("bitcoin", limit=7000) sc_hist = db.get_sentiment_history(limit=7000) scorecard = Scorecard().compute(sc_hist, btc_history) # Liquidation heatmap (estimated from OI + leverage tiers vs recent prices). # Fall back to the last stored BTC price if CoinGecko was rate-limited. 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")) # Record the strongest-magnet strength for this cycle so the chart can # mark high-liquidation moments (this history was never being written). db.save_magnet_seed(liquidation) # Overlay series: BTC price vs sentiment index (+ liquidation intensity) chart_series = _build_chart_series( btc_history, sc_hist, db.get_magnet_strength_history(2000)) divergence = _compute_divergence(sentiment_24h, btc_history) # Magnet target tracker: hit-check + expire + register (every cycle), # each level tracked for 24h, never re-registered while active. _mt = MagnetTracker() _mt.update(db, liquidation, liq_price) magnet_scorecard = _mt.compute(db) # Economic calendar (FMP) + Market Impact Forecast for the next 24-48h calendar = EconomicCalendar().fetch(days_ahead=7) forecast = MarketImpactForecast().compute( sentiment_24h=sentiment_24h, onchain=onchain, liquidation=liquidation, calendar=calendar, fear_greed=fear_greed_current) # #8 flush system-health events for this cycle monitor.flush(db) health = db.health_summary(24) db.purge_old() # Persist the DB to the HF Dataset backup (throttled; no-op if unset). # Fully isolated: a backup problem must NEVER discard the cycle's data. try: persistence.backup_db(db) except Exception as exc: logger.warning(f"[Persist] backup skipped: {exc}") # Build news feed (most recent 60 items with title + source) 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, "hype": hype, "retail_excitement": retail, "google_trends": google_trends, "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, "chart_series": chart_series, "divergence": divergence, "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") # Mirror the (already computed) signal onto the exchange. Wrapped so # a trading problem can never affect the analysis pipeline. try: with _cache_lock: snapshot = dict(_cache) trader.on_cycle(snapshot) except Exception: logger.exception("[Trade] hook failed (analysis unaffected)") time.sleep(max(3, REFRESH_MINUTES) * 60) # default every 10 min (configurable) 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"): # Cache not warmed yet (e.g. just after a restart). Kick off a fetch # so the next attempt succeeds, and tell the user clearly. 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 = edb.get_email_settings() recipients = [c["email"] for c in edb.get_clients(active_only=True)] if not recipients: edb.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) edb.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 = edb.get_email_settings() if settings.get("enabled"): now = datetime.now(timezone.utc) hhmm = now.strftime("%H:%M") today = now.strftime("%Y-%m-%d") # ONE brief per day, at send_time_1 only. if settings.get("send_time_1") == hhmm: slot_id = f"{today}#1" if settings.get("last_sent_slot") != slot_id: ok, detail = _send_brief_now(slot_id) edb.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) # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @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() # Timed exit for live trades, independent of the analysis pipeline so a # stalled pipeline can never leave a leveraged position open past its hold. threading.Thread(target=trader.watchdog_loop, daemon=True).start() yield app.router.lifespan_context = lifespan @app.api_route("/", methods=["GET", "HEAD"], response_class=HTMLResponse) async def index(): # Public front door pre-launch = the waitlist. The real terminal lives at # /terminal behind the preview password. return HTMLResponse(_JOIN_HTML) def _preview_gate_page() -> str: body = ('
This is a private preview. Enter the access password to open the ' 'terminal.
' '' '' '') return _page("Private preview", "🔒 Private preview", body) @app.get("/terminal", response_class=HTMLResponse) async def terminal(request: Request): # Terminal is open to everyone, no login required. Accounts add the daily # email brief and the AI analyst, which the UI nudges toward. return HTMLResponse(_HTML) @app.post("/api/preview-login") async def preview_login(payload: dict): if payload.get("password") == PREVIEW_PASSWORD: resp = JSONResponse({"ok": True}) resp.set_cookie("crx_preview", _PREVIEW_TOKEN, httponly=True, samesite="lax", max_age=60 * 60 * 24 * 30) return resp return JSONResponse({"ok": False, "error": "Wrong password"}, status_code=401) @app.api_route("/terms", methods=["GET", "HEAD"], response_class=HTMLResponse) async def terms(): return HTMLResponse(_TERMS_HTML) # --------------------------------------------------------------------------- # SEO content pages — Articles (blog), FAQ, About. Data-driven + structured data. # --------------------------------------------------------------------------- @app.api_route("/articles", methods=["GET", "HEAD"], response_class=HTMLResponse) async def articles_index(request: Request): return HTMLResponse(site_content.render_articles_index(_public_base(request))) @app.api_route("/articles/{slug}", methods=["GET", "HEAD"], response_class=HTMLResponse) async def article_page(slug: str, request: Request): html = site_content.render_article(_public_base(request), slug) if html is None: return RedirectResponse("/articles", status_code=302) return HTMLResponse(html) @app.api_route("/faq", methods=["GET", "HEAD"], response_class=HTMLResponse) async def faq_page(request: Request): return HTMLResponse(site_content.render_faq(_public_base(request))) @app.api_route("/about", methods=["GET", "HEAD"], response_class=HTMLResponse) async def about_page(request: Request): return HTMLResponse(site_content.render_about(_public_base(request))) @app.api_route("/tools", methods=["GET", "HEAD"], response_class=HTMLResponse) async def tools_page(request: Request): return HTMLResponse(site_content.render_tools(_public_base(request))) # --------------------------------------------------------------------------- # Auth pages + API (accounts foundation; terminal stays preview-gated for now) # --------------------------------------------------------------------------- def _auth_html(mode: str) -> str: login = mode == "login" title = "Log in" if login else "Create account" other = ('Create one' if login else 'Log in') other_label = ("New here? " if login else "Already have an account? ") action = "/api/auth/login" if login else "/api/auth/signup" google_btn = '' if google_configured(): google_btn = ( '' '' 'Continue with Google' '' f'{"Welcome back." if login else "Free account, get the daily brief and the AI analyst."}
' + google_btn + '' '' f'{other_label}{other}
' f'' ) return _page(title + ", Sentiments Analyzer", title, body) @app.api_route("/login", methods=["GET", "HEAD"], response_class=HTMLResponse) async def login_page(): return HTMLResponse(_auth_html("login")) @app.api_route("/signup", methods=["GET", "HEAD"], response_class=HTMLResponse) async def signup_page(): return HTMLResponse(_auth_html("signup")) @app.get("/account", response_class=HTMLResponse) async def account_page(request: Request): email = _current_user(request) if not email: return HTMLResponse('') u = edb.get_user(email) or {} plan = (u.get("plan") or "free").title() body = ( f'Signed in as {email}
' f'Plan: ' f'{plan} · full terminal access included, free.
' 'Thanks for joining the Crypto Narrative waitlist. Click below to confirm your email, we'll notify you the moment early access opens:
If you didn't request this, just ignore this email — you won't be added. Not financial advice.
You\'re on the waitlist. We\'ll email you the moment early access ' 'opens, no spam in between.
' 'Want in sooner? Share your ' 'link, every friend who joins moves you up.
' f'' '' f'{info["referrals"]}' ' friends referred so far
' f'') return HTMLResponse(_page("You're on the list", "✓ You're on the list!", body)) body = ('This confirmation link is invalid or has expired. ' 'Try subscribing again.
Back to signup') return HTMLResponse(_page("Invalid link", "Link not valid", body, accent="#F2555A")) @app.get("/api/ref") async def ref_status(code: str = ""): n = edb.referral_count(code) return JSONResponse({"referrals": n}) @app.get("/unsubscribe", response_class=HTMLResponse) async def unsubscribe_page(): body = ('Enter your email to stop receiving the daily brief.
' '' '' '') return HTMLResponse(_page("Unsubscribe", "Unsubscribe", body)) @app.post("/api/unsubscribe") async def unsubscribe(payload: dict): email = (payload.get("email") or "").strip().lower() if not _valid_email(email): return JSONResponse({"ok": False, "error": "Please enter a valid email."}, status_code=400) edb.unsubscribe_email(email) _persist_now() # Always report success (don't reveal whether an email was on the list) return {"ok": True, "message": "Done, you've been unsubscribed."} @app.get("/api/data") async def get_data(request: Request): # Open, the terminal is free for everyone. 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.post("/api/ask") async def ask_ai(payload: dict, request: Request): """Premium AI assistant, answers from the terminal's own live data. Gated behind the preview cookie (part of the paid terminal).""" if not _has_access(request): return JSONResponse({"ok": False, "error": "gated"}, status_code=403) with _cache_lock: snapshot = dict(_cache) ok, answer = ai_assistant.ask(payload.get("question", ""), snapshot) return {"ok": ok, "answer": answer, "configured": ai_assistant.ai_configured()} @app.get("/api/ask/status") async def ask_status(request: Request): if not _has_access(request): return JSONResponse({"configured": False}, status_code=403) return {"configured": ai_assistant.ai_configured()} @app.get("/api/teaser") async def get_teaser(): """Public, safe subset for the waitlist page, headline numbers only.""" with _cache_lock: c = _cache or {} o = (c.get("sentiment_24h") or {}).get("overall", {}) or {} sc = c.get("scorecard") or {} try: waitlist = edb.subscriber_count(confirmed_only=True) except Exception: waitlist = None try: leaderboard = [{"name": _mask_email(r["email"]), "referrals": r["referrals"]} for r in edb.top_referrers(3)] except Exception: leaderboard = [] return JSONResponse({ "index": o.get("index"), "signal_hit_rate": sc.get("signal_hit_rate"), "win_rate": sc.get("win_rate"), "narratives": len(c.get("narratives") or []), "waitlist": waitlist, "leaderboard": leaderboard, # Public teaser of the price/index overlay, shows the product works. "chart_series": c.get("chart_series") or [], "divergence": c.get("divergence") or {}, }) def _public_output(cache: dict) -> dict: """The canonical public JSON other platforms consume.""" s = cache.get("sentiment_24h") or {} o = s.get("overall") or {} label = (o.get("label") or "").title() or None conf = o.get("confidence") # short narrative labels (e.g. "ETF Approval" -> "ETF", "AI & Crypto" -> "AI") def _short(nm): nm = (nm or "").strip() for cut in (" (", " / ", " & ", " Approval", " Adoption", " Scaling"): if cut in nm: nm = nm.split(cut)[0] return nm.split()[0] if nm else nm narrs = [_short(n.get("name")) for n in (cache.get("narratives") or [])[:3] if n.get("name")] def _ascii(t): return (t or "").replace("—", "-").replace("→", "->").replace("↓", "down").replace("↑", "up") warnings = [] for a in (cache.get("spike_alerts") or [])[:4]: n, m = a.get("narrative"), a.get("message") warnings.append(_ascii(f"{n}: {m}" if n and m else (m or f"{n} spike"))) # add manufactured-hype warnings warnings += [_ascii(w) for w in (cache.get("hype") or {}).get("warnings", [])[:3]] f = cache.get("forecast") or {} direction = (f.get("direction") or o.get("label") or "").title() forecast = f"{direction} next 48H" if direction else None return { "market_sentiment": o.get("index"), "trend": label, "confidence": round(conf * 100) if isinstance(conf, (int, float)) else None, "leading_narratives": narrs, "warnings": warnings, "forecast": forecast, } @app.api_route("/api/v1/sentiment", methods=["GET", "HEAD"]) async def public_sentiment(): """Public, versioned API, the canonical market-psychology output for other platforms to consume. Open + CORS-enabled.""" with _cache_lock: c = dict(_cache) if _cache else {} # ALWAYS the same fixed 6-key schema so every response is consistent; # before the first pipeline run the values are just null / empty lists. body = _public_output(c) return JSONResponse(body, headers={ "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=60", }) @app.get("/api/refresh") async def trigger_refresh(request: Request): """Manually trigger a data refresh, gated so the public can't spam it.""" if not _has_access(request): return JSONResponse({"status": "gated"}, status_code=403) if not _is_fetching: t = threading.Thread(target=lambda: _cache.update(run_pipeline()), daemon=True) t.start() return {"status": "refreshing"} # --------------------------------------------------------------------------- # Admin panel # --------------------------------------------------------------------------- @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 # ── live trading (admin only; all money settings come from env/secrets) ── @app.get("/api/admin/trade/status") async def admin_trade_status(x_token: str | None = Header(default=None)): err = _auth_or_401(x_token) if err: return err return {"ok": True, "trade": trader.status()} @app.get("/api/admin/trade/diagnose") async def admin_trade_diagnose(x_token: str | None = Header(default=None)): """Which MEXC hosts/paths this server can actually reach. Places no orders.""" err = _auth_or_401(x_token) if err: return err return {"ok": True, "checks": trader.diagnose()} @app.post("/api/admin/trade/test") async def admin_trade_test(x_token: str | None = Header(default=None)): """Smallest possible live round-trip so you can verify orders really land.""" err = _auth_or_401(x_token) if err: return err ok, msg = trader.test_trade() return JSONResponse({"ok": ok, "message": msg}, status_code=200 if ok else 400) @app.post("/api/admin/trade/close") async def admin_trade_close(x_token: str | None = Header(default=None)): """Panic button: cancel all orders and market-close any open position.""" err = _auth_or_401(x_token) if err: return err ok, msg = trader.panic_close() return JSONResponse({"ok": ok, "message": msg}, status_code=200 if ok else 400) @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": edb.get_clients(), "settings": edb.get_email_settings(), "log": edb.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")), "scorecard": _cache.get("scorecard", {}), "data_span": db.sentiment_span(), "index_basis": (_cache.get("sentiment_24h", {}).get("overall", {}) or {}).get("index_basis"), "baseline_net": (_cache.get("sentiment_24h", {}).get("overall", {}) or {}).get("baseline_net"), } @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 = edb.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 edb.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 edb.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 edb.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": edb.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 endpoints, hit by an external free scheduler (GitHub Actions) so # briefs fire and the host stays awake even on sleepy free tiers. # Secured by a shared secret (CRON_SECRET in .env). # --------------------------------------------------------------------------- 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). Fail-closed: refuses if CRON_SECRET is unset or the key doesn't match, so a missing secret can never leave mass-email triggering open to the public.""" if not CRON_SECRET or 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")