Files
trumpsignal-backend/app/services/market_data.py
T
k 5fb1d52026 Pre-launch hardening: KOL module, Telegram, scanners, WS resilience
Big-picture changes since b941223:

KOL pipeline (new) — Substack/podcast/blog RSS → AI ticker extraction →
on-chain wallet diff → talks-vs-trades divergence detection. Daily polls,
19 feeds, divergence emits Post + Telegram fan-out.

Telegram push (new) — walletless free tier + wallet-linked Pro upgrade,
in-bot preference commands (/trump /btc /funding /kol /conf /quiet),
signed-envelope API for dashboard. Disconnect-wallet keeps free
subscription.

BTC funding-rate reversal scanner (new) — hourly cron, 30d cumulative
funding threshold + mean-revert + 7d price confirm, emits via
/api/signals/ingest. BTC bottom-reversal scanner promoted to System 2.

WS broadcast rewrite — per-client send timeout + parallel fan-out
(asyncio.gather). Fixes "Binance WS no close frame" reconnect storms +
APScheduler 11-min job misses, both caused by one slow client stalling
the kline loop.

Error visibility — three silent-error sites (trumpstruth/truth_social
fetchers, funding_reversal scanner) now include exception type name so
httpx ConnectError-style empty-message errors stop logging blank lines.
Telegram bot loop now classifies ReadTimeout vs network vs unknown +
logger.exception for the unknown bucket.

Security hygiene — trumpsignal.db untracked from git (held subscriber
wallets + encrypted HL keys + 22 bot trades); .gitignore now blocks
*.db/.next/backups. CORS only allows FRONTEND_URL in production.

New ops scripts —
  - scripts/preflight.py: env/DB/Telegram/AI auth verification gate
  - scripts/backup_db.sh: cron-friendly daily DB backup (SQLite + Postgres)
  - scripts/seed_kol_wallets.py: idempotent KOL on-chain wallet seeder

15 new Alembic migrations (007-021) covering convex strategy fields,
phase-1 safety, two-system frozen exits, invalidation prices, dynamic
SYS2 leverage, staged de-risk + pyramiding, peak gain tracking, risk
mode, auto-trade + grow flags, KOL module, KOL on-chain, KOL divergence,
Telegram bindings + walletless.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 00:52:56 +08:00

361 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Market data abstraction — pluggable candle sources.
Currently 2 providers:
- Binance : Free public REST, broad coverage of major coins.
- Hyperliquid : SAME venue as execution. Covers HL-native perps that
Binance doesn't list (TRUMP, HYPE, PURR, etc.) and
gives us mark-price-consistent data for those assets.
Routing rule:
- Assets in HL_NATIVE_ASSETS → Hyperliquid (no Binance pair exists)
- Everything else → Binance (better history, no rate friction)
Override per-call by selecting a provider explicitly:
await BinanceCandles().fetch_4h("BTC", days=30)
await HyperliquidCandles().fetch_4h("HYPE", days=30)
# Or auto-route:
src = for_asset("HYPE") # → HyperliquidCandles
candles = await src.fetch_4h("HYPE", days=30)
Normalized candle shape (returned by ALL providers):
{"time_ms": int, "open": float, "high": float, "low": float,
"close": float, "volume": float}
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Protocol
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
# ─── Provider protocol ──────────────────────────────────────────────────────
class CandleSource(Protocol):
name: str
async def fetch_4h(self, asset: str, days: int) -> list[dict]:
"""Last `days` worth of 4-hour candles for `asset`."""
...
async def fetch_1d(self, asset: str, days: int) -> list[dict]:
"""Last `days` daily candles. Used for SMA reclaim / VCP-Daily."""
...
async def fetch_1w(self, asset: str, weeks: int) -> list[dict]:
"""Last `weeks` weekly candles. Used for weekly-RSI reversal."""
...
async def fetch_1m(self, asset: str, start_ms: int, end_ms: int) -> list[dict]:
"""1-minute candles in [start_ms, end_ms]. Paginated internally."""
...
async def fetch_funding(self, asset: str, days: int) -> list[dict]:
"""Funding-rate history. List of {time_ms, rate}. HL-only for now —
Binance provides funding but the cross-venue rate differs, so we
defer to the execution venue (HL)."""
...
# ─── Binance ────────────────────────────────────────────────────────────────
class BinanceCandles:
name = "binance"
base_url = "https://api.binance.com/api/v3/klines"
@staticmethod
def _symbol(asset: str) -> str:
return f"{asset.upper()}USDT"
async def fetch_4h(self, asset: str, days: int) -> list[dict]:
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - days * 24 * 3600 * 1000
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(self.base_url, params={
"symbol": self._symbol(asset),
"interval": "4h",
"startTime": start_ms, "endTime": end_ms,
"limit": 1000,
})
resp.raise_for_status()
rows = resp.json()
return [self._normalize(r) for r in rows]
async def fetch_1d(self, asset: str, days: int) -> list[dict]:
return await self._fetch_simple(asset, "1d", days * 24 * 3600 * 1000)
async def fetch_1w(self, asset: str, weeks: int) -> list[dict]:
return await self._fetch_simple(asset, "1w", weeks * 7 * 24 * 3600 * 1000)
async def _fetch_simple(self, asset: str, interval: str, window_ms: int) -> list[dict]:
"""Single-call fetch for intervals where 1000 bars covers the window."""
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - window_ms
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(self.base_url, params={
"symbol": self._symbol(asset),
"interval": interval,
"startTime": start_ms, "endTime": end_ms,
"limit": 1000,
})
resp.raise_for_status()
rows = resp.json()
return [self._normalize(r) for r in rows]
async def fetch_1m(self, asset: str, start_ms: int, end_ms: int) -> list[dict]:
# Binance caps each call at 1000 candles — paginate forward.
out: list[dict] = []
cursor = start_ms
async with httpx.AsyncClient(timeout=20) as client:
while cursor < end_ms:
resp = await client.get(self.base_url, params={
"symbol": self._symbol(asset),
"interval": "1m",
"startTime": cursor, "endTime": end_ms,
"limit": 1000,
})
resp.raise_for_status()
chunk = resp.json()
if not chunk:
break
out.extend(self._normalize(r) for r in chunk)
last_open = chunk[-1][0]
if last_open <= cursor:
break
cursor = last_open + 60_000
if len(chunk) < 1000:
break
return out
async def fetch_funding(self, asset: str, days: int) -> list[dict]:
"""Binance perp funding. Format: list of {time_ms, rate}.
Binance returns rate per 8h funding cycle (matches HL convention).
Note: this is Binance's perp funding, NOT HL's. For HL-traded
positions, prefer HyperliquidCandles.fetch_funding().
"""
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - days * 24 * 3600 * 1000
url = "https://fapi.binance.com/fapi/v1/fundingRate"
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.get(url, params={
"symbol": self._symbol(asset),
"startTime": start_ms, "endTime": end_ms,
"limit": 1000,
})
resp.raise_for_status()
rows = resp.json()
return [
{"time_ms": r["fundingTime"], "rate": float(r["fundingRate"])}
for r in rows
]
@staticmethod
def _normalize(row) -> dict:
return {
"time_ms": row[0],
"open": float(row[1]),
"high": float(row[2]),
"low": float(row[3]),
"close": float(row[4]),
"volume": float(row[5]),
}
# ─── Hyperliquid ────────────────────────────────────────────────────────────
class HyperliquidCandles:
"""HL public /info endpoint — same data the HL UI uses.
Endpoint:
POST https://api.hyperliquid.xyz/info
body { "type": "candleSnapshot",
"req": { "coin": "SOL", "interval": "4h",
"startTime": ms, "endTime": ms } }
Returns array of {t, T, s, i, o, c, h, l, v, n} — we normalize to our
standard shape. Useful for HL-native perps Binance doesn't list.
"""
name = "hyperliquid"
def __init__(self, mainnet: bool | None = None):
use_mainnet = settings.hl_mainnet if mainnet is None else mainnet
self.base_url = (
"https://api.hyperliquid.xyz/info" if use_mainnet
else "https://api.hyperliquid-testnet.xyz/info"
)
async def fetch_4h(self, asset: str, days: int) -> list[dict]:
return await self._fetch_window(asset, "4h", days * 24 * 3600 * 1000)
async def fetch_1d(self, asset: str, days: int) -> list[dict]:
return await self._fetch_window(asset, "1d", days * 24 * 3600 * 1000)
async def fetch_1w(self, asset: str, weeks: int) -> list[dict]:
return await self._fetch_window(asset, "1w", weeks * 7 * 24 * 3600 * 1000)
async def fetch_1m(self, asset: str, start_ms: int, end_ms: int) -> list[dict]:
# HL returns ALL candles in the window in one response — no pagination
# needed for typical scanner windows. For multi-day 1m calls HL may
# truncate; the caller should keep windows under ~24h for 1m data.
return await self._fetch(asset.upper(), "1m", start_ms, end_ms)
async def _fetch_window(self, asset: str, interval: str, window_ms: int) -> list[dict]:
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - window_ms
return await self._fetch(asset.upper(), interval, start_ms, end_ms)
async def _fetch(self, coin: str, interval: str, start_ms: int, end_ms: int) -> list[dict]:
async with httpx.AsyncClient(timeout=20) as client:
resp = await client.post(self.base_url, json={
"type": "candleSnapshot",
"req": {
"coin": coin, "interval": interval,
"startTime": start_ms, "endTime": end_ms,
},
})
resp.raise_for_status()
rows = resp.json() or []
return [self._normalize(r) for r in rows]
async def fetch_funding(self, asset: str, days: int) -> list[dict]:
"""HL funding history — HOURLY cadence (1 cycle per hour).
IMPORTANT: HL's /info endpoint caps fundingHistory at 500 rows per
response. 500 rows × 1h cadence = 20.8 days, so a single call CAN'T
return a full 30-day window. We page backwards from `endTime` until
we cover `days` worth of history (or HL runs out of data).
Returns chronologically-sorted list of {time_ms, rate}, deduplicated.
"""
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
start_ms = end_ms - days * 24 * 3600 * 1000
cursor = end_ms
collected: dict[int, float] = {} # time_ms → rate (dedup by time)
async with httpx.AsyncClient(timeout=20) as client:
# Safety cap: at most 10 pages (5000 rows ≈ 208 days) — way more
# than any caller could reasonably want, prevents runaway loops
# if HL returns inconsistent data.
for _ in range(10):
if cursor <= start_ms:
break
resp = await client.post(self.base_url, json={
"type": "fundingHistory",
"coin": asset.upper(),
"startTime": start_ms,
"endTime": cursor,
})
resp.raise_for_status()
chunk = resp.json() or []
if not chunk:
break
for r in chunk:
t = r["time"]
if t not in collected:
collected[t] = float(r["fundingRate"])
oldest = min(r["time"] for r in chunk)
if oldest <= start_ms or len(chunk) < 500:
# Either we reached the start of our window, or HL gave
# us a partial page (no more data behind it).
break
cursor = oldest - 1
return [
{"time_ms": t, "rate": rate}
for t, rate in sorted(collected.items())
]
@staticmethod
def _normalize(row: dict) -> dict:
# HL candle keys: t=open_ms, o=open, h/l, c, v
return {
"time_ms": row["t"],
"open": float(row["o"]),
"high": float(row["h"]),
"low": float(row["l"]),
"close": float(row["c"]),
"volume": float(row["v"]),
}
# ─── Routing ────────────────────────────────────────────────────────────────
# HL-native perps that DO NOT have a Binance spot pair. The scanner / backtest
# auto-route these to HL. Add more as you discover them — the list is the
# only place to maintain provider preference.
HL_NATIVE_ASSETS = {
"HYPE", "PURR", "JEFF", "VAPOR",
"PIP", "OMNIX", "PYTH",
# NOTE: TRUMP is now listed on Binance too — using Binance for that gets
# cleaner history. SUI is on both.
}
# Reversal-strategy basket. Major-cap only (no shitcoins), 1 HL-native.
# Used by all three reversal scanners (weekly RSI, SMA reclaim, funding extreme).
REVERSAL_BASKET = ["BTC", "ETH", "SOL", "BNB", "LINK", "AAVE", "DOGE", "HYPE"]
# Bar durations in seconds — used by drop_in_progress_bar() to know whether
# the last candle in a response is still open. Crypto exchanges return the
# CURRENT (in-progress) bar in `klines` responses; for daily/weekly logic
# we usually want the most recent CLOSED bar instead.
INTERVAL_SECONDS = {
"1m": 60, "5m": 300, "15m": 900,
"1h": 3600, "4h": 14400, "8h": 28800,
"1d": 86400, "1w": 604800,
}
def drop_in_progress_bar(candles: list[dict], interval: str) -> list[dict]:
"""Return `candles` minus the last entry if it's still an open bar.
Daily / weekly bars on Binance & HL are returned with `time_ms = open_time`.
The bar's close time is open_time + interval_seconds. If now < close_time,
the bar hasn't closed yet — using its volume/close is misleading (volume
is partial, close is current spot mid-bar).
Use this in scanners that care about CONFIRMED signals (SMA reclaim,
weekly RSI). Use raw candles only when you want to react intra-bar
(rare and usually wrong for position trading).
"""
if not candles:
return candles
dur_s = INTERVAL_SECONDS.get(interval)
if dur_s is None:
return candles # unknown interval — be conservative, return as-is
bar_close_ms = candles[-1]["time_ms"] + dur_s * 1000
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
if now_ms < bar_close_ms:
return candles[:-1]
return candles
_BINANCE_SINGLETON = BinanceCandles()
_HL_SINGLETON = HyperliquidCandles()
def for_asset(asset: str) -> CandleSource:
"""Pick the right provider. HL-native → HL, otherwise Binance.
Always returns SOMETHING — caller doesn't need to handle None. If the
asset doesn't actually trade on the chosen venue, the underlying HTTP
call will return an empty list and the caller falls back to its
"no data" path.
"""
return _HL_SINGLETON if asset.upper() in HL_NATIVE_ASSETS else _BINANCE_SINGLETON