5fb1d52026
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>
240 lines
9.5 KiB
Python
Executable File
240 lines
9.5 KiB
Python
Executable File
#!/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()))
|