Files
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

76 lines
2.8 KiB
Python

"""
WebSocket connection manager.
One global broadcaster that fans out every message to every connected client.
Critical that broadcast NEVER blocks the caller — Binance kline ticks arrive
every ~500 ms and the broadcast is inline with the WS read loop. A single
slow / half-closed client could otherwise stall the kline pipeline, miss the
keepalive ping, and tear the upstream Binance socket down (this was the root
cause of the "no close frame received or sent" reconnect storms in prod logs).
Two defences here:
* Per-client send wrapped in `asyncio.wait_for(..., timeout=PER_CLIENT_SEND_TIMEOUT)`.
* All clients dispatched in parallel via `asyncio.gather()` so one slow
client can't delay anyone else.
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import List
from fastapi import WebSocket
logger = logging.getLogger(__name__)
# Per-client send timeout. 2 s is plenty for a healthy TCP send of our tiny
# JSON payloads; anything slower means a half-closed or zombie client.
PER_CLIENT_SEND_TIMEOUT = 2.0
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, ws: WebSocket):
await ws.accept()
self.active_connections.append(ws)
logger.info("WebSocket connected. Total connections: %d", len(self.active_connections))
async def disconnect(self, ws: WebSocket):
if ws in self.active_connections:
self.active_connections.remove(ws)
logger.info("WebSocket disconnected. Total connections: %d", len(self.active_connections))
async def broadcast(self, message: dict):
if not self.active_connections:
return
payload = json.dumps(message)
# Snapshot the list — disconnect() mutates active_connections and we
# don't want "list changed during iteration" when we prune.
connections = list(self.active_connections)
async def _send_one(ws: WebSocket):
try:
await asyncio.wait_for(ws.send_text(payload),
timeout=PER_CLIENT_SEND_TIMEOUT)
return None
except asyncio.TimeoutError:
logger.warning("WebSocket send timed out (>%.1fs) — pruning client",
PER_CLIENT_SEND_TIMEOUT)
return ws
except Exception as exc:
logger.warning("WebSocket send failed: %s — pruning client", exc)
return ws
# Fan out concurrently — one slow client can't stall the others.
results = await asyncio.gather(*[_send_one(ws) for ws in connections])
for dead_ws in results:
if dead_ws is not None:
await self.disconnect(dead_ws)
manager = ConnectionManager()