Files
k 54884f3e24 KOL feeds: fix dead/blocked sources, drop stale feeds (29→25)
Feed-health pass over KOL_FEEDS:
- raoulpal: stale Substack (last 2024-05) → Real Vision podcast feed
- dampedspring: paywalled (0 entries) → free "Damped Spring 101" Substack
- unchained: Cloudflare 403 → canonical Megaphone podcast feed
- lynalden: Cloudflare 202 → FeedBurner mirror
- glassnode: recovered via httpx http2=True (was 403 on HTTP/1.1)
- browser User-Agent + Accept headers on feed fetch
- removed dead feeds with no active replacement: placeholder,
  dragonfly, niccarter, eugene
- pin h2==4.3.0 (required by http2=True)

All 25 remaining feeds verified fetching real body content; newest
post per feed ≤88d. Bundles in-flight KOL-module work already in the
working tree (kol_x ingest, migration 027, tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:55:16 +08:00

261 lines
10 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 json
import logging
import os
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}
# B52: persist _enabled across process restarts without a DB migration.
# The file survives `systemctl restart` but is cleared by OS reboots (which is
# acceptable — an operator who reboots the server is expected to re-arm the
# monitor). Path is configurable via env so staging vs prod can differ.
_STATE_FILE = os.environ.get(
"BREAKOUT_MONITOR_STATE_FILE",
"/tmp/trumpsignal-breakout-state.json",
)
def _load_persisted_state() -> None:
"""Read _enabled from disk on startup. Called once at module import time."""
global _enabled
try:
with open(_STATE_FILE) as f:
data = json.load(f)
_enabled = bool(data.get("enabled", False))
logger.info("Breakout monitor: loaded persisted state enabled=%s", _enabled)
except FileNotFoundError:
pass # first run — start disabled
except Exception as exc:
logger.warning("Breakout monitor: failed to load state file: %s", exc)
def _persist_state() -> None:
"""Write _enabled to disk so it survives process restarts."""
try:
with open(_STATE_FILE, "w") as f:
json.dump({"enabled": _enabled, "updated_at": datetime.now(timezone.utc).isoformat()}, f)
except Exception as exc:
logger.warning("Breakout monitor: failed to persist state: %s", exc)
# Load persisted state at import time (called when main.py registers the scheduler).
_load_persisted_state()
# ── Public API ────────────────────────────────────────────────────────────────
def set_enabled(value: bool) -> None:
global _enabled
_enabled = value
_persist_state() # B52: survive restarts
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)