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>
This commit is contained in:
Executable
+239
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pre-launch readiness check.
|
||||
|
||||
Run this RIGHT BEFORE flipping production traffic. Verifies that:
|
||||
* Every required env var is set
|
||||
* KEK / shared secrets have plausible entropy (not "change_me")
|
||||
* DB is reachable + schema is at the latest Alembic head
|
||||
* The Telegram bot token authenticates with the actual @username
|
||||
* AI provider answers with a real model id
|
||||
* No leftover open positions in bot_trades that the bot doesn't know about
|
||||
|
||||
Exits non-zero on any failure so you can wire it into CI / a deploy gate.
|
||||
|
||||
DATABASE_URL=... venv/bin/python scripts/preflight.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from app.config import settings
|
||||
from app.database import AsyncSessionLocal, engine
|
||||
|
||||
|
||||
# Vars that MUST be non-empty for production. If you genuinely don't need one
|
||||
# (e.g. running without Telegram), comment it out — don't push junk values.
|
||||
REQUIRED = [
|
||||
("database_url", "Where the API stores everything"),
|
||||
("frontend_url", "Used as the single CORS origin"),
|
||||
("encryption_key", "KEK for HL API key envelope encryption"),
|
||||
("ingest_api_key", "Shared secret for /api/signals/ingest"),
|
||||
]
|
||||
# Required for the listed feature; non-fatal but logged as a warning.
|
||||
OPTIONAL_BUT_RECOMMENDED = [
|
||||
("ai_api_key", "Trump signal AI scoring (or anthropic_api_key)"),
|
||||
("anthropic_api_key", "Required if ai_api_key empty AND you want KOL analysis"),
|
||||
("telegram_bot_token", "Telegram push alerts (whole feature disabled if empty)"),
|
||||
("telegram_bot_username","Required for the dashboard's Telegram connect deep link"),
|
||||
("etherscan_api_key", "KOL on-chain (talks-vs-trades) — Ethereum side"),
|
||||
# NOTE: glassnode_api_key is no longer required — the BTC bottom-reversal
|
||||
# scanner switched to AHR999 + 200-week MA + Pi Cycle Bottom (all derived
|
||||
# from public price candles, no Glassnode call). The setting is kept on
|
||||
# config.Settings for future Glassnode-backed signals but isn't checked.
|
||||
]
|
||||
# These should NEVER appear unchanged in production.
|
||||
SUSPECT_DEFAULTS = {
|
||||
"change_me_in_production",
|
||||
"your_key_here",
|
||||
"CHANGE_ME",
|
||||
"",
|
||||
}
|
||||
|
||||
|
||||
def red(s): return f"\033[31m{s}\033[0m"
|
||||
def green(s): return f"\033[32m{s}\033[0m"
|
||||
def yellow(s): return f"\033[33m{s}\033[0m"
|
||||
|
||||
|
||||
async def check_env() -> list[str]:
|
||||
errors = []
|
||||
print("── env vars ──────────────────────────────────────")
|
||||
for name, why in REQUIRED:
|
||||
v = getattr(settings, name, "")
|
||||
if not v or v in SUSPECT_DEFAULTS:
|
||||
print(red(f" ✗ {name:25s} EMPTY or default — required: {why}"))
|
||||
errors.append(f"{name} empty")
|
||||
else:
|
||||
shown = v if len(v) < 30 else v[:8] + "…" + v[-4:]
|
||||
print(green(f" ✓ {name:25s} = {shown}"))
|
||||
print()
|
||||
print("── recommended (warnings only) ────────────────────")
|
||||
for name, why in OPTIONAL_BUT_RECOMMENDED:
|
||||
v = getattr(settings, name, "")
|
||||
if not v:
|
||||
print(yellow(f" ! {name:25s} empty — {why}"))
|
||||
else:
|
||||
print(green(f" ✓ {name:25s} set"))
|
||||
return errors
|
||||
|
||||
|
||||
async def check_db() -> list[str]:
|
||||
errors = []
|
||||
print()
|
||||
print("── database ──────────────────────────────────────")
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await db.execute(text("SELECT 1"))
|
||||
print(green(" ✓ DB reachable"))
|
||||
except Exception as exc:
|
||||
print(red(f" ✗ DB unreachable: {exc}"))
|
||||
errors.append("db unreachable")
|
||||
return errors
|
||||
|
||||
# Schema check — current head
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
r = await conn.execute(text("SELECT version_num FROM alembic_version"))
|
||||
row = r.fetchone()
|
||||
current = row[0] if row else None
|
||||
# Read the latest version from alembic/versions/
|
||||
versions_dir = Path(__file__).resolve().parent.parent / "alembic" / "versions"
|
||||
head_files = sorted(p.stem for p in versions_dir.glob("*.py")
|
||||
if p.stem != "__init__")
|
||||
latest_prefix = max(int(f.split("_")[0]) for f in head_files
|
||||
if f.split("_")[0].isdigit())
|
||||
if current and current.startswith(f"{latest_prefix:03d}".rstrip("0") or "0"):
|
||||
print(green(f" ✓ alembic at head {current}"))
|
||||
elif current:
|
||||
print(yellow(f" ! alembic at {current} but versions/ has up to {latest_prefix:03d}"))
|
||||
print(yellow(f" → run: alembic upgrade head"))
|
||||
else:
|
||||
print(red(" ✗ alembic_version table empty — schema unmanaged"))
|
||||
errors.append("schema unmanaged")
|
||||
except Exception as exc:
|
||||
print(yellow(f" ! schema version check failed: {exc}"))
|
||||
|
||||
# Orphan open positions
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
r = await db.execute(text(
|
||||
"SELECT COUNT(*) FROM bot_trades WHERE closed_at IS NULL"
|
||||
))
|
||||
n = r.scalar() or 0
|
||||
if n:
|
||||
print(yellow(f" ! {n} open bot_trades rows — verify these match HL state before launch"))
|
||||
else:
|
||||
print(green(" ✓ no orphan open positions"))
|
||||
except Exception as exc:
|
||||
print(yellow(f" ! open-positions check failed: {exc}"))
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
async def check_telegram() -> list[str]:
|
||||
errors = []
|
||||
print()
|
||||
print("── telegram ──────────────────────────────────────")
|
||||
if not settings.telegram_bot_token:
|
||||
print(yellow(" ! token empty — skipping"))
|
||||
return errors
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(
|
||||
f"https://api.telegram.org/bot{settings.telegram_bot_token}/getMe"
|
||||
)
|
||||
if r.status_code != 200:
|
||||
print(red(f" ✗ getMe HTTP {r.status_code}: {r.text[:120]}"))
|
||||
errors.append("telegram auth failed")
|
||||
return errors
|
||||
data = r.json()
|
||||
if not data.get("ok"):
|
||||
print(red(f" ✗ getMe returned ok=false: {data}"))
|
||||
errors.append("telegram auth failed")
|
||||
return errors
|
||||
bot_username = data["result"]["username"]
|
||||
print(green(f" ✓ token authenticates as @{bot_username}"))
|
||||
if settings.telegram_bot_username and bot_username != settings.telegram_bot_username:
|
||||
print(red(f" ✗ TELEGRAM_BOT_USERNAME='{settings.telegram_bot_username}'"
|
||||
f" but token is for @{bot_username}"))
|
||||
errors.append("telegram username mismatch")
|
||||
except Exception as exc:
|
||||
print(red(f" ✗ telegram check failed: {exc}"))
|
||||
errors.append("telegram unreachable")
|
||||
return errors
|
||||
|
||||
|
||||
async def check_ai() -> list[str]:
|
||||
errors = []
|
||||
print()
|
||||
print("── ai provider ───────────────────────────────────")
|
||||
if settings.anthropic_api_key:
|
||||
# Cheap, free models endpoint check.
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(
|
||||
"https://api.anthropic.com/v1/models",
|
||||
headers={
|
||||
"x-api-key": settings.anthropic_api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
print(green(" ✓ anthropic_api_key authenticates"))
|
||||
else:
|
||||
print(red(f" ✗ anthropic /v1/models HTTP {r.status_code}: {r.text[:120]}"))
|
||||
errors.append("anthropic auth failed")
|
||||
except Exception as exc:
|
||||
print(red(f" ✗ anthropic check failed: {exc}"))
|
||||
elif settings.ai_api_key:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(
|
||||
f"{settings.ai_base_url.rstrip('/')}/models",
|
||||
headers={"Authorization": f"Bearer {settings.ai_api_key}"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
print(green(f" ✓ ai_api_key authenticates at {settings.ai_base_url}"))
|
||||
else:
|
||||
print(red(f" ✗ /models HTTP {r.status_code}: {r.text[:120]}"))
|
||||
errors.append("ai auth failed")
|
||||
except Exception as exc:
|
||||
print(red(f" ✗ ai check failed: {exc}"))
|
||||
else:
|
||||
print(yellow(" ! no ai provider key set — Trump signals will not be scored"))
|
||||
return errors
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
print(f"Preflight check — env: {settings.environment}\n")
|
||||
if settings.environment != "production":
|
||||
print(yellow("WARNING: settings.environment is not 'production'. Some dev-only"))
|
||||
print(yellow(" endpoints (/api/dev/*) and auto-create_all() are enabled.\n"))
|
||||
|
||||
all_errors: list[str] = []
|
||||
all_errors += await check_env()
|
||||
all_errors += await check_db()
|
||||
all_errors += await check_telegram()
|
||||
all_errors += await check_ai()
|
||||
await engine.dispose()
|
||||
|
||||
print()
|
||||
if all_errors:
|
||||
print(red(f"❌ {len(all_errors)} fatal issue(s):"))
|
||||
for e in all_errors:
|
||||
print(red(f" - {e}"))
|
||||
return 1
|
||||
print(green("✅ All checks passed. Safe to launch."))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(main()))
|
||||
Reference in New Issue
Block a user