import asyncio import logging from contextlib import asynccontextmanager from apscheduler.schedulers.asyncio import AsyncIOScheduler from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from app.config import settings 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.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 logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) logger = logging.getLogger(__name__) from typing import Optional _binance_task: Optional[asyncio.Task] = None _scheduler: Optional[AsyncIOScheduler] = None @asynccontextmanager async def lifespan(app: FastAPI): global _binance_task, _scheduler # 1. Create DB tables (dev convenience; production uses Alembic) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) logger.info("Database tables ensured.") # 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.") # 3. Start Truth Social poller via APScheduler _scheduler = AsyncIOScheduler() _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. _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=None, # APScheduler will pick a fresh slot ) _scheduler.start() logger.info( "Truth Social pollers scheduled every %ds (CNN + trumpstruth.org).", settings.truth_social_poll_seconds, ) 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 await engine.dispose() logger.info("Shutdown complete.") app = FastAPI( title="TrumpSignal API", version="1.0.0", description="Crypto trading signals derived from Trump's Truth Social posts.", lifespan=lifespan, ) # CORS allowed_origins = [ settings.frontend_url, "http://localhost:3001", "http://localhost:3000", ] 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.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)") body = { "status": "ok" if not problems else "degraded", "now": now.isoformat(), "db_ok": db_ok, "db_error": db_error, "scrapers": source_status, "freshest_age_sec": freshest_age, "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)