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:
k
2026-05-25 00:52:56 +08:00
parent b941223c88
commit 5fb1d52026
81 changed files with 13251 additions and 158 deletions
View File
+12
View File
@@ -0,0 +1,12 @@
# Archived scanners (not in the live path)
These were generic System-2 scanners superseded by the focused
`btc_bottom_reversal` state machine. Nothing imports them; they are NOT
scheduled and do NOT register with scanner_state. Kept for reference/history.
- vcp_breakout.py — volatility-contraction breakout (bidirectional)
- weekly_rsi_reversal.py — weekly RSI extreme + recovery (bidirectional)
To revive: re-add an APScheduler job in app/main.py and a
scanner_state.register() call, and ensure the source tag is in
signal_categories.SYSTEM_2_SOURCES.
@@ -0,0 +1,251 @@
"""
VCP-style breakout scanner — example of an external signal module.
What this scans for (Minervini-light, adapted for crypto perps):
1. Volatility contraction: 7-day ATR ≤ 50% of 30-day ATR
(the price has been getting tighter — supply is drying up)
2. Breakout confirmation: current 4h close > max(prior 30-day highs)
(a real break of the range, not just touching the top)
3. Volume confirmation: last 24h volume ≥ 1.5× 30-day average
(institutional participation, not retail noise)
4. Cooldown: no signal for the same asset in the past 48h
(avoid stacking)
When all 4 conditions hit, the scanner POSTs to /api/signals/ingest. From
there the trade goes through the same convex-strategy pipeline as Trump
signals — regime filter, sizing, trailing stop, all of it.
Why this module exists:
Trump signals have 1-2 true catalysts per month. Adding a TA-based source
that finds setups continuously means the bot has SOMETHING to act on most
weeks, instead of waiting for geopolitical fireworks. The user's hypothesis
is that consolidation breakouts have asymmetric upside; the existing
trailing-stop logic is purpose-built to capture that.
Tunables (don't hardcode in caller — change them HERE if you want to test):
ATR_RECENT_DAYS, ATR_BASELINE_DAYS, ATR_RATIO_MAX,
BREAKOUT_LOOKBACK_DAYS, VOLUME_MULT_MIN, COOLDOWN_HOURS.
Asset list:
Default = SOL only. Extend ASSETS_TO_SCAN with more once you've watched
this run for a week and confirmed it doesn't fire-hose noise.
"""
from __future__ import annotations
import logging
import statistics
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from app.config import settings
from app.services.market_data import for_asset, drop_in_progress_bar
from app.services import scanner_state
logger = logging.getLogger(__name__)
SCANNER_NAME = "vcp_breakout"
scanner_state.register(SCANNER_NAME)
# ─── Tunables ───────────────────────────────────────────────────────────────
ATR_RECENT_DAYS = 7
ATR_BASELINE_DAYS = 30
ATR_RATIO_MAX = 0.5 # recent ATR must be ≤ 50% of baseline
BREAKOUT_LOOKBACK_DAYS = 30 # break of this many days' high
VOLUME_MULT_MIN = 1.5 # current 24h vol vs 30-day avg
COOLDOWN_HOURS = 48 # don't re-signal same asset within this window
# Assets to scan. Provider selection (Binance vs HL) is automatic via
# market_data.for_asset() — HL-native perps like TRUMP/HYPE route to HL
# automatically. Add to HL_NATIVE_ASSETS in market_data.py if needed.
ASSETS_TO_SCAN = [
"SOL",
# "ETH", "LINK", "AVAX", # Binance has them
# "TRUMP", "HYPE", # HL-native (routed automatically)
]
# Cooldown via scanner_state.in_cooldown — DB-backed, restart-safe.
# Data fetching is delegated to market_data.for_asset() — see that module
# for Binance vs Hyperliquid routing.
# ─── Signal logic ───────────────────────────────────────────────────────────
def _atr_proxy(candles: list[dict]) -> Optional[float]:
"""Mean of (high-low)/close — simple ATR proxy, dimensionless."""
ranges = [(c["high"] - c["low"]) / c["close"] for c in candles if c["close"] > 0]
return statistics.fmean(ranges) if ranges else None
def evaluate_breakout(candles: list[dict]) -> tuple[bool, dict]:
"""Returns (is_signal, debug_info). All 4 gates must pass.
Pure function — no side effects, fully testable.
"""
if len(candles) < ATR_BASELINE_DAYS * 6:
return False, {"reason": "insufficient_data", "bars": len(candles)}
# 1. Volatility contraction
recent_bars = candles[-ATR_RECENT_DAYS * 6:]
baseline_bars = candles[-ATR_BASELINE_DAYS * 6:]
atr_recent = _atr_proxy(recent_bars)
atr_baseline = _atr_proxy(baseline_bars)
if not atr_recent or not atr_baseline:
return False, {"reason": "atr_calc_failed"}
atr_ratio = atr_recent / atr_baseline
if atr_ratio > ATR_RATIO_MAX:
return False, {"reason": "no_contraction", "atr_ratio": round(atr_ratio, 3)}
# 2. Range break — UP through prior high (long) or DOWN through prior
# low (short). Lookback excludes the current bar.
lookback = candles[-BREAKOUT_LOOKBACK_DAYS * 6 : -1]
if not lookback:
return False, {"reason": "no_lookback"}
prior_high = max(c["high"] for c in lookback)
prior_low = min(c["low"] for c in lookback)
current_close = candles[-1]["close"]
if current_close > prior_high:
direction, ref, gap = "buy", prior_high, (current_close - prior_high) / prior_high * 100
elif current_close < prior_low:
direction, ref, gap = "short", prior_low, (prior_low - current_close) / prior_low * 100
else:
return False, {"reason": "no_range_break", "close": current_close,
"prior_high": prior_high, "prior_low": prior_low}
# 3. Volume confirmation (same for both directions — a real break needs
# participation regardless of which way it goes)
recent_vol_24h = sum(c["volume"] for c in candles[-6:])
baseline_avg_24h = sum(c["volume"] for c in candles[-180:]) / 30
if baseline_avg_24h <= 0:
return False, {"reason": "no_volume_data"}
vol_ratio = recent_vol_24h / baseline_avg_24h
if vol_ratio < VOLUME_MULT_MIN:
return False, {"reason": "weak_volume", "vol_ratio": round(vol_ratio, 2)}
return True, {
"direction": direction,
"atr_ratio": round(atr_ratio, 3),
"vol_ratio": round(vol_ratio, 2),
"break_ref": round(ref, 4),
"break_at": round(current_close, 4),
"headroom_pct": round(gap, 2),
}
def _confidence_from(debug: dict) -> int:
"""Map the signal's quality into a 0-100 confidence used by sizing.
The tighter the contraction and the bigger the volume spike, the higher
the confidence. Capped at 95 — leave headroom so AI signals at 100 stay
distinguishable.
"""
score = 70 # baseline for "all 4 gates passed"
# Tighter contraction → higher confidence
if debug.get("atr_ratio", 1) < 0.35: score += 10
elif debug.get("atr_ratio", 1) < 0.45: score += 5
# Bigger volume spike → higher confidence
if debug.get("vol_ratio", 0) >= 3.0: score += 10
elif debug.get("vol_ratio", 0) >= 2.0: score += 5
return min(score, 95)
# ─── Ingest call ────────────────────────────────────────────────────────────
async def _emit_signal(asset: str, debug: dict) -> None:
"""POST the signal to /api/signals/ingest. Treats the local server as the
target — same machine, no network hop in dev.
"""
if not settings.ingest_api_key:
logger.warning("VCP: signal would fire on %s but INGEST_API_KEY is empty", asset)
return
confidence = _confidence_from(debug)
direction = debug["direction"]
way = "breakout ↑" if direction == "buy" else "breakdown ↓"
rel = "above" if direction == "buy" else "below"
payload = {
"source": "breakout",
"external_id": f"vcp-{asset}-{direction}-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}",
"text": (
f"VCP {way} on {asset}: ATR ratio {debug['atr_ratio']} "
f"(7d/30d), {debug['vol_ratio']}× normal volume, "
f"close ${debug['break_at']} {rel} {BREAKOUT_LOOKBACK_DAYS}-day "
f"level ${debug['break_ref']} ({debug['headroom_pct']}%)"
),
"signal": direction,
"target_asset": asset,
"confidence": confidence,
"category": "vcp_breakout",
"expected_move_pct": 5.0, # historical VCP median expansion
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
"http://localhost:8000/api/signals/ingest",
json=payload,
headers={"X-Ingest-Key": settings.ingest_api_key},
)
if resp.status_code >= 400:
logger.error("VCP ingest failed (%d): %s", resp.status_code, resp.text[:200])
else:
logger.info("VCP signal emitted for %s, confidence=%d: %s",
asset, confidence, resp.json())
# ─── Scheduler entry point ──────────────────────────────────────────────────
async def scan_once() -> None:
"""Single scan pass over ASSETS_TO_SCAN. Called every 30 minutes.
Kill-switch + DB-backed cooldown. Drops in-progress 4h bar before
evaluating — without that, the breakout test ran against a partial bar
and could flicker FIRE / no-FIRE within a 4h window.
"""
if not scanner_state.is_enabled(SCANNER_NAME):
logger.debug("VCP scanner disabled — skipping run")
return
fired_any = False
error_msg: Optional[str] = None
for asset in ASSETS_TO_SCAN:
# Cooldown — DB-backed, restart-safe.
cooldown_days = COOLDOWN_HOURS / 24
if await scanner_state.in_cooldown("breakout", asset, cooldown_days):
continue
provider = for_asset(asset)
try:
candles = await provider.fetch_4h(asset, days=ATR_BASELINE_DAYS + 1)
except Exception as exc:
error_msg = f"{asset}: {exc}"
logger.error("VCP fetch failed for %s via %s: %s", asset, provider.name, exc)
continue
candles = drop_in_progress_bar(candles, "4h")
if not candles:
logger.warning("VCP: %s returned 0 candles from %s — symbol may not exist",
asset, provider.name)
continue
is_signal, debug = evaluate_breakout(candles)
logger.info("VCP scan %s [%s]: %s%s", asset, provider.name,
"FIRE" if is_signal else "no", debug)
if is_signal:
await _emit_signal(asset, debug)
fired_any = True
if error_msg:
scanner_state.record_run(SCANNER_NAME, "error", error_msg)
elif fired_any:
scanner_state.record_run(SCANNER_NAME, "fired")
else:
scanner_state.record_run(SCANNER_NAME, "ok")
@@ -0,0 +1,264 @@
"""
Weekly RSI extreme + recovery scanner.
What it catches:
Severe weekly oversold conditions that flush capitulating holders, followed
by the first sign of strength. Historical examples this would have hit:
BTC 2022-11 ($15.5k bottom)
ETH 2022-06 ($900 bottom)
SOL 2022-12 ($8 bottom)
AAVE 2022-06 ($45 bottom)
Trigger logic (intentionally strict):
PRE-CONDITION: ≥ 4 consecutive completed weekly bars with RSI(14) < 30
(this is the capitulation phase — sustained extreme weakness)
TRIGGER: current completed week's RSI(14) >= 35
(i.e. the FIRST recovery week — RSI has lifted off the floor)
COOLDOWN: 60 days — these events happen ~once per year per asset.
No re-firing on the same setup.
Companion exit parameters (passed to the bot via signal payload — wider stops
to match the longer holding period and higher single-trade conviction):
SL = 8%
TRAILING_ACTIVATE = 15%
TRAILING_STOP = 6%
MAX_HOLD = 60 days
This is a high-conviction, low-frequency signal. Expect 0-3 fires per asset
per year. Don't tune it for higher frequency — that defeats the point.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from app.config import settings
from app.services.market_data import REVERSAL_BASKET, for_asset, drop_in_progress_bar
from app.services import scanner_state
SCANNER_NAME = "weekly_rsi_reversal"
scanner_state.register(SCANNER_NAME)
logger = logging.getLogger(__name__)
# ─── Tunables ───────────────────────────────────────────────────────────────
RSI_PERIOD = 14
RSI_OVERSOLD = 30 # "deeply oversold" floor (long setup)
RSI_RECOVERY_TRIGGER = 35 # bounce-off line for the long
RSI_OVERBOUGHT = 70 # "euphoric" ceiling (short setup)
RSI_ROLLOVER_TRIGGER = 65 # roll-off line for the short
WEEKS_OF_OVERSOLD = 4 # consecutive extreme weeks required (both sides)
WEEKS_TO_FETCH = 40 # enough history for stable Wilder's RSI
COOLDOWN_DAYS = 60
# Exit profile passed to /signals/ingest. Caller can override on the bot side.
PAYLOAD_CONFIDENCE = 88 # high conviction — top of the regime-filter score
PAYLOAD_EXPECTED_MOVE = 30.0 # historical median move after capitulation
# Cooldown is now DB-backed via scanner_state.in_cooldown() — survives
# restart. No more in-memory _last_signal_at dict.
# ─── RSI math (Wilder's smoothed) ───────────────────────────────────────────
def calculate_rsi(closes: list[float], period: int = 14) -> list[Optional[float]]:
"""Wilder's RSI. Returns a list aligned with `closes` where the first
`period` entries are None (not enough history yet).
Implementation note: the first valid RSI uses a SIMPLE mean of the first
`period` gains/losses, subsequent values use exponential smoothing with
alpha = 1/period. This matches TradingView, Binance UI, and most charting
tools — the "Cutler's RSI" (pure SMA) variant would give different results.
"""
n = len(closes)
rsi: list[Optional[float]] = [None] * n
if n < period + 1:
return rsi
gains = [max(closes[i] - closes[i - 1], 0) for i in range(1, n)]
losses = [max(closes[i - 1] - closes[i], 0) for i in range(1, n)]
# First valid RSI = simple mean over first `period` deltas
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
if avg_loss == 0:
rsi[period] = 100.0
else:
rs = avg_gain / avg_loss
rsi[period] = 100 - 100 / (1 + rs)
# Subsequent values use Wilder's smoothing
for i in range(period + 1, n):
avg_gain = (avg_gain * (period - 1) + gains[i - 1]) / period
avg_loss = (avg_loss * (period - 1) + losses[i - 1]) / period
if avg_loss == 0:
rsi[i] = 100.0
else:
rs = avg_gain / avg_loss
rsi[i] = 100 - 100 / (1 + rs)
return rsi
# ─── Signal logic ───────────────────────────────────────────────────────────
def evaluate_rsi_reversal(weekly_candles: list[dict]) -> tuple[bool, dict]:
"""Pure function — no side effects, fully testable.
Returns (is_signal, debug_info).
"""
if len(weekly_candles) < RSI_PERIOD + WEEKS_OF_OVERSOLD + 2:
return False, {"reason": "insufficient_data", "bars": len(weekly_candles)}
closes = [c["close"] for c in weekly_candles]
rsi = calculate_rsi(closes, RSI_PERIOD)
# The MOST RECENT weekly bar is `closes[-1]`. We treat it as the "current"
# recovery candidate. Look BACKWARDS for WEEKS_OF_OVERSOLD prior bars all
# with RSI < RSI_OVERSOLD.
current_rsi = rsi[-1]
if current_rsi is None:
return False, {"reason": "rsi_not_computable"}
window = rsi[-(WEEKS_OF_OVERSOLD + 1):-1] # the N weeks before current
if any(r is None for r in window):
return False, {"reason": "history_gaps_in_window"}
this_close = weekly_candles[-1]["close"]
prev_close = weekly_candles[-2]["close"]
# ── LONG: capitulation bottom ──────────────────────────────────────────
# Sustained RSI<30, now lifting ≥35, price turning up.
if (current_rsi >= RSI_RECOVERY_TRIGGER
and all(r < RSI_OVERSOLD for r in window)
and this_close > prev_close):
low_w = min(c["low"] for c in weekly_candles[-(WEEKS_OF_OVERSOLD + 1):])
return True, {
"direction": "buy",
"current_rsi": round(current_rsi, 1),
"window_rsi": [round(r, 1) for r in window],
"move_pct": round((this_close - low_w) / low_w * 100, 2) if low_w > 0 else 0.0,
"trigger_close": round(this_close, 4),
}
# ── SHORT: euphoric top ────────────────────────────────────────────────
# Sustained RSI>70, now rolling ≤65, price turning down. Symmetric mirror.
if (current_rsi <= RSI_ROLLOVER_TRIGGER
and all(r > RSI_OVERBOUGHT for r in window)
and this_close < prev_close):
high_w = max(c["high"] for c in weekly_candles[-(WEEKS_OF_OVERSOLD + 1):])
return True, {
"direction": "short",
"current_rsi": round(current_rsi, 1),
"window_rsi": [round(r, 1) for r in window],
"move_pct": round((high_w - this_close) / high_w * 100, 2) if high_w > 0 else 0.0,
"trigger_close": round(this_close, 4),
}
# Neither side qualified — report the closest miss for the log.
return False, {
"reason": "no_setup",
"current_rsi": round(current_rsi, 1),
"window_rsi": [round(r, 1) for r in window],
}
# ─── Ingest emission ────────────────────────────────────────────────────────
async def _emit_signal(asset: str, debug: dict) -> None:
if not settings.ingest_api_key:
logger.warning("RSI-reversal would fire on %s but INGEST_API_KEY empty", asset)
return
direction = debug["direction"]
side_word = "capitulation bottom" if direction == "buy" else "euphoric top"
payload = {
"source": "rsi_reversal",
"external_id": f"rsi-{asset}-{direction}-{datetime.now(timezone.utc).strftime('%Y%W')}",
"text": (
f"Weekly RSI {side_word} on {asset}: RSI {debug['current_rsi']} after "
f"{WEEKS_OF_OVERSOLD}wk extreme (window: {debug['window_rsi']}). "
f"{debug['move_pct']}% move off the extreme @ ${debug['trigger_close']}."
),
"signal": direction,
"target_asset": asset,
"confidence": PAYLOAD_CONFIDENCE,
"category": "rsi_extreme_reversal",
"expected_move_pct": PAYLOAD_EXPECTED_MOVE,
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
"http://localhost:8000/api/signals/ingest",
json=payload,
headers={"X-Ingest-Key": settings.ingest_api_key},
)
if resp.status_code >= 400:
logger.error("RSI-reversal ingest failed (%d): %s", resp.status_code, resp.text[:200])
else:
logger.info("RSI-reversal signal emitted for %s: %s", asset, resp.json())
# ─── Scheduler entry point ──────────────────────────────────────────────────
async def scan_once() -> None:
"""One scan pass over REVERSAL_BASKET. Called by APScheduler.
Kill-switch aware: short-circuits if the operator disabled this scanner.
Cooldown is DB-backed (queries posts table), so restarts don't reset
state.
"""
if not scanner_state.is_enabled(SCANNER_NAME):
logger.debug("RSI-reversal scanner disabled — skipping run")
return
fired_any = False
error_msg: Optional[str] = None
for asset in REVERSAL_BASKET:
# Cooldown — DB-backed.
if await scanner_state.in_cooldown("rsi_reversal", asset, COOLDOWN_DAYS):
continue
provider = for_asset(asset)
try:
candles = await provider.fetch_1w(asset, weeks=WEEKS_TO_FETCH)
except Exception as exc:
error_msg = f"{asset}: {exc}"
logger.error("RSI-reversal fetch failed for %s via %s: %s",
asset, provider.name, exc)
continue
candles = drop_in_progress_bar(candles, "1w")
if not candles:
logger.warning("RSI-reversal: %s returned 0 weekly bars from %s",
asset, provider.name)
continue
is_signal, debug = evaluate_rsi_reversal(candles)
if is_signal:
logger.info("RSI-reversal scan %s [%s]: FIRE — %s", asset, provider.name, debug)
await _emit_signal(asset, debug)
fired_any = True
else:
logger.debug("RSI-reversal scan %s [%s]: no — %s", asset, provider.name, debug)
if error_msg:
scanner_state.record_run(SCANNER_NAME, "error", error_msg)
elif fired_any:
scanner_state.record_run(SCANNER_NAME, "fired")
else:
scanner_state.record_run(SCANNER_NAME, "ok")
@@ -0,0 +1,148 @@
"""BTC bottom-reversal long scanner — 2-of-3 price confluence.
Simple, transparent, no on-chain data and no API keys. Fires only when at
least TWO of these three classic bottom signals agree:
A. AHR999 < 0.45 — deep-value / bottom zone
B. price ≤ 200-week MA ×1.05 — every cycle has bottomed near the 200WMA
C. Pi Cycle Bottom — 150d EMA ≤ 471d SMA × 0.745
Long only, low frequency, stateless. No tight stop (real macro bottoms wick
1530%); the position is invalidated when AHR999 climbs back above 1.2 — i.e.
BTC is no longer cheap and the bottom thesis is spent.
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
import httpx
from app.config import settings
from app.services import scanner_state
from app.services.market_data import for_asset, drop_in_progress_bar
from app.services.bottom_indicators import bottom_confluence
SCANNER_NAME = "btc_bottom_reversal"
scanner_state.register(SCANNER_NAME)
logger = logging.getLogger(__name__)
ASSET = "BTC"
COOLDOWN_DAYS = 30
# 3 votes → max conviction; 2 votes → solid. Scale confidence with agreement.
CONFIDENCE_BY_VOTES = {2: 88, 3: 94}
EXPECTED_MOVE_PCT = 25.0
# Pi Cycle needs 471 daily closes; +buffer for the dropped in-progress bar.
DAILY_LOOKBACK_DAYS = 520
# 200-week MA needs 200 weekly closes; +buffer.
WEEKLY_LOOKBACK_WEEKS = 210
async def evaluate_once() -> tuple[bool, dict]:
provider = for_asset(ASSET)
raw_daily = await provider.fetch_1d(ASSET, days=DAILY_LOOKBACK_DAYS)
daily = drop_in_progress_bar(raw_daily, "1d")
raw_weekly = await provider.fetch_1w(ASSET, weeks=WEEKLY_LOOKBACK_WEEKS)
weekly = drop_in_progress_bar(raw_weekly, "1w")
if not daily:
return False, {"reason": "missing_btc_daily"}
if not weekly:
return False, {"reason": "missing_btc_weekly"}
daily_closes = [float(c["close"]) for c in daily if c.get("close")]
weekly_closes = [float(c["close"]) for c in weekly if c.get("close")]
conf = bottom_confluence(daily_closes, weekly_closes)
debug: dict = dict(conf.detail)
if not conf.fired:
debug["reason"] = "confluence_not_met"
return False, debug
confidence = CONFIDENCE_BY_VOTES.get(conf.votes, 88)
debug["confidence"] = confidence
# No tight stop. Thesis is invalidated when BTC is no longer cheap; the
# 200WMA band is a useful structural reference for the exit monitor.
debug["invalidation_price"] = debug.get("wma200")
return True, debug
def _summary(debug: dict) -> str:
s = debug.get("signals", {})
on = [k for k, v in s.items() if v]
return (
f"BTC bottom confluence ({debug.get('votes')}/3): {', '.join(on)}. "
f"AHR999 {debug.get('ahr999')} (<{debug.get('ahr999_threshold')}), "
f"price {debug.get('price')}, 200WMA {debug.get('wma200')}, "
f"Pi 150EMA {debug.get('pi_ema150')} vs 471SMA×0.745 "
f"{debug.get('pi_sma471_scaled')}. No tight stop; "
f"invalidate if AHR999 > 1.2."
)
async def _emit_signal(debug: dict) -> bool:
"""POST the signal to the ingest endpoint. Returns True iff a Post was
(idempotently) created. False means the signal was NOT recorded — caller
must NOT treat the run as 'fired' (cooldown is keyed off the DB Post)."""
if not settings.ingest_api_key:
logger.error("BTC bottom-reversal would fire but INGEST_API_KEY empty "
"— signal NOT recorded, check deploy env")
return False
payload = {
"source": "btc_bottom_reversal",
"external_id": f"btc-bottom-{datetime.now(timezone.utc).strftime('%Y%m%d')}",
"text": _summary(debug),
"signal": "buy",
"target_asset": ASSET,
"confidence": debug["confidence"],
"category": "btc_bottom_reversal_long",
"expected_move_pct": EXPECTED_MOVE_PCT,
"invalidation_price": debug.get("invalidation_price"),
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
"http://localhost:8000/api/signals/ingest",
json=payload,
headers={"X-Ingest-Key": settings.ingest_api_key},
)
if resp.status_code >= 400:
logger.error("BTC bottom-reversal ingest failed (%d): %s",
resp.status_code, resp.text[:200])
return False
logger.info("BTC bottom-reversal signal emitted: %s", resp.json())
return True
async def scan_once() -> None:
if not scanner_state.is_enabled(SCANNER_NAME):
logger.debug("BTC bottom-reversal scanner disabled — skipping run")
return
if await scanner_state.in_cooldown("btc_bottom_reversal", ASSET, COOLDOWN_DAYS):
logger.debug("BTC bottom-reversal cooldown active")
scanner_state.record_run(SCANNER_NAME, "ok", "cooldown")
return
try:
should_fire, debug = await evaluate_once()
except Exception as exc:
logger.error("BTC bottom-reversal scan failed: %s", exc)
scanner_state.record_run(SCANNER_NAME, "error", str(exc))
return
if should_fire:
logger.info("BTC bottom-reversal FIRE — %s", debug)
emitted = await _emit_signal(debug)
if emitted:
scanner_state.record_run(SCANNER_NAME, "fired")
else:
# Not recorded → no DB Post → cooldown won't arm. Surface this as
# an error so a misconfigured deploy is obvious, not a silent
# daily "fired" that never actually trades.
scanner_state.record_run(SCANNER_NAME, "error", "ingest_failed_or_key_missing")
else:
logger.info("BTC bottom-reversal no — %s", debug)
scanner_state.record_run(SCANNER_NAME, "ok")
+379
View File
@@ -0,0 +1,379 @@
"""
Funding rate extreme + reversal scanner.
What it catches:
Crowded perp positioning that's about to unwind. When one side has been
paying ridiculous funding for weeks, the eventual unwind ("the squeeze")
is the cleanest single-day move you'll find. Examples:
BTC 2024-08 funding deeply +ve for 30d → 14% flush down within a week
SOL 2023-09 funding deeply -ve for 30d → 60% rally over the next month
ETH 2024-Q4 funding +3.5% on 30d → 12% pullback
Trigger logic:
PRE-CONDITION: Sum of last 30 days of funding rate (per 8h cycle)
exceeds ±FUNDING_EXTREME_PCT. The sign tells us which
side is overcrowded:
sum > +3% → longs have been paying — bearish setup
(signal = short)
sum < -3% → shorts paying — bullish setup
(signal = buy)
TRIGGER: Funding direction has STARTED to mean-revert
(last 3 funding cycles closer to zero than the 30d avg)
AND price has begun moving in the contrarian direction
(last 7 days price move at least 3% in that direction).
COOLDOWN: 14 days. Funding extremes can persist for weeks but
each unwind is a distinct event.
Companion exits — tighter than RSI/SMA because funding moves play out faster
(days, not weeks):
SL = 4%
TRAILING_ACTIVATE = 10%
TRAILING_STOP = 5%
MAX_HOLD = 30 days
This is the highest-frequency of the three reversal signals — expect 3-5
fires per asset per year — and the most "alpha-like" (most market participants
ignore funding entirely).
"""
from __future__ import annotations
import logging
import statistics
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from app.config import settings
from app.services import scanner_state
from app.services.market_data import REVERSAL_BASKET, for_asset, drop_in_progress_bar
# Promoted from booster → standalone scanner. Still importable by
# btc_bottom_reversal for confluence boost; ALSO emits its own signals via
# scan_once() registered with the APScheduler in app/main.py.
SCANNER_NAME = "funding_reversal"
scanner_state.register(SCANNER_NAME)
ASSET = "BTC" # BTC-only for now; extend to ETH/SOL after results validate
# How much funding+price history to load each tick.
# - Binance: 8h cadence → 30d ≈ 90 cycles
# - HL: 1h cadence → capped at 500 rows ≈ 20.8d (handled inside evaluate)
FUNDING_DAYS_LOOKBACK = 30
PRICE_DAYS_LOOKBACK = 35 # need 7d confirm + buffer for in-progress bar
logger = logging.getLogger(__name__)
# ─── Tunables ───────────────────────────────────────────────────────────────
FUNDING_HISTORY_DAYS = 30
FUNDING_EXTREME_THRESHOLD = 0.03 # 3% cumulative — extreme one-sided pressure
# CRITICAL: "recent" is in HOURS, not CYCLES. Binance funding is 8h-cadence
# (3 cycles/day) but HL is 1h-cadence (24 cycles/day) — a fixed "3 cycles
# lookback" means 24h on Binance but only 3h on HL. We pick cycles dynamically
# based on the response's actual cadence so 24h of funding history is always
# the comparison window.
FUNDING_REVERSAL_LOOKBACK_HOURS = 24
FUNDING_REVERSAL_RATIO = 0.5 # recent avg must be ≤ 50% of 30d-avg in magnitude
PRICE_CONFIRM_DAYS = 7
PRICE_CONFIRM_PCT = 3.0 # price moved 3%+ in contrarian direction
COOLDOWN_DAYS = 14
PAYLOAD_CONFIDENCE = 82 # slightly lower than RSI/SMA — funding can chop
PAYLOAD_EXPECTED_MOVE = 10.0
EMIT_STANDALONE_SIGNALS = True # promoted from booster → standalone signal source
# Cooldown via scanner_state.in_cooldown — DB-backed, restart-safe.
# ─── Signal logic ───────────────────────────────────────────────────────────
def _detect_cadence_hours(funding: list[dict]) -> float:
"""Infer the funding cadence (hours between cycles) from the response.
Binance returns ~8h intervals; HL returns ~1h. We can't assume — the
response itself is the source of truth. Average over the most recent 10
intervals to smooth out occasional gaps.
"""
if len(funding) < 2:
return 8.0 # safe Binance default
sample = funding[-min(11, len(funding)):]
diffs = [sample[i]["time_ms"] - sample[i - 1]["time_ms"] for i in range(1, len(sample))]
if not diffs:
return 8.0
return statistics.fmean(diffs) / 3_600_000.0 # ms → hours
MIN_COVERAGE_DAYS = 14 # below this, the signal is statistically too noisy
def evaluate_funding_reversal(
funding_history: list[dict],
daily_candles: list[dict],
) -> tuple[bool, dict]:
"""Pure function. Returns (is_signal, debug).
Debug contains `direction` key on real signals — 'buy' or 'short'.
Cross-venue safety: HL caps fundingHistory at 500 rows (~20.8 days for
1h cadence), Binance can give a full 30 days. We compute the actual
coverage span from the data and scale the cumulative-funding threshold
proportionally — so 3% over 30 days and 2.08% over 20.8 days are
treated as equivalent in daily-average terms.
"""
if not funding_history or len(funding_history) < 2:
return False, {"reason": "insufficient_funding_history",
"cycles": len(funding_history)}
cadence_h = _detect_cadence_hours(funding_history)
# Actual time span the response covers — bounded by HL's 500-row cap
# for HL, or by what we asked for on Binance.
span_ms = funding_history[-1]["time_ms"] - funding_history[0]["time_ms"]
actual_days = span_ms / 86_400_000
if actual_days < MIN_COVERAGE_DAYS:
return False, {"reason": "insufficient_coverage_days",
"actual_days": round(actual_days, 1),
"min_required": MIN_COVERAGE_DAYS}
rates = [f["rate"] for f in funding_history]
cumulative_funding = sum(rates)
avg_full_window = statistics.fmean(rates)
# Scale the extreme threshold by ACTUAL coverage so cross-venue results
# are comparable. Binance: 30d → threshold ×1. HL: 20.8d → threshold ×0.69.
scaled_threshold = FUNDING_EXTREME_THRESHOLD * (actual_days / FUNDING_HISTORY_DAYS)
# "Recent" lookback in TIME units, not cycles. 24h of cycles regardless
# of venue. Min 3 to avoid pathological cases (very short history).
recent_cycles = max(3, int(FUNDING_REVERSAL_LOOKBACK_HOURS / cadence_h))
recent_cycles = min(recent_cycles, len(rates)) # don't over-slice
avg_recent_cycle = statistics.fmean(rates[-recent_cycles:])
# 2. Identify direction (which side is overcrowded)
if cumulative_funding > scaled_threshold:
# Longs have been paying → expect SHORT squeeze
direction = "short"
elif cumulative_funding < -scaled_threshold:
# Shorts have been paying → expect LONG rally
direction = "buy"
else:
return False, {
"reason": "no_extreme",
"cumulative_funding_pct": round(cumulative_funding * 100, 3),
"scaled_threshold_pct": round(scaled_threshold * 100, 3),
"coverage_days": round(actual_days, 1),
}
# 3. Funding must be MEAN-REVERTING (recent cycles softer than 30d avg)
# Use absolute magnitude — what matters is "the pressure is easing",
# not the direction of zero-crossing.
if abs(avg_full_window) == 0:
return False, {"reason": "degenerate_avg"}
revert_ratio = abs(avg_recent_cycle) / abs(avg_full_window)
if revert_ratio > FUNDING_REVERSAL_RATIO:
return False, {
"reason": "funding_still_extreme",
"revert_ratio": round(revert_ratio, 2),
"needed_below": FUNDING_REVERSAL_RATIO,
"direction": direction,
}
# 4. Price confirms the contrarian move (last 7 days)
if not daily_candles or len(daily_candles) < PRICE_CONFIRM_DAYS + 1:
return False, {"reason": "insufficient_price_history",
"have": len(daily_candles), "need": PRICE_CONFIRM_DAYS + 1}
first_close = daily_candles[-(PRICE_CONFIRM_DAYS + 1)]["close"]
last_close = daily_candles[-1]["close"]
if first_close == 0:
return False, {"reason": "bad_first_close"}
pct_change = (last_close - first_close) / first_close * 100
if direction == "buy" and pct_change < PRICE_CONFIRM_PCT:
return False, {"reason": "price_not_yet_recovering",
"pct_7d": round(pct_change, 2), "direction": direction}
if direction == "short" and pct_change > -PRICE_CONFIRM_PCT:
return False, {"reason": "price_not_yet_falling",
"pct_7d": round(pct_change, 2), "direction": direction}
boost = {
"direction": direction,
"cum_funding_30d_pct": round(cumulative_funding * 100, 3),
"recent_avg_cycle_pct": round(avg_recent_cycle * 100, 4),
"revert_ratio": round(revert_ratio, 2),
"price_7d_pct": round(pct_change, 2),
"trigger_close": round(last_close, 4),
}
if not EMIT_STANDALONE_SIGNALS:
return False, {"reason": "boost_only", "boost": boost}
return True, boost
# ─── Standalone scanner plumbing ────────────────────────────────────────────
async def _fetch_inputs() -> tuple[list[dict], list[dict]]:
"""Pull funding history + daily candles for ASSET.
We bypass provider.fetch_1d in favor of fapi.binance.com/fapi/v1/klines
because (a) funding lives on the futures venue anyway — same liquidity
pool, same trade flow — and (b) the spot api.binance.com host is
occasionally geo-blocked, while fapi is more reliably reachable.
"""
provider = for_asset(ASSET)
funding = await provider.fetch_funding(ASSET, days=FUNDING_DAYS_LOOKBACK)
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - PRICE_DAYS_LOOKBACK * 24 * 3600 * 1000
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(
"https://fapi.binance.com/fapi/v1/klines",
params={"symbol": f"{ASSET}USDT", "interval": "1d",
"startTime": start_ms, "endTime": end_ms, "limit": 1000},
)
resp.raise_for_status()
raw_daily = [
{"time_ms": r[0], "open": float(r[1]), "high": float(r[2]),
"low": float(r[3]), "close": float(r[4]), "volume": float(r[5])}
for r in resp.json()
]
daily = drop_in_progress_bar(raw_daily, "1d")
return funding, daily
def _summary(debug: dict) -> str:
direction = debug.get("direction", "?").upper()
cum = debug.get("cum_funding_30d_pct")
recent = debug.get("recent_avg_cycle_pct")
revert = debug.get("revert_ratio")
p7d = debug.get("price_7d_pct")
return (
f"BTC funding extreme reversal — {direction}. "
f"30d cumulative funding {cum}% (crowded {'longs' if direction == 'SHORT' else 'shorts'}); "
f"last-24h cycle avg {recent}% (revert ratio {revert}); "
f"price 7d {p7d:+.2f}% confirms the unwind."
)
async def _emit_signal(debug: dict) -> bool:
"""POST to the ingest endpoint. Idempotent via external_id (per-day)."""
if not settings.ingest_api_key:
logger.error("Funding reversal would fire but INGEST_API_KEY empty — not recorded")
return False
direction = debug["direction"] # 'buy' or 'short'
expected_move = PAYLOAD_EXPECTED_MOVE if direction == "buy" else -PAYLOAD_EXPECTED_MOVE
payload = {
"source": "funding_reversal",
"external_id": f"funding-{ASSET}-{direction}-{datetime.now(timezone.utc).strftime('%Y%m%d')}",
"text": _summary(debug),
"signal": direction,
"target_asset": ASSET,
"confidence": PAYLOAD_CONFIDENCE,
"category": f"funding_reversal_{direction}",
"expected_move_pct": expected_move,
"invalidation_price": debug.get("trigger_close"),
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
"http://localhost:8000/api/signals/ingest",
json=payload,
headers={"X-Ingest-Key": settings.ingest_api_key},
)
if resp.status_code >= 400:
logger.error("Funding reversal ingest failed (%d): %s",
resp.status_code, resp.text[:200])
return False
logger.info("Funding reversal signal emitted: %s", resp.json())
return True
async def scan_once() -> None:
"""Hourly tick. Idempotent (cooldown-gated, external_id-dedupe at ingest)."""
if not scanner_state.is_enabled(SCANNER_NAME):
logger.debug("Funding reversal scanner disabled — skipping")
return
if await scanner_state.in_cooldown("funding_reversal", ASSET, COOLDOWN_DAYS):
logger.debug("Funding reversal cooldown active (%dd)", COOLDOWN_DAYS)
scanner_state.record_run(SCANNER_NAME, "ok", "cooldown")
return
try:
funding, daily = await _fetch_inputs()
fired, debug = evaluate_funding_reversal(funding, daily)
except Exception as exc:
# Always include exception type — httpx errors often have empty .args
# which formatted as just "Funding reversal scan failed:" before.
logger.exception("Funding reversal scan failed: %s (%s)",
type(exc).__name__, exc)
scanner_state.record_run(SCANNER_NAME, "error",
f"{type(exc).__name__}: {exc}"[:200])
return
if fired:
logger.info("Funding reversal FIRE — %s", debug)
emitted = await _emit_signal(debug)
scanner_state.record_run(SCANNER_NAME, "fired" if emitted else "error",
None if emitted else "ingest_failed")
else:
logger.info("Funding reversal no — %s", debug.get("reason"))
scanner_state.record_run(SCANNER_NAME, "ok", debug.get("reason"))
# ─── Read API helper — current snapshot for the BTC page tab ────────────────
async def get_current_snapshot() -> dict:
"""Live read for the frontend BTC page funding tab. Returns the latest
funding rate, the 24h running average, cumulative 30d sum, and the verdict
of evaluate_funding_reversal() against current data. Cheap to call — only
network cost is two market_data fetches the scanner would do anyway."""
try:
funding, daily = await _fetch_inputs()
except Exception as exc:
logger.exception("funding snapshot fetch failed")
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
if not funding:
return {"ok": False, "error": "no_funding_data"}
cadence_h = _detect_cadence_hours(funding)
rates = [f["rate"] for f in funding]
cum_30d_pct = sum(rates) * 100
span_days = (funding[-1]["time_ms"] - funding[0]["time_ms"]) / 86_400_000
latest = rates[-1] * 100
# Last-24h equivalent average per-cycle rate (in %)
recent_n = max(3, int(24 / cadence_h)) if cadence_h > 0 else 24
recent_n = min(recent_n, len(rates))
last_24h_avg = (sum(rates[-recent_n:]) / recent_n) * 100
fired, debug = evaluate_funding_reversal(funding, daily)
# 7-day funding history for the sparkline (truncate to keep payload small)
history = [
{"t": int(f["time_ms"]), "rate_pct": round(f["rate"] * 100, 5)}
for f in funding[-int(min(len(funding), 24 * 7 / max(cadence_h, 0.5))) :]
]
return {
"ok": True,
"asset": ASSET,
"cadence_hours": round(cadence_h, 2),
"coverage_days": round(span_days, 1),
"latest_rate_pct": round(latest, 5),
"last_24h_avg_pct": round(last_24h_avg, 5),
"cum_30d_pct": round(cum_30d_pct, 3),
"extreme_threshold_pct": round(FUNDING_EXTREME_THRESHOLD * 100, 3),
"signal_fired": fired,
"debug": debug,
"history": history,
}
+146
View File
@@ -0,0 +1,146 @@
"""
200-day SMA Reclaim scanner.
What it catches:
The moment a price that has been LIVING BELOW its 200-day moving average
for a sustained period climbs back ABOVE it on real volume. Historically
one of the most reliable "trend has changed" markers in any market —
hedge fund books, retail TA tools, momentum quants, everyone watches it.
Examples this would have caught:
BTC 2023-01 (~$22k, after the FTX flush)
BTC 2024-09 (after Q3 chop)
ETH 2023-01 (~$1500)
SOL 2023-02 (~$24, after FTX)
Trigger logic:
PRE-CONDITION: For the past DAYS_BELOW_REQUIRED days, daily close has been
BELOW the rolling 200-day SMA. (proves we're reversing a
sustained downtrend, not crossing a flat MA in chop)
TRIGGER: Today's close > 200-day SMA, AND
Today's volume > 1.3 × 30-day avg volume.
COOLDOWN: 30 days — false reclaims and shake-outs happen, don't
re-fire on noise.
Companion exit profile:
SL = 6%
TRAILING_ACTIVATE = 12%
TRAILING_STOP = 5%
MAX_HOLD = 90 days
The 90-day max-hold matches the holding period needed for a real trend
change to play out (~3 months is the historical median for a confirmed
200d-SMA-reclaim trend run).
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
import httpx
from app.config import settings
from app.services.market_data import REVERSAL_BASKET, for_asset, drop_in_progress_bar
# LIBRARY MODULE — NOT a standalone scanner. evaluate_sma_reclaim() is
# imported by btc_bottom_reversal.py as the price-reclaim entry gate. It
# deliberately does NOT register with scanner_state: no UI toggle, no
# schedule. (Old standalone scan_once/_emit_signal removed — see git log.)
logger = logging.getLogger(__name__)
# ─── Tunables ───────────────────────────────────────────────────────────────
SMA_PERIOD = 200
DAYS_BELOW_REQUIRED = 30 # how long the asset must have been under SMA
VOLUME_LOOKBACK_DAYS = 30
VOLUME_MULT_MIN = 1.3
DAYS_TO_FETCH = 260 # SMA(200) + 30d-below check + safety margin
COOLDOWN_DAYS = 30
PAYLOAD_CONFIDENCE = 85
PAYLOAD_EXPECTED_MOVE = 20.0 # historical median 90-day run after reclaim
# Cooldown via scanner_state.in_cooldown — DB-backed, restart-safe.
# ─── Signal logic ───────────────────────────────────────────────────────────
def evaluate_sma_reclaim(daily_candles: list[dict]) -> tuple[bool, dict]:
"""Pure function. Returns (is_signal, debug).
Expects `daily_candles` ordered chronologically (oldest first), each
having keys close, volume.
"""
if len(daily_candles) < SMA_PERIOD + DAYS_BELOW_REQUIRED + 2:
return False, {"reason": "insufficient_data", "bars": len(daily_candles)}
closes = [c["close"] for c in daily_candles]
volumes = [c["volume"] for c in daily_candles]
# Rolling 200-day SMA at each bar from index SMA_PERIOD-1 onwards
smas: list[Optional[float]] = [None] * len(closes)
running_sum = sum(closes[:SMA_PERIOD])
smas[SMA_PERIOD - 1] = running_sum / SMA_PERIOD
for i in range(SMA_PERIOD, len(closes)):
running_sum += closes[i] - closes[i - SMA_PERIOD]
smas[i] = running_sum / SMA_PERIOD
today_close = closes[-1]
today_sma = smas[-1]
if today_sma is None:
return False, {"reason": "sma_not_computable"}
# Bottom-reversal mode is LONG-only:
# reclaim (long): was BELOW the SMA for N days, today closes ABOVE
# We explicitly do not trade symmetric short breakdowns here. Crypto
# top-calling is a different strategy with different risk.
reclaimed = today_close > today_sma
brokedown = today_close < today_sma
if brokedown:
return False, {
"reason": "shorts_disabled",
"close": round(today_close, 4),
"sma": round(today_sma, 4),
}
if not reclaimed:
return False, {"reason": "on_sma_no_cross",
"close": round(today_close, 4), "sma": round(today_sma, 4)}
# Prior DAYS_BELOW_REQUIRED bars must ALL be on the OPPOSITE side of the
# SMA from today (a real regime flip, not chop around a flat MA).
streak = 0
for i in range(2, DAYS_BELOW_REQUIRED + 2):
sma_at = smas[-i]
if sma_at is None:
return False, {"reason": "sma_history_incomplete"}
prior_on_wrong_side = closes[-i] >= sma_at
if prior_on_wrong_side:
return False, {"reason": "regime_period_too_short", "broke_at_day": i}
streak += 1
# Volume confirmation: today >= VOLUME_MULT_MIN × 30-day avg
avg_vol_30d = sum(volumes[-(VOLUME_LOOKBACK_DAYS + 1):-1]) / VOLUME_LOOKBACK_DAYS
if avg_vol_30d <= 0:
return False, {"reason": "no_volume_baseline"}
vol_ratio = volumes[-1] / avg_vol_30d
if vol_ratio < VOLUME_MULT_MIN:
return False, {"reason": "weak_volume", "vol_ratio": round(vol_ratio, 2)}
return True, {
"direction": "buy",
"close": round(today_close, 4),
"sma_200": round(today_sma, 4),
"gap_pct": round(abs(today_close - today_sma) / today_sma * 100, 2),
"streak_days": streak,
"vol_ratio": round(vol_ratio, 2),
}