Files
trumpsignal-backend/app/services/funding_signal.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

222 lines
8.7 KiB
Python

"""
Breakout Signal Monitor — ETH + LINK (BTC as trend context)
Polls Binance every 5 minutes. When both conditions are met:
1. BB squeeze: BB width in bottom 20% of last 60 candles
2. Volume spike: current volume > 2.5x 20-period average
3. Taker buy ratio > 60%
4. Price closes above upper Bollinger Band
Broadcasts alert via WebSocket. Gated by user on/off toggle.
"""
import collections
import logging
from datetime import datetime, timezone
from typing import Deque, Optional
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
# ── Config ────────────────────────────────────────────────────────────────────
WATCH_SYMBOLS = ["ETHUSDT", "LINKUSDT"]
CONTEXT_SYMBOL = "BTCUSDT"
ALL_SYMBOLS = [CONTEXT_SYMBOL] + WATCH_SYMBOLS
CANDLE_LIMIT = 200 # candles to fetch per poll (200 x 5m = ~16.7h history)
BB_PERIOD = 20
BB_SQUEEZE_PCT = 20 # bottom 20% of BB width history → squeeze
VOLUME_MULT = 2.5
TBR_THRESH = 0.60 # taker buy ratio threshold
BTC_TREND_MA = 288 # 24h trend (needs separate longer fetch for BTC)
# ── State ─────────────────────────────────────────────────────────────────────
_enabled: bool = False
_recent_signals: Deque[dict] = collections.deque(maxlen=50)
_last_fired: dict[str, Optional[datetime]] = {s: None for s in WATCH_SYMBOLS}
# ── Public API ────────────────────────────────────────────────────────────────
def set_enabled(value: bool) -> None:
global _enabled
_enabled = value
logger.info("Funding signal monitor: %s", "ENABLED" if value else "DISABLED")
def is_enabled() -> bool:
return _enabled
def get_recent_signals(limit: int = 20) -> list[dict]:
return list(reversed(list(_recent_signals)))[:limit]
def get_status() -> dict:
return {
"enabled": _enabled,
"symbols": WATCH_SYMBOLS,
"recent_signal_count": len(_recent_signals),
}
# ── Binance fetch ─────────────────────────────────────────────────────────────
async def _fetch_klines(client: httpx.AsyncClient, symbol: str, limit: int) -> list[list]:
url = f"{settings.binance_rest_url}/api/v3/klines"
try:
resp = await client.get(url, params={
"symbol": symbol, "interval": "5m", "limit": limit
}, timeout=15)
resp.raise_for_status()
return resp.json()
except Exception as exc:
logger.warning("Failed to fetch klines for %s: %s", symbol, exc)
return []
def _parse_candle(row: list) -> dict:
total_vol = float(row[5])
taker_buy = float(row[9])
return {
"time": row[0],
"open": float(row[1]),
"high": float(row[2]),
"low": float(row[3]),
"close": float(row[4]),
"volume": total_vol,
"tbr": taker_buy / total_vol if total_vol > 0 else 0.5,
}
# ── Indicators ────────────────────────────────────────────────────────────────
def _compute_signal(candles: list[dict]) -> Optional[dict]:
"""
Returns signal dict if breakout signal fires on the most recent candle,
else None. Requires at least 60 candles.
"""
# Drop the last (current incomplete) candle — always use completed candles
candles = candles[:-1]
if len(candles) < 60:
return None
closes = [c["close"] for c in candles]
volumes = [c["volume"] for c in candles]
tbrs = [c["tbr"] for c in candles]
# ── Bollinger Bands (last BB_PERIOD candles) ──────────────────────────────
window = closes[-BB_PERIOD:]
bb_mid = sum(window) / BB_PERIOD
variance = sum((x - bb_mid) ** 2 for x in window) / BB_PERIOD
bb_std = variance ** 0.5
bb_upper = bb_mid + 2 * bb_std
bb_width = (4 * bb_std) / bb_mid if bb_mid > 0 else 0
# BB width percentile over last 60 candles
widths = []
for i in range(len(candles) - 60, len(candles)):
w_window = closes[max(0, i - BB_PERIOD + 1): i + 1]
if len(w_window) < BB_PERIOD:
continue
m = sum(w_window) / len(w_window)
s = (sum((x - m) ** 2 for x in w_window) / len(w_window)) ** 0.5
widths.append((4 * s) / m if m > 0 else 0)
if not widths:
return None
rank = sum(1 for w in widths if w < bb_width) / len(widths) * 100
in_squeeze = rank < BB_SQUEEZE_PCT
# ── Volume ────────────────────────────────────────────────────────────────
vol_ma = sum(volumes[-BB_PERIOD - 1:-1]) / BB_PERIOD
vol_spike = volumes[-1] > VOLUME_MULT * vol_ma if vol_ma > 0 else False
# ── Taker buy ratio ───────────────────────────────────────────────────────
tbr_ok = tbrs[-1] > TBR_THRESH
# ── Price above upper BB ──────────────────────────────────────────────────
above_bb = closes[-1] > bb_upper
if in_squeeze and vol_spike and tbr_ok and above_bb:
return {
"bb_width_pct": round(rank, 1),
"vol_mult": round(volumes[-1] / vol_ma, 2) if vol_ma > 0 else 0,
"tbr": round(tbrs[-1], 3),
"close": closes[-1],
"bb_upper": round(bb_upper, 4),
}
return None
def _btc_trend(candles: list[dict]) -> Optional[bool]:
"""True = BTC above 24h MA, False = below, None = not enough data."""
if len(candles) < BTC_TREND_MA:
return None
closes = [c["close"] for c in candles]
ma = sum(closes[-BTC_TREND_MA:]) / BTC_TREND_MA
return closes[-1] > ma
# ── Main poll loop ────────────────────────────────────────────────────────────
async def poll_funding_signal() -> None:
"""Called by APScheduler every 5 minutes."""
from app.ws.manager import manager # avoid circular import at module load
async with httpx.AsyncClient(timeout=20) as client:
# Fetch BTC with longer history for 24h trend
btc_rows = await _fetch_klines(client, CONTEXT_SYMBOL, BTC_TREND_MA + 10)
btc_candles = [_parse_candle(r) for r in btc_rows]
btc_up = _btc_trend(btc_candles)
# Fetch ETH + LINK
for symbol in WATCH_SYMBOLS:
rows = await _fetch_klines(client, symbol, CANDLE_LIMIT)
if not rows:
continue
candles = [_parse_candle(r) for r in rows]
sig = _compute_signal(candles)
if sig is None:
continue
# Deduplicate: don't fire same symbol twice within 30 minutes
now = datetime.now(timezone.utc)
last = _last_fired.get(symbol)
if last and (now - last).total_seconds() < 1800:
logger.info("Signal for %s deduplicated (too soon)", symbol)
continue
_last_fired[symbol] = now
alert = {
"type": "funding_signal",
"symbol": symbol,
"time": now.isoformat(),
"close": sig["close"],
"tbr": sig["tbr"],
"vol_mult": sig["vol_mult"],
"bb_pct": sig["bb_width_pct"],
"bb_upper": sig["bb_upper"],
"btc_trend": "↑ uptrend" if btc_up else ("↓ downtrend" if btc_up is False else "unknown"),
"enabled": _enabled,
}
_recent_signals.append(alert)
if _enabled:
logger.info("🚨 SIGNAL %s | price=%.4f tbr=%.2f vol=%.1fx btc=%s",
symbol, sig["close"], sig["tbr"], sig["vol_mult"],
alert["btc_trend"])
await manager.broadcast(alert)
else:
logger.info("Signal %s (monitor OFF — not broadcast)", symbol)