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>
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user