Files
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

532 lines
22 KiB
Python
Raw Permalink 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.
"""KOL A-tier: daily on-chain holdings snapshot + change detection.
Data sources (all free):
Hyperliquid public API — No key. Perp positions + mark prices for any
HL-listed asset (BTC/ETH/SOL/HYPE/...).
Endpoint: POST /info {"type":"clearinghouseState"}
Prices: POST /info {"type":"allMids"}
Etherscan API — Free key (etherscan.io/register, email only).
ERC-20 token balances for any Ethereum address.
Env: ETHERSCAN_API_KEY. Skip ETH wallets if unset.
CoinGecko (no key) — Fallback price for tokens not listed on HL.
Free tier: 30 req/min, batch by contract address.
Pricing priority per token:
1. HL allMids (by symbol, e.g. "ETH" → $2025)
2. CoinGecko by contract address (Ethereum only)
3. Skip (price unknown → usd_value=0, excluded from snapshot)
Daily flow (run_onchain_poll at 02:00 UTC):
For each HL wallet → fetch HL positions → snapshot → diff
For each ETH wallet → fetch Etherscan ERC-20 list → price each token → snapshot → diff
Diffs with usd_after > $50k (new) or ±25% change written to kol_holding_changes.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from datetime import datetime, timezone
from typing import Optional
import httpx
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import AsyncSessionLocal
from app.models import KolHoldingChange, KolHoldingSnapshot, KolWallet, utcnow
logger = logging.getLogger(__name__)
HL_API_URL = "https://api.hyperliquid.xyz/info"
ETHERSCAN_API_URL = "https://api.etherscan.io/v2/api"
COINGECKO_URL = "https://api.coingecko.com/api/v3"
# Stablecoins — ignored in all snapshots
STABLES = {"USDC", "USDT", "DAI", "BUSD", "TUSD", "USDE", "FRAX", "LUSD", "CRVUSD"}
# Change detection thresholds
_NEW_POSITION_MIN_USD = 50_000 # new token > $50k → alert
_CHANGE_PCT_THRESHOLD = 25.0 # ±25% USD move → alert
_CLOSED_MIN_USD = 10_000 # closed position must have been > $10k to report
# ── Price layer ───────────────────────────────────────────────────────────────
_hl_prices: dict[str, float] = {} # symbol → USD, refreshed each poll
_hl_prices_fetched_at: float = 0.0
async def _get_hl_prices() -> dict[str, float]:
"""Fetch all HL mark prices (free, no auth). Cached for 5 min per poll cycle.
Returns empty dict on network failure — callers fall back to CoinGecko."""
global _hl_prices, _hl_prices_fetched_at
now = time.time()
if now - _hl_prices_fetched_at < 300 and _hl_prices:
return _hl_prices
try:
async with httpx.AsyncClient(timeout=10.0) as c:
r = await c.post(HL_API_URL, json={"type": "allMids"})
r.raise_for_status()
raw = r.json() # {"BTC": "74541.0", "ETH": "2025.0", ...}
_hl_prices = {sym: float(px) for sym, px in raw.items()}
_hl_prices_fetched_at = now
logger.info("[kol_onchain] HL prices loaded: %d assets", len(_hl_prices))
except Exception as e:
logger.warning("[kol_onchain] HL price fetch failed (%s) — falling back to CoinGecko", e)
# Don't overwrite a previous good cache on transient failure
if not _hl_prices:
_hl_prices = {}
return _hl_prices
# CoinGecko symbol→id map for common crypto assets (fallback when HL unavailable)
_CG_SYMBOL_IDS = {
"BTC": "bitcoin", "ETH": "ethereum", "SOL": "solana",
"BNB": "binancecoin", "AVAX": "avalanche-2", "MATIC": "matic-network",
"ARB": "arbitrum", "OP": "optimism", "LINK": "chainlink",
"UNI": "uniswap", "AAVE": "aave", "MKR": "maker",
"HYPE": "hyperliquid", "ENA": "ethena", "PENDLE": "pendle",
"WLD": "worldcoin-wld", "JUP": "jupiter-exchange-solana",
"WBTC": "wrapped-bitcoin", "STETH": "staked-ether",
"EETH": "ether-fi-staked-eth", "WEETH": "wrapped-eeth",
}
async def _coingecko_prices_by_symbol(symbols: list[str]) -> dict[str, float]:
"""Batch price lookup by CoinGecko coin ID. No key needed."""
ids_needed = {s: _CG_SYMBOL_IDS[s] for s in symbols if s in _CG_SYMBOL_IDS}
if not ids_needed:
return {}
try:
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.get(f"{COINGECKO_URL}/simple/price", params={
"ids": ",".join(ids_needed.values()),
"vs_currencies": "usd",
})
if r.status_code != 200:
return {}
data = r.json()
# Invert: coin_id → price, then map back to symbol
id_to_price = {v: data.get(v, {}).get("usd", 0) for v in ids_needed.values()}
return {sym: id_to_price[cid] for sym, cid in ids_needed.items() if id_to_price.get(cid)}
except Exception as e:
logger.warning("[kol_onchain] CoinGecko symbol lookup failed: %s", e)
return {}
async def _coingecko_prices_by_contract(
contract_addresses: list[str],
) -> dict[str, float]:
"""Batch price lookup by Ethereum contract address. No key needed.
Chunks into batches of 25 (CoinGecko free tier limit).
Returns {contract_addr_lower: usd_price}."""
if not contract_addresses:
return {}
result: dict[str, float] = {}
addrs_lower = [a.lower() for a in contract_addresses[:100]]
# CoinGecko free tier caps at ~25 addresses per request
chunk_size = 25
async with httpx.AsyncClient(timeout=15.0) as c:
for i in range(0, len(addrs_lower), chunk_size):
chunk = addrs_lower[i:i + chunk_size]
try:
r = await c.get(
f"{COINGECKO_URL}/simple/token_price/ethereum",
params={"contract_addresses": ",".join(chunk), "vs_currencies": "usd"},
)
if r.status_code != 200:
logger.warning("[kol_onchain] CoinGecko %s: %s", r.status_code, r.text[:80])
continue
data = r.json()
result.update({addr: info["usd"] for addr, info in data.items() if "usd" in info})
except Exception as e:
logger.warning("[kol_onchain] CoinGecko chunk failed: %s", e)
return result
# ── Hyperliquid: perp positions ───────────────────────────────────────────────
async def _fetch_hl_positions(address: str) -> tuple[list[dict], float]:
"""Query HL clearinghouseState. Returns (holdings, account_value_usd).
holdings: [{ticker, amount, usd_value, chain, side}]
"""
async with httpx.AsyncClient(timeout=15.0) as c:
r = await c.post(HL_API_URL, json={"type": "clearinghouseState", "user": address})
r.raise_for_status()
data = r.json()
prices = await _get_hl_prices()
holdings = []
for pos in data.get("assetPositions", []):
p = pos.get("position", {})
coin = p.get("coin", "")
if not coin:
continue
size = float(p.get("szi", 0))
if abs(size) < 1e-8:
continue
price = prices.get(coin, float(p.get("entryPx") or 0))
notional = abs(size) * price
if notional < 1:
continue
holdings.append({
"ticker": coin,
"amount": abs(size),
"usd_value": round(notional, 2),
"chain": "hl",
"side": "long" if size > 0 else "short",
})
margin = data.get("marginSummary", {})
account_value = float(margin.get("accountValue", 0))
return holdings, account_value
# ── Etherscan: ERC-20 balances ────────────────────────────────────────────────
async def _fetch_eth_holdings(address: str) -> tuple[list[dict], float]:
"""Fetch ERC-20 + ETH holdings via Etherscan free tier.
Free-tier strategy (no Pro needed):
Step 1: GET account/balance → native ETH amount
Step 2: GET account/tokentx (recent) → discover which ERC-20 contracts
the wallet has interacted with
Step 3: GET account/tokenbalance → current balance per contract
Step 4: Price via HL allMids, then CoinGecko by contract address fallback
Etherscan free limit: 5 req/s, ~100k req/day — well within budget for
daily snapshots of a handful of KOL wallets.
"""
if not settings.etherscan_api_key:
return [], 0.0
prices = await _get_hl_prices()
key = settings.etherscan_api_key
async with httpx.AsyncClient(timeout=20.0) as c:
# ── Step 1: ETH native balance ───────────────────────────────────
eth_r = await c.get(ETHERSCAN_API_URL, params={
"chainid": "1", "module": "account", "action": "balance",
"address": address, "tag": "latest", "apikey": key,
})
eth_data = eth_r.json()
# ── Step 2: ERC-20 tx history → unique (contract, symbol, decimals) ─
# offset=200 is enough to cover all tokens a KOL holds. Vitalik-scale
# addresses with 100k+ txs can still timeout — real KOL wallets are far smaller.
tx_r = await c.get(ETHERSCAN_API_URL, params={
"chainid": "1", "module": "account", "action": "tokentx",
"address": address, "page": "1", "offset": "200",
"sort": "desc", "apikey": key,
}, timeout=30.0)
tx_data = tx_r.json()
# Collect unique contracts from tx history
seen: dict[str, dict] = {} # contract → {symbol, decimals}
txs = tx_data.get("result") or []
if isinstance(txs, list):
for tx in txs:
contract = (tx.get("contractAddress") or "").lower()
if not contract or contract in seen:
continue
symbol = (tx.get("tokenSymbol") or "").upper()
if not symbol or symbol in STABLES:
continue
try:
decimals = int(tx.get("tokenDecimal") or 18)
except ValueError:
decimals = 18
seen[contract] = {"symbol": symbol, "decimals": decimals}
# ── Step 3: current balance per contract ─────────────────────────────
holdings: list[dict] = []
unpriced: list[str] = [] # contracts needing CoinGecko
contract_meta: dict[str, dict] = {}
# Rate-limit: Etherscan free = 5 req/s. Sleep 0.22s between calls to stay
# under burst limit even when looping 80 tokens × 4 wallets in one poll.
async with httpx.AsyncClient(timeout=20.0) as c:
for contract, meta in list(seen.items())[:80]: # cap at 80 tokens
await asyncio.sleep(0.22)
bal_r = await c.get(ETHERSCAN_API_URL, params={
"chainid": "1", "module": "account", "action": "tokenbalance",
"contractaddress": contract, "address": address,
"tag": "latest", "apikey": key,
})
bal_data = bal_r.json()
if bal_data.get("message") != "OK":
continue
raw = int(bal_data.get("result") or 0)
if raw == 0:
continue
balance = raw / (10 ** meta["decimals"])
symbol = meta["symbol"]
hl_price = prices.get(symbol)
if hl_price and hl_price > 0:
usd_value = round(balance * hl_price, 2)
if usd_value >= 500:
holdings.append({
"ticker": symbol, "amount": round(balance, 6),
"usd_value": usd_value, "chain": "ethereum",
"contract": contract,
})
else:
unpriced.append(contract)
contract_meta[contract] = {
"ticker": symbol, "amount": round(balance, 6),
"chain": "ethereum", "contract": contract,
}
# ── Step 4a: ETH native ──────────────────────────────────────────────
eth_raw = int(eth_data.get("result") or 0)
eth_balance = eth_raw / 1e18
if eth_balance > 0.001:
eth_price = prices.get("ETH", 0.0)
# Fallback: CoinGecko by symbol if HL unavailable
if not eth_price:
cg_sym = await _coingecko_prices_by_symbol(["ETH"])
eth_price = cg_sym.get("ETH", 0.0)
eth_usd = round(eth_balance * eth_price, 2)
if eth_usd >= 100:
holdings.append({
"ticker": "ETH", "amount": round(eth_balance, 6),
"usd_value": eth_usd, "chain": "ethereum",
})
# ── Step 4b: CoinGecko fallback for unpriced tokens ──────────────────
if unpriced:
try:
cg_prices = await _coingecko_prices_by_contract(unpriced)
for contract, price in cg_prices.items():
meta = contract_meta.get(contract)
if not meta or price <= 0:
continue
usd_value = round(meta["amount"] * price, 2)
if usd_value >= 500:
holdings.append({**meta, "usd_value": usd_value})
except Exception as e:
logger.warning("[kol_onchain] CoinGecko fallback failed: %s", e)
# Merge duplicate tickers (different contracts, same symbol) by summing USD value
merged: dict[str, dict] = {}
for h in holdings:
t = h["ticker"]
if t in merged:
merged[t]["usd_value"] = round(merged[t]["usd_value"] + h["usd_value"], 2)
merged[t]["amount"] = round(merged[t]["amount"] + h["amount"], 6)
else:
merged[t] = dict(h)
holdings = list(merged.values())
total_usd = round(sum(h["usd_value"] for h in holdings), 2)
logger.info("[kol_onchain] %s ETH holdings: %d tokens, $%.0f total",
address[:10], len(holdings), total_usd)
return holdings, total_usd
# ── Snapshot + diff ───────────────────────────────────────────────────────────
def _diff_holdings(
prev: list[dict], curr: list[dict], wallet_id: int,
) -> list[KolHoldingChange]:
# SUM by ticker — earlier snapshots may contain duplicate ticker rows
# (different contracts, same symbol e.g. two APE tokens). Dict comprehension
# would silently drop one and falsely flag +1900% diffs the next day.
def _sum_by_ticker(rows: list[dict]) -> dict[str, float]:
agg: dict[str, float] = {}
for h in rows:
t = h.get("ticker")
if not t:
continue
agg[t] = agg.get(t, 0.0) + float(h.get("usd_value") or 0)
return agg
prev_map = _sum_by_ticker(prev)
curr_map = _sum_by_ticker(curr)
now = utcnow()
changes = []
for ticker in set(prev_map) | set(curr_map):
before = prev_map.get(ticker, 0.0)
after = curr_map.get(ticker, 0.0)
if before == 0 and after >= _NEW_POSITION_MIN_USD:
changes.append(KolHoldingChange(
wallet_id=wallet_id, detected_at=now, ticker=ticker,
change_type="new_position", usd_before=0, usd_after=after,
))
elif after == 0 and before >= _CLOSED_MIN_USD:
changes.append(KolHoldingChange(
wallet_id=wallet_id, detected_at=now, ticker=ticker,
change_type="closed", usd_before=before, usd_after=0,
pct_change=-100.0,
))
elif before > 0 and after > 0:
pct = (after - before) / before * 100
if abs(pct) >= _CHANGE_PCT_THRESHOLD:
changes.append(KolHoldingChange(
wallet_id=wallet_id, detected_at=now, ticker=ticker,
change_type="increased" if pct > 0 else "decreased",
usd_before=before, usd_after=after, pct_change=round(pct, 1),
))
return changes
async def _snapshot_wallet(
session: AsyncSession,
wallet: KolWallet,
holdings: list[dict],
total_usd: float,
source: str,
) -> dict:
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
# Skip if already snapshotted today
existing = (await session.execute(
select(KolHoldingSnapshot).where(
KolHoldingSnapshot.wallet_id == wallet.id,
KolHoldingSnapshot.snapshot_date == today,
)
)).scalar_one_or_none()
if existing:
return {"wallet_id": wallet.id, "status": "already_snapshotted"}
# Previous snapshot for diff
prev_row = (await session.execute(
select(KolHoldingSnapshot)
.where(KolHoldingSnapshot.wallet_id == wallet.id)
.order_by(KolHoldingSnapshot.snapshot_date.desc())
.limit(1)
)).scalar_one_or_none()
prev_holdings = json.loads(prev_row.holdings_json) if prev_row else []
# Store snapshot (strip contract addresses to keep JSON lean)
clean_holdings = [{k: v for k, v in h.items() if k != "contract"} for h in holdings]
snap = KolHoldingSnapshot(
wallet_id=wallet.id,
snapshot_date=today,
holdings_json=json.dumps(clean_holdings, ensure_ascii=False),
total_usd=total_usd,
source=source,
)
session.add(snap)
# Detect and store changes
changes = _diff_holdings(prev_holdings, holdings, wallet.id)
for c in changes:
session.add(c)
logger.info(
"[kol_onchain] %s %s %s $%.0f→$%.0f",
wallet.handle, c.change_type, c.ticker,
c.usd_before or 0, c.usd_after or 0,
)
await session.flush()
return {
"handle": wallet.handle,
"chain": wallet.chain,
"holdings": len(holdings),
"total_usd": total_usd,
"changes": len(changes),
"source": source,
}
# ── Poll entry points ─────────────────────────────────────────────────────────
async def run_hl_poll() -> list[dict]:
"""Poll HL perp positions for all chain='hl' wallets."""
results = []
async with AsyncSessionLocal() as session:
wallets = (await session.execute(
select(KolWallet).where(KolWallet.chain == "hl", KolWallet.active == True)
)).scalars().all()
if not wallets:
logger.info("[kol_onchain] no HL wallets configured")
return []
for wallet in wallets:
try:
holdings, total = await _fetch_hl_positions(wallet.address)
stat = await _snapshot_wallet(session, wallet, holdings, total, "hl")
results.append(stat)
except Exception as e:
logger.warning("[kol_onchain] HL fetch failed %s: %s", wallet.handle, e)
results.append({"handle": wallet.handle, "error": str(e)})
await session.commit()
logger.info("[kol_onchain] HL poll done: %s", results)
return results
async def run_eth_poll() -> list[dict]:
"""Poll Ethereum ERC-20 holdings for all chain='ethereum' wallets.
No-op if ETHERSCAN_API_KEY not set."""
if not settings.etherscan_api_key:
logger.debug("[kol_onchain] ETHERSCAN_API_KEY not set — skipping ETH poll")
return []
results = []
async with AsyncSessionLocal() as session:
wallets = (await session.execute(
select(KolWallet).where(KolWallet.chain == "ethereum", KolWallet.active == True)
)).scalars().all()
if not wallets:
logger.info("[kol_onchain] no Ethereum wallets configured")
return []
# Pre-warm HL prices once for the whole batch
await _get_hl_prices()
for wallet in wallets:
try:
holdings, total = await _fetch_eth_holdings(wallet.address)
stat = await _snapshot_wallet(session, wallet, holdings, total, "etherscan")
results.append(stat)
except Exception as e:
logger.warning("[kol_onchain] ETH fetch failed %s: %s", wallet.handle, e)
results.append({"handle": wallet.handle, "error": str(e)})
await session.commit()
logger.info("[kol_onchain] ETH poll done: %s", results)
return results
async def run_onchain_poll() -> dict:
"""Combined entry point for APScheduler. Runs HL + ETH polls."""
hl = await run_hl_poll()
eth = await run_eth_poll()
return {"hl": hl, "eth": eth}
async def seed_wallets(entries: list[tuple]) -> int:
"""Bulk-insert (handle, chain, address, label, source_url) tuples, skip dupes."""
count = 0
async with AsyncSessionLocal() as session:
for handle, chain, address, label, source_url in entries:
existing = (await session.execute(
select(KolWallet).where(
KolWallet.chain == chain,
KolWallet.address == address.lower(),
)
)).scalar_one_or_none()
if existing:
continue
session.add(KolWallet(
handle=handle, chain=chain,
address=address.lower(),
label=label, source_url=source_url,
))
count += 1
await session.commit()
return count