KOL feeds: fix dead/blocked sources, drop stale feeds (29→25)
Feed-health pass over KOL_FEEDS: - raoulpal: stale Substack (last 2024-05) → Real Vision podcast feed - dampedspring: paywalled (0 entries) → free "Damped Spring 101" Substack - unchained: Cloudflare 403 → canonical Megaphone podcast feed - lynalden: Cloudflare 202 → FeedBurner mirror - glassnode: recovered via httpx http2=True (was 403 on HTTP/1.1) - browser User-Agent + Accept headers on feed fetch - removed dead feeds with no active replacement: placeholder, dragonfly, niccarter, eugene - pin h2==4.3.0 (required by http2=True) All 25 remaining feeds verified fetching real body content; newest post per feed ≤88d. Bundles in-flight KOL-module work already in the working tree (kol_x ingest, migration 027, tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -161,63 +161,54 @@ async def fetch_ahr999() -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ── 2. Altcoin Season Index (blockchaincenter.net formula) ───────────────────
|
||||
# Take top-50 coins by market cap (excluding stablecoins + wrapped). Count how
|
||||
# many beat BTC's 90-day return. Result is the count, projected to 0-100.
|
||||
# 75+ = altseason, <25 = bitcoin season, middle = neutral.
|
||||
# ── 2. Altcoin Season Index (blockchaincenter.net — official source) ─────────
|
||||
# Scrape the value directly from blockchaincenter.net, which is the canonical
|
||||
# publisher of this index (90-day window: how many of the top 50 alts beat BTC
|
||||
# over 90 days). 75+ = altseason, <25 = bitcoin season.
|
||||
#
|
||||
# Previous implementation computed the index from CoinGecko /coins/markets
|
||||
# using a 30d window (CoinGecko doesn't return 90d per-coin data on that
|
||||
# endpoint). The 30d vs 90d discrepancy caused readings up to ~30 points
|
||||
# higher than the official index during BTC-dominated markets. Scraping the
|
||||
# actual page is more reliable than re-implementing the formula.
|
||||
#
|
||||
# Fallback: if the page scrape fails, return None (the @_none_on_fail
|
||||
# decorator handles that gracefully).
|
||||
|
||||
_STABLE_OR_WRAPPED = {
|
||||
"USDT", "USDC", "DAI", "BUSD", "TUSD", "USDD", "FDUSD", "PYUSD", "USDE",
|
||||
"WBTC", "WETH", "STETH", "WSTETH", "WEETH", "RETH",
|
||||
}
|
||||
_BCC_URL = "https://www.blockchaincenter.net/altcoin-season-index/"
|
||||
# Regex for the server-rendered value in the Next.js HTML:
|
||||
# "Season<!-- -->41" or "Season (<!-- -->41" inside the page markup.
|
||||
_BCC_RE = re.compile(r"Season[^<]{0,20}<!--\s*-->\s*(\d{1,3})")
|
||||
|
||||
|
||||
@_none_on_fail("altcoin_season_index")
|
||||
async def fetch_altcoin_season_index() -> dict:
|
||||
"""Compute the Altcoin Season Index from CoinGecko /coins/markets.
|
||||
"""Fetch the Altcoin Season Index from blockchaincenter.net.
|
||||
|
||||
Original blockchaincenter.net formula uses a 90-day window, but
|
||||
CoinGecko's /coins/markets `price_change_percentage` parameter only
|
||||
accepts 1h/24h/7d/14d/30d/200d/1y — 90d returns HTTP 400. We use 30d
|
||||
as the closest practical proxy. Long-horizon altseason (which 90d
|
||||
captures better) would need per-coin /market_chart calls — 50× the
|
||||
API budget for a marginal definition improvement.
|
||||
The site is Next.js SSR — the value is embedded in the initial HTML as a
|
||||
server-rendered text node. We parse it with a tight regex and fall back to
|
||||
None on any parse failure so the rest of the snapshot is unaffected.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, headers=UA) as c:
|
||||
r = await c.get(
|
||||
"https://api.coingecko.com/api/v3/coins/markets",
|
||||
params={"vs_currency": "usd", "order": "market_cap_desc",
|
||||
"per_page": 60, "page": 1,
|
||||
"price_change_percentage": "30d"},
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
headers={**UA, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"},
|
||||
follow_redirects=True,
|
||||
) as c:
|
||||
r = await c.get(_BCC_URL)
|
||||
r.raise_for_status()
|
||||
rows = r.json()
|
||||
html = r.text
|
||||
|
||||
# Drop stablecoins + wrapped, keep top 50 of the remainder.
|
||||
eligible = [
|
||||
row for row in rows
|
||||
if (row.get("symbol") or "").upper() not in _STABLE_OR_WRAPPED
|
||||
and row.get("price_change_percentage_30d_in_currency") is not None
|
||||
][:50]
|
||||
if len(eligible) < 30:
|
||||
return {"value": None, "raw": {"error": "insufficient eligible coins",
|
||||
"have": len(eligible)}}
|
||||
m = _BCC_RE.search(html)
|
||||
if not m:
|
||||
return {"value": None, "raw": {"error": "regex did not match", "url": _BCC_URL}}
|
||||
|
||||
btc_row = next((x for x in rows if x.get("symbol", "").upper() == "BTC"), None)
|
||||
btc_30d = btc_row.get("price_change_percentage_30d_in_currency") if btc_row else None
|
||||
if btc_30d is None:
|
||||
return {"value": None, "raw": {"error": "BTC 30d return missing"}}
|
||||
value = int(m.group(1))
|
||||
if not 0 <= value <= 100:
|
||||
return {"value": None, "raw": {"error": f"parsed value out of range: {value}"}}
|
||||
|
||||
n_outperform = sum(
|
||||
1 for row in eligible
|
||||
if (row["price_change_percentage_30d_in_currency"] or -999) > btc_30d
|
||||
)
|
||||
# Project the count over `len(eligible)` to a 0–100 scale.
|
||||
index = (n_outperform / len(eligible)) * 100
|
||||
return {
|
||||
"value": round(index, 1),
|
||||
"raw": {"n_outperform": n_outperform, "of": len(eligible),
|
||||
"btc_30d_pct": round(btc_30d, 2), "window": "30d"},
|
||||
"value": float(value),
|
||||
"raw": {"source": "blockchaincenter.net", "window": "90d", "parsed": value},
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user