5fb1d52026
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>
532 lines
22 KiB
Python
532 lines
22 KiB
Python
"""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
|