Files
trumpsignal-backend/app/main.py
T
k 5fb1d52026 Pre-launch hardening: KOL module, Telegram, scanners, WS resilience
Big-picture changes since b941223:

KOL pipeline (new) — Substack/podcast/blog RSS → AI ticker extraction →
on-chain wallet diff → talks-vs-trades divergence detection. Daily polls,
19 feeds, divergence emits Post + Telegram fan-out.

Telegram push (new) — walletless free tier + wallet-linked Pro upgrade,
in-bot preference commands (/trump /btc /funding /kol /conf /quiet),
signed-envelope API for dashboard. Disconnect-wallet keeps free
subscription.

BTC funding-rate reversal scanner (new) — hourly cron, 30d cumulative
funding threshold + mean-revert + 7d price confirm, emits via
/api/signals/ingest. BTC bottom-reversal scanner promoted to System 2.

WS broadcast rewrite — per-client send timeout + parallel fan-out
(asyncio.gather). Fixes "Binance WS no close frame" reconnect storms +
APScheduler 11-min job misses, both caused by one slow client stalling
the kline loop.

Error visibility — three silent-error sites (trumpstruth/truth_social
fetchers, funding_reversal scanner) now include exception type name so
httpx ConnectError-style empty-message errors stop logging blank lines.
Telegram bot loop now classifies ReadTimeout vs network vs unknown +
logger.exception for the unknown bucket.

Security hygiene — trumpsignal.db untracked from git (held subscriber
wallets + encrypted HL keys + 22 bot trades); .gitignore now blocks
*.db/.next/backups. CORS only allows FRONTEND_URL in production.

New ops scripts —
  - scripts/preflight.py: env/DB/Telegram/AI auth verification gate
  - scripts/backup_db.sh: cron-friendly daily DB backup (SQLite + Postgres)
  - scripts/seed_kol_wallets.py: idempotent KOL on-chain wallet seeder

15 new Alembic migrations (007-021) covering convex strategy fields,
phase-1 safety, two-system frozen exits, invalidation prices, dynamic
SYS2 leverage, staged de-risk + pyramiding, peak gain tracking, risk
mode, auto-trade + grow flags, KOL module, KOL on-chain, KOL divergence,
Telegram bindings + walletless.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 00:52:56 +08:00

375 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
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
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
_telegram_task: Optional[asyncio.Task] = None
_scheduler: Optional[AsyncIOScheduler] = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global _binance_task, _telegram_task, _scheduler
# 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.")
# 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 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.")
_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 _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.")
app = FastAPI(
title="TrumpSignal API",
version="1.0.0",
description="Crypto trading signals derived from Trump's Truth Social posts.",
lifespan=lifespan,
)
# 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.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)