4ffcb442fe
- New migrations for daily_budget, active_window, and bottrade snapshot - Add trumpstruth scraper and price_impact_monitor service - Expand bot_engine, hyperliquid, recovery, and tp_sl_monitor logic - Update API/schemas/models for new features Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
250 lines
8.2 KiB
Python
250 lines
8.2 KiB
Python
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)
|