54884f3e24
Feed-health pass over KOL_FEEDS: - raoulpal: stale Substack (last 2024-05) → Real Vision podcast feed - dampedspring: paywalled (0 entries) → free "Damped Spring 101" Substack - unchained: Cloudflare 403 → canonical Megaphone podcast feed - lynalden: Cloudflare 202 → FeedBurner mirror - glassnode: recovered via httpx http2=True (was 403 on HTTP/1.1) - browser User-Agent + Accept headers on feed fetch - removed dead feeds with no active replacement: placeholder, dragonfly, niccarter, eugene - pin h2==4.3.0 (required by http2=True) All 25 remaining feeds verified fetching real body content; newest post per feed ≤88d. Bundles in-flight KOL-module work already in the working tree (kol_x ingest, migration 027, tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
566 lines
24 KiB
Python
566 lines
24 KiB
Python
import asyncio
|
||
import fcntl
|
||
import logging
|
||
import os
|
||
from contextlib import asynccontextmanager
|
||
|
||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from slowapi import _rate_limit_exceeded_handler
|
||
from slowapi.errors import RateLimitExceeded
|
||
from slowapi.middleware import SlowAPIMiddleware
|
||
|
||
from app.config import settings
|
||
from app.ratelimit import limiter
|
||
from app.database import AsyncSessionLocal, engine
|
||
from app.models import Base
|
||
from app.scrapers.truth_social import poll_truth_social, backfill_history
|
||
from app.scrapers.trumpstruth import poll_trumpstruth
|
||
from app.services.binance import run_binance_ws
|
||
from app.services.hl_price_feed import run_hl_price_feed
|
||
from app.ws.manager import manager
|
||
|
||
# Import all routers
|
||
from app.api.posts import router as posts_router
|
||
from app.api.prices import router as prices_router
|
||
from app.api.trades import router as trades_router
|
||
from app.api.performance import router as performance_router
|
||
from app.api.subscribe import router as subscribe_router
|
||
from app.api.user import router as user_router
|
||
from app.api.funding_signal import router as funding_signal_router
|
||
from app.api.funding_reversal import router as funding_reversal_router
|
||
from app.api.telegram import router as telegram_router
|
||
from app.api.signals import router as signals_router
|
||
from app.api.positions import router as positions_router
|
||
from app.api.scanners import router as scanners_router
|
||
from app.api.kol import router as kol_router
|
||
from app.api.macro import router as macro_router
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
from datetime import datetime, timezone
|
||
from typing import Optional
|
||
_binance_task: Optional[asyncio.Task] = None
|
||
_hl_price_task: Optional[asyncio.Task] = None
|
||
_telegram_task: Optional[asyncio.Task] = None
|
||
_scheduler: Optional[AsyncIOScheduler] = None
|
||
|
||
# Process boot time — used by /api/health/deep to grant feeds a startup grace
|
||
# period before "no tick yet" counts as a failure (avoids a false 503 at boot).
|
||
_PROCESS_START_AT = datetime.now(timezone.utc)
|
||
_FEED_BOOT_GRACE_SEC = 180
|
||
|
||
# Single-instance guard. The backend is single-process BY DESIGN (APScheduler,
|
||
# scanner_state, the replay cache, tp_sl_monitor's open-trade table and
|
||
# price_store are all in-memory). Running >1 worker silently duplicates the
|
||
# scheduler, splits TP/SL state, and lets two processes both long-poll the
|
||
# Telegram token. This advisory file lock lets only ONE process ("the leader")
|
||
# start background tasks; any extra worker serves HTTP reads only and logs loud.
|
||
_singleton_lock_fd = None
|
||
_is_leader: bool = False
|
||
|
||
|
||
def _acquire_singleton_lock() -> bool:
|
||
"""Return True if this process won the advisory lock and may run background
|
||
tasks. False means another process/worker already holds it (multi-worker
|
||
misconfig). The OS releases the lock automatically when the holder exits,
|
||
so a crashed leader frees it for the next start."""
|
||
global _singleton_lock_fd
|
||
path = os.environ.get("SINGLETON_LOCK_PATH", "/tmp/trumpsignal-backend.lock")
|
||
try:
|
||
fd = open(path, "w")
|
||
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
try:
|
||
fd.write(str(os.getpid()))
|
||
fd.flush()
|
||
except OSError:
|
||
pass
|
||
_singleton_lock_fd = fd # keep the fd alive for the process lifetime
|
||
return True
|
||
except BlockingIOError:
|
||
# Lock held by another process — this is the multi-worker signal.
|
||
return False
|
||
except OSError as exc:
|
||
# Lock file itself is unusable (perms, missing dir). Fail OPEN so a
|
||
# lock-path problem never takes the whole service down — but log loudly.
|
||
logger.warning("Singleton lock unavailable (%s) — proceeding as leader.", exc)
|
||
return True
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
global _binance_task, _hl_price_task, _telegram_task, _scheduler, _is_leader
|
||
|
||
# 0. Single-instance guard. If another worker already holds the lock, this
|
||
# process must NOT start the scheduler / scrapers / price feeds / Telegram
|
||
# poller — otherwise scans double-fire, TP/SL state splits across processes,
|
||
# and two pollers fight over the Telegram token. Serve HTTP reads only.
|
||
_is_leader = _acquire_singleton_lock()
|
||
if not _is_leader:
|
||
logger.critical(
|
||
"SINGLETON LOCK NOT ACQUIRED — another backend process/worker is "
|
||
"already the leader. This process will serve HTTP reads ONLY and "
|
||
"start NO background tasks. You are almost certainly running with "
|
||
">1 worker, which is UNSUPPORTED (see deploy/supervisor.conf). "
|
||
"Run uvicorn/gunicorn with --workers 1."
|
||
)
|
||
yield
|
||
return
|
||
|
||
# 1. Dev convenience only. Production should rely on Alembic so schema
|
||
# ownership stays explicit and startup never mutates the DB implicitly.
|
||
if settings.environment == "development":
|
||
async with engine.begin() as conn:
|
||
await conn.run_sync(Base.metadata.create_all)
|
||
logger.info("Database tables ensured (development mode).")
|
||
else:
|
||
logger.info("Skipping create_all; expecting schema managed by Alembic.")
|
||
|
||
# 2. Backfill historical posts on startup (fast, no Claude API call)
|
||
asyncio.create_task(backfill_history(AsyncSessionLocal, limit=500))
|
||
|
||
# 2b. Rehydrate TP/SL + max-hold backstops for any positions still open in DB.
|
||
# Critical: without this, a redeploy/crash silently drops protection on live trades.
|
||
from app.services.recovery import rehydrate_open_trades
|
||
asyncio.create_task(rehydrate_open_trades())
|
||
|
||
# 3. Start Binance WebSocket background task
|
||
_binance_task = asyncio.create_task(run_binance_ws(), name="binance_ws")
|
||
logger.info("Binance WebSocket task started.")
|
||
|
||
# 3b. Supplemental HL price feed for assets not on Binance (HYPE, PURR, …)
|
||
_hl_price_task = asyncio.create_task(run_hl_price_feed(), name="hl_price_feed")
|
||
logger.info("HL supplemental price feed task started.")
|
||
|
||
# 3. Start Truth Social poller via APScheduler
|
||
_scheduler = AsyncIOScheduler()
|
||
# Signal monitor — polls every 5 minutes
|
||
from app.services.funding_signal import poll_funding_signal
|
||
_scheduler.add_job(
|
||
poll_funding_signal,
|
||
"interval",
|
||
minutes=5,
|
||
id="funding_signal_poll",
|
||
max_instances=1,
|
||
coalesce=True,
|
||
)
|
||
logger.info("Breakout signal monitor scheduled every 5 minutes.")
|
||
|
||
_scheduler.add_job(
|
||
poll_truth_social,
|
||
"interval",
|
||
seconds=settings.truth_social_poll_seconds,
|
||
args=[AsyncSessionLocal],
|
||
id="truth_social_poll",
|
||
max_instances=1,
|
||
coalesce=True,
|
||
)
|
||
# Fallback poller — trumpstruth.org RSS. Same Truth Social originalId =
|
||
# same external_id hash, so dedup is automatic. Whoever sees the post
|
||
# first wins. Offset by half the interval so the two pollers don't
|
||
# hammer the network at the same instant.
|
||
# Offset the fallback by half the interval so the two pollers don't hit
|
||
# upstream at the same instant. APScheduler treats next_run_time=None as
|
||
# "paused" (job never fires) — must be an actual datetime.
|
||
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
||
offset = settings.truth_social_poll_seconds // 2
|
||
_scheduler.add_job(
|
||
poll_trumpstruth,
|
||
"interval",
|
||
seconds=settings.truth_social_poll_seconds,
|
||
args=[AsyncSessionLocal],
|
||
id="trumpstruth_poll",
|
||
max_instances=1,
|
||
coalesce=True,
|
||
next_run_time=_dt.now(_tz.utc) + _td(seconds=offset),
|
||
)
|
||
# HL <-> DB reconciliation — every 60s, detects state drift
|
||
# (manual closes on HL UI, liquidations, orphan positions). See
|
||
# app/services/reconciler.py. Critical for live trading safety.
|
||
from app.services.reconciler import reconcile_all_once, RECONCILE_INTERVAL_SECONDS
|
||
_scheduler.add_job(
|
||
reconcile_all_once,
|
||
"interval",
|
||
seconds=RECONCILE_INTERVAL_SECONDS,
|
||
id="hl_reconcile",
|
||
max_instances=1,
|
||
coalesce=True,
|
||
)
|
||
logger.info("HL <-> DB reconciler scheduled every %ds.", RECONCILE_INTERVAL_SECONDS)
|
||
|
||
# ── System-2 bottom-reversal state machine ─────────────────────────────
|
||
# Low-frequency, long-only: fires when ≥2 of 3 classic bottom signals
|
||
# agree — AHR999 < 0.45, price ≤ 200-week MA ×1.05, Pi Cycle Bottom
|
||
# (150d EMA ≤ 471d SMA × 0.745). Funding is a booster inside the scanner,
|
||
# not an independent entry source. See app/services/scanners/btc_bottom_reversal.py.
|
||
from app.services.scanners.btc_bottom_reversal import scan_once as btc_bottom_scan
|
||
_scheduler.add_job(
|
||
btc_bottom_scan, "cron", hour=0, minute=45,
|
||
id="btc_bottom_reversal_scan", max_instances=1, coalesce=True,
|
||
)
|
||
logger.info("BTC bottom-reversal scanner scheduled daily at 00:45 UTC.")
|
||
|
||
# ── BTC funding-rate reversal (hourly) ────────────────────────────────
|
||
# Mean-reversion play on extreme perp positioning. Independent of the
|
||
# bottom-reversal state machine but uses the same `evaluate_funding_reversal`
|
||
# algorithm. Runs every hour because HL funding settles hourly.
|
||
from app.services.scanners.funding_reversal import scan_once as funding_scan
|
||
_scheduler.add_job(
|
||
funding_scan, "cron", minute=7, # :07 every hour, away from other jobs
|
||
id="funding_reversal_scan", max_instances=1, coalesce=True,
|
||
)
|
||
logger.info("Funding reversal scanner scheduled hourly at :07.")
|
||
|
||
# ── KOL Substack poller (daily) ──────────────────────────────────────
|
||
# Hayes & co publish at most a few times a week. Daily poll is plenty;
|
||
# RSS dedupe by URL is idempotent if it ever fires twice.
|
||
from app.services.kol_substack import run_substack_poll
|
||
_scheduler.add_job(
|
||
run_substack_poll, "cron", hour=1, minute=15,
|
||
id="kol_substack_poll", max_instances=1, coalesce=True,
|
||
)
|
||
logger.info("KOL Substack poller scheduled daily at 01:15 UTC.")
|
||
|
||
# ── KOL X (Twitter) tweet ingestion (daily) ───────────────────────────
|
||
# Polls X-native KOLs (andrewkang/@Rewkang, murad — no Substack feed) plus
|
||
# Hayes's real-time position statements. Writes KolPost(source="twitter")
|
||
# that the divergence scan (02:15) joins against on-chain data — this is the
|
||
# post-side feed that lights up the andrewkang/murad wallets. No-op if
|
||
# twitterapi_io_key is unset. Runs 01:30 — between substack (01:15) and
|
||
# on-chain (02:00) so fresh posts are present for the 02:15 divergence scan.
|
||
from app.services.kol_x import run_x_poll
|
||
_scheduler.add_job(
|
||
run_x_poll, "cron", hour=1, minute=30,
|
||
id="kol_x_poll", max_instances=1, coalesce=True,
|
||
)
|
||
logger.info("KOL X (Twitter) poller scheduled daily at 01:30 UTC.")
|
||
|
||
# ── KOL A-tier: on-chain holdings snapshot (daily) ────────────────────
|
||
# Polls HL public API (free) for perp positions; Arkham (key optional)
|
||
# for full portfolio. Diffs against yesterday's snapshot → writes
|
||
# kol_holding_changes. Runs at 02:00 UTC, after Substack poll finishes.
|
||
from app.services.kol_onchain import run_onchain_poll
|
||
_scheduler.add_job(
|
||
run_onchain_poll, "cron", hour=2, minute=0,
|
||
id="kol_onchain_poll", max_instances=1, coalesce=True,
|
||
)
|
||
logger.info("KOL on-chain holdings poller scheduled daily at 02:00 UTC.")
|
||
|
||
# ── KOL talks-vs-trades divergence scan (daily) ───────────────────────
|
||
# Runs after the on-chain poll finishes. Matches post ticker signals vs
|
||
# holding changes for the same KOL+ticker within ±7 days.
|
||
from app.services.kol_divergence import run_divergence_scan
|
||
_scheduler.add_job(
|
||
run_divergence_scan, "cron", hour=2, minute=15,
|
||
id="kol_divergence_scan", max_instances=1, coalesce=True,
|
||
)
|
||
logger.info("KOL divergence scan scheduled daily at 02:15 UTC.")
|
||
|
||
# ── Macro indicator daily snapshot ────────────────────────────────────
|
||
# 8 indicators (AHR999, Fear & Greed, BTC dominance, ETH/BTC, stablecoin
|
||
# supply, ETF flow, BTC OI, altcoin season). One row per calendar date.
|
||
# Runs after KOL jobs so a slow KOL fetch can't make this one miss.
|
||
from app.services.macro.poll import run_macro_poll
|
||
_scheduler.add_job(
|
||
run_macro_poll, "cron", hour=3, minute=0,
|
||
id="macro_poll", max_instances=1, coalesce=True,
|
||
)
|
||
logger.info("Macro indicator snapshot scheduled daily at 03:00 UTC.")
|
||
|
||
# Kick off an initial poll on startup IF today's row doesn't exist yet.
|
||
# Otherwise a fresh deploy shows an empty macro panel until 03:00 UTC of
|
||
# the next day. Fire-and-forget — never blocks startup.
|
||
async def _macro_bootstrap():
|
||
try:
|
||
from datetime import datetime, timezone
|
||
from sqlalchemy import select
|
||
from app.models import MacroSnapshot
|
||
today = datetime.now(timezone.utc).date()
|
||
async with AsyncSessionLocal() as db:
|
||
exists = (await db.execute(
|
||
select(MacroSnapshot).where(MacroSnapshot.snapshot_date == today)
|
||
)).scalar_one_or_none()
|
||
if exists is None:
|
||
logger.info("Macro: no row for today, running one-shot bootstrap fetch.")
|
||
await run_macro_poll()
|
||
except Exception as exc:
|
||
logger.warning("Macro bootstrap fetch failed: %s (%s)",
|
||
type(exc).__name__, exc)
|
||
asyncio.create_task(_macro_bootstrap())
|
||
|
||
# ── Telegram daily digest ─────────────────────────────────────────────
|
||
# Runs every hour at :00. Each binding picks its own digest_hour_utc
|
||
# (0–23, default 12); the sender only fans out to rows whose preferred
|
||
# hour matches the current one — so we get one cron job covering all 24
|
||
# delivery slots. The send function itself dedupes against
|
||
# last_digest_sent_at (skips if < 23h), so a coalesced cron or worker
|
||
# restart can't double-send a user in the same day.
|
||
from app.services.telegram_digest import send_daily_digest
|
||
_scheduler.add_job(
|
||
send_daily_digest, "cron", minute=0,
|
||
id="telegram_daily_digest",
|
||
max_instances=1, coalesce=True,
|
||
)
|
||
logger.info("Telegram daily digest scheduled hourly at :00 UTC "
|
||
"(per-user hour selection).")
|
||
|
||
_scheduler.start()
|
||
logger.info(
|
||
"Truth Social pollers scheduled every %ds (CNN + trumpstruth.org).",
|
||
settings.truth_social_poll_seconds,
|
||
)
|
||
|
||
# ── Telegram bot long-poll loop (optional) ────────────────────────────
|
||
# Only started if TELEGRAM_BOT_TOKEN is set. Handles /start CODE bindings
|
||
# and /stop, /status, /test commands. Survives transient network errors
|
||
# via internal back-off.
|
||
from app.services.telegram_bot import run_bot_loop
|
||
_telegram_task = asyncio.create_task(run_bot_loop(), name="telegram_bot")
|
||
logger.info("Telegram bot task created (will no-op if token missing).")
|
||
|
||
yield
|
||
|
||
# Shutdown
|
||
logger.info("Shutting down background tasks...")
|
||
if _scheduler:
|
||
_scheduler.shutdown(wait=False)
|
||
if _binance_task and not _binance_task.done():
|
||
_binance_task.cancel()
|
||
try:
|
||
await _binance_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
if _hl_price_task and not _hl_price_task.done():
|
||
_hl_price_task.cancel()
|
||
try:
|
||
await _hl_price_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
if _telegram_task and not _telegram_task.done():
|
||
_telegram_task.cancel()
|
||
try:
|
||
await _telegram_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
await engine.dispose()
|
||
logger.info("Shutdown complete.")
|
||
|
||
|
||
# Rate limiter is the shared instance from app.ratelimit — it keys on the
|
||
# real client IP (x-forwarded-for / x-real-ip) relayed by the Next.js proxy,
|
||
# NOT the proxy's own IP. See app/ratelimit.py for the BUG-02 rationale.
|
||
# Public read endpoints: 60 req/min. Signed mutations: 20 req/min (per-route).
|
||
|
||
app = FastAPI(
|
||
title="TrumpSignal API",
|
||
version="1.0.0",
|
||
description="Crypto trading signals derived from Trump's Truth Social posts.",
|
||
lifespan=lifespan,
|
||
)
|
||
app.state.limiter = limiter
|
||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||
# Register the middleware so default_limits actually applies to EVERY route
|
||
# (not just the two with an explicit @limiter.limit decorator). Without this,
|
||
# all signed-mutation routes had no rate limit at all. WebSocket scope is
|
||
# skipped by slowapi. Decorated routes keep their stricter explicit limit.
|
||
app.add_middleware(SlowAPIMiddleware)
|
||
|
||
# CORS
|
||
# In production we only allow the canonical frontend origin (FRONTEND_URL).
|
||
# In development we additionally permit the local Next dev server. NEVER
|
||
# permit "*" here — every endpoint either reads/writes user-personalised
|
||
# data or accepts signed envelopes, both of which require credentialled
|
||
# requests (and "*" is rejected by browsers in combination with credentials
|
||
# anyway).
|
||
allowed_origins = [settings.frontend_url]
|
||
if settings.environment == "development":
|
||
allowed_origins.extend([
|
||
"http://localhost:3000",
|
||
"http://localhost:3001",
|
||
])
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=allowed_origins,
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
# REST routers
|
||
app.include_router(posts_router, prefix="/api")
|
||
app.include_router(prices_router, prefix="/api")
|
||
app.include_router(trades_router, prefix="/api")
|
||
app.include_router(performance_router, prefix="/api")
|
||
app.include_router(subscribe_router, prefix="/api")
|
||
app.include_router(user_router, prefix="/api")
|
||
app.include_router(funding_signal_router, prefix="/api")
|
||
app.include_router(funding_reversal_router, prefix="/api")
|
||
app.include_router(telegram_router, prefix="/api")
|
||
app.include_router(signals_router, prefix="/api")
|
||
app.include_router(positions_router, prefix="/api")
|
||
app.include_router(scanners_router, prefix="/api")
|
||
app.include_router(kol_router, prefix="/api")
|
||
app.include_router(macro_router, prefix="/api")
|
||
|
||
|
||
@app.get("/api/health")
|
||
async def health():
|
||
"""Shallow liveness — used by load balancers / Docker healthcheck.
|
||
Returns 200 as long as the process is alive and the event loop responds."""
|
||
return {"status": "ok"}
|
||
|
||
|
||
@app.get("/api/health/deep")
|
||
async def health_deep():
|
||
"""Deep healthcheck — used by external uptime monitors (UptimeRobot, etc).
|
||
|
||
Returns 503 if any of:
|
||
- DB query fails
|
||
- Scraper hasn't completed a successful poll in > 90s
|
||
(poll runs every 15s; allow 6 misses before alarm)
|
||
|
||
Body always includes diagnostic fields so the alarm payload is actionable.
|
||
"""
|
||
from datetime import datetime, timezone
|
||
from fastapi import Response
|
||
from sqlalchemy import text
|
||
from app.scrapers import truth_social as ts_scraper
|
||
from app.scrapers import trumpstruth as ts_fallback
|
||
|
||
now = datetime.now(timezone.utc)
|
||
problems: list[str] = []
|
||
|
||
# 1. DB ping
|
||
db_ok = False
|
||
db_error: Optional[str] = None
|
||
try:
|
||
async with AsyncSessionLocal() as db:
|
||
await db.execute(text("SELECT 1"))
|
||
db_ok = True
|
||
except Exception as exc:
|
||
db_error = str(exc)
|
||
problems.append(f"db: {db_error}")
|
||
|
||
# 2. Scraper liveness — system is healthy if AT LEAST ONE source polled
|
||
# recently. Both stale = real problem (network down, or both upstreams dead).
|
||
sources = [
|
||
("cnn", ts_scraper.last_successful_poll_at, ts_scraper.last_poll_error),
|
||
("trumpstruth", ts_fallback.last_successful_poll_at, ts_fallback.last_poll_error),
|
||
]
|
||
source_status = []
|
||
freshest_age: Optional[int] = None
|
||
for name, last, err in sources:
|
||
age = int((now - last).total_seconds()) if last else None
|
||
source_status.append({
|
||
"name": name,
|
||
"last_poll": last.isoformat() if last else None,
|
||
"age_sec": age,
|
||
"last_error": err,
|
||
})
|
||
if age is not None and (freshest_age is None or age < freshest_age):
|
||
freshest_age = age
|
||
|
||
if freshest_age is None:
|
||
problems.append("scrapers: no source has ever completed a poll")
|
||
elif freshest_age > 90:
|
||
# 6× the 15s interval — both sources are silent
|
||
problems.append(f"scrapers: all stale (freshest={freshest_age}s)")
|
||
|
||
# 3. Price-feed liveness — INDEPENDENT of the scrapers. A dead price feed
|
||
# means tp_sl_monitor never fires: stop-loss / take-profit / trailing stops
|
||
# silently stop protecting live trades. This is a real-money incident the
|
||
# scraper check above would completely miss, so it gets its own per-feed gate.
|
||
from app.services import binance as _binance
|
||
from app.services import hl_price_feed as _hl_feed
|
||
|
||
boot_age = int((now - _PROCESS_START_AT).total_seconds())
|
||
in_grace = boot_age < _FEED_BOOT_GRACE_SEC
|
||
# (feed name, last_tick_at, max age before stale). Binance ticks multiple
|
||
# times/sec for BTC/ETH; HL feed polls every 2s. Thresholds are generous.
|
||
feeds = [
|
||
("binance_ws", _binance.last_tick_at, 120),
|
||
("hl_price_feed", _hl_feed.last_tick_at, 60),
|
||
]
|
||
feed_status = []
|
||
for name, last, max_age in feeds:
|
||
age = int((now - last).total_seconds()) if last else None
|
||
feed_status.append({
|
||
"name": name,
|
||
"last_tick": last.isoformat() if last else None,
|
||
"age_sec": age,
|
||
"max_age_sec": max_age,
|
||
})
|
||
if last is None:
|
||
# Never ticked: only a problem once past the boot grace window
|
||
# (otherwise we'd 503 for the first ~3 min of every restart).
|
||
if not in_grace:
|
||
problems.append(f"{name}: no tick since boot ({boot_age}s ago)")
|
||
elif age is not None and age > max_age:
|
||
problems.append(f"{name}: stale ({age}s > {max_age}s)")
|
||
|
||
# A follower (lost the singleton lock) runs no background tasks — surface it
|
||
# explicitly so the 503 it already triggers has an obvious root cause.
|
||
if not _is_leader:
|
||
problems.append("instance: not the singleton leader (>1 worker?) — no background tasks running")
|
||
|
||
body = {
|
||
"status": "ok" if not problems else "degraded",
|
||
"now": now.isoformat(),
|
||
"is_leader": _is_leader,
|
||
"db_ok": db_ok,
|
||
"db_error": db_error,
|
||
"scrapers": source_status,
|
||
"freshest_age_sec": freshest_age,
|
||
"price_feeds": feed_status,
|
||
"problems": problems,
|
||
}
|
||
|
||
if problems:
|
||
return Response(
|
||
content=__import__("json").dumps(body),
|
||
status_code=503,
|
||
media_type="application/json",
|
||
)
|
||
return body
|
||
|
||
if settings.environment == "development":
|
||
from app.api.dev import router as dev_router
|
||
app.include_router(dev_router, prefix="/api")
|
||
|
||
|
||
# ── WebSocket endpoints ────────────────────────────────────────────────────
|
||
|
||
|
||
@app.websocket("/ws/prices")
|
||
async def ws_prices(websocket: WebSocket):
|
||
"""Subscribe to live price ticks (type: 'price')."""
|
||
await manager.connect(websocket)
|
||
try:
|
||
while True:
|
||
# Keep connection alive; messages are pushed via manager.broadcast
|
||
await websocket.receive_text()
|
||
except WebSocketDisconnect:
|
||
await manager.disconnect(websocket)
|
||
except Exception as exc:
|
||
logger.warning("WebSocket /ws/prices error: %s", exc)
|
||
await manager.disconnect(websocket)
|
||
|
||
|
||
@app.websocket("/ws/posts")
|
||
async def ws_posts(websocket: WebSocket):
|
||
"""Subscribe to new post events (type: 'new_post')."""
|
||
await manager.connect(websocket)
|
||
try:
|
||
while True:
|
||
await websocket.receive_text()
|
||
except WebSocketDisconnect:
|
||
await manager.disconnect(websocket)
|
||
except Exception as exc:
|
||
logger.warning("WebSocket /ws/posts error: %s", exc)
|
||
await manager.disconnect(websocket)
|