Files
trumpsignal-backend/app/api/scanners.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

183 lines
6.1 KiB
Python

"""
Scanner control endpoints — kill switch + per-scanner toggle + status.
GET /scanners is public (read-only status, polled by the UI).
ALL mutating endpoints (toggle / all-disable / all-enable) require a SIGNED
request from a SUBSCRIBED wallet. Rationale: the scanner switch is a global
control — flipping it OFF stops the signal engine for every user. Before, it
was unauthenticated and any anonymous visitor could kill it. Now the caller
must prove wallet ownership (EIP-191 signature) AND already be a subscriber.
# See what's running (still public)
curl http://localhost:8000/api/scanners
Mutations are no longer curl-able without a wallet signature — that's the
point. Use the dashboard (which signs via the connected wallet).
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Subscription
from app.services import scanner_state
from app.services.signed_request import verify_signed_request
router = APIRouter()
logger = logging.getLogger(__name__)
ACTION_TOGGLE_SCANNER = "toggle_scanner"
ACTION_SCANNER_KILL = "scanner_kill_all"
ACTION_SCANNER_REVIVE = "scanner_revive_all"
class ScannerSummary(BaseModel):
name: str
enabled: bool
last_run_at: Optional[str] = None
last_status: str
last_message: Optional[str] = None
last_fired_at: Optional[str] = None
total_runs: int
total_fires: int
consecutive_errors: int
class ScannersResponse(BaseModel):
count: int
enabled: int
scanners: list[ScannerSummary]
class ToggleResponse(BaseModel):
status: str
name: Optional[str] = None
enabled: Optional[bool] = None
count: Optional[int] = None
async def _require_subscribed_signer(
request: Request,
db: AsyncSession,
*,
action: str,
body: Optional[dict],
) -> str:
"""Verify the signed envelope and that the signer is a subscriber.
Expects JSON body: { wallet, timestamp, signature, ...body fields }.
`body` is the canonical signed payload (must match what the client
hashed) — pass None for no-body actions (kill/revive).
Returns the verified lower-cased wallet.
"""
raw = await request.json()
wallet = (raw.get("wallet") or "").lower().strip()
timestamp = raw.get("timestamp")
signature = raw.get("signature")
if not wallet:
raise HTTPException(422, "wallet required")
if not isinstance(timestamp, int):
raise HTTPException(422, "timestamp (ms) required")
if not isinstance(signature, str) or not signature:
raise HTTPException(422, "signature required")
verify_signed_request(
action=action,
wallet=wallet,
timestamp_ms=timestamp,
signature=signature,
body=body,
)
sub = (await db.execute(
select(Subscription).where(Subscription.wallet_address == wallet)
)).scalar_one_or_none()
if sub is None:
raise HTTPException(403, "Wallet is not a subscriber — scanner control denied")
return wallet
@router.get("/scanners", response_model=ScannersResponse)
async def list_scanners() -> ScannersResponse:
"""Operational snapshot of every registered scanner. Polled by the UI
every 15-30s. Idempotent + cheap (in-memory dict read). Public."""
all_states = scanner_state.get_all()
return ScannersResponse(
count=len(all_states),
enabled=sum(1 for s in all_states if s.enabled),
scanners=[ScannerSummary(**s.to_dict()) for s in all_states],
)
@router.post("/scanners/{name}/toggle", response_model=ToggleResponse)
async def toggle_scanner(
name: str,
request: Request,
db: AsyncSession = Depends(get_db),
) -> ToggleResponse:
"""Flip ONE scanner. Signed + subscriber-gated.
Body: { wallet, timestamp, signature, name, enabled }
The signed body is { "enabled": <bool>, "name": <str> }.
"""
raw = await request.json()
enabled = raw.get("enabled")
body_name = raw.get("name")
if not isinstance(enabled, bool):
raise HTTPException(422, "enabled (bool) required")
if body_name != name:
raise HTTPException(400, "scanner name mismatch (path vs signed body)")
# NOTE: request.json() is cached by Starlette, so re-reading inside the
# helper returns the same payload.
wallet = await _require_subscribed_signer(
request, db, action=ACTION_TOGGLE_SCANNER,
body={"enabled": enabled, "name": name},
)
s = scanner_state.set_enabled(name, enabled)
if s is None:
raise HTTPException(404, f"unknown scanner {name!r}")
logger.info("Scanner %s set enabled=%s by %s", name, s.enabled, wallet)
return ToggleResponse(status="ok", name=name, enabled=s.enabled)
@router.post("/scanners/all/disable", response_model=ToggleResponse)
async def kill_switch(
request: Request,
db: AsyncSession = Depends(get_db),
) -> ToggleResponse:
"""Emergency stop — disables ALL scanners. Signed + subscriber-gated.
Body: { wallet, timestamp, signature }. Does NOT close open positions —
manage those via /positions/{id}/close or the Trades page."""
wallet = await _require_subscribed_signer(
request, db, action=ACTION_SCANNER_KILL, body=None,
)
n = scanner_state.disable_all()
logger.warning("ALL scanners disabled (%d) by %s", n, wallet)
return ToggleResponse(status="killed", count=n)
@router.post("/scanners/all/enable", response_model=ToggleResponse)
async def revive_all(
request: Request,
db: AsyncSession = Depends(get_db),
) -> ToggleResponse:
"""Re-enable every scanner that was disabled. Signed + subscriber-gated.
Body: { wallet, timestamp, signature }."""
wallet = await _require_subscribed_signer(
request, db, action=ACTION_SCANNER_REVIVE, body=None,
)
n = scanner_state.enable_all()
logger.info("ALL scanners re-enabled (%d) by %s", n, wallet)
return ToggleResponse(status="revived", count=n)