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,531 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user