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>
183 lines
6.1 KiB
Python
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)
|