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,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
|
||||
15–30%); 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")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user