feat(macro): Macro Vibes — 8-indicator daily snapshot + composite score
New backend pipeline: 8 free public macro signals fetched in parallel,
upserted once per calendar day, served via /api/macro/{snapshot,history}.
- AHR999 (computed from Binance 200d klines)
- Altcoin Season Index (CoinGecko top-50 30d)
- Fear & Greed (alternative.me)
- BTC dominance, ETH/BTC ratio
- Stablecoin supply (DeFiLlama)
- Spot BTC ETF net flow (Farside)
- BTC perp open interest (Binance fapi)
Each fetcher is independently @_none_on_fail decorated so one outage
can't take down the snapshot; scoring renormalises across whichever
indicators returned a value. Daily cron at 03:00 UTC; on startup a
fire-and-forget bootstrap fills today's row if missing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
"""Individual fetchers for each macro indicator.
|
||||
|
||||
Each function is an async coroutine that returns a dict shaped like:
|
||||
{ "value": float | int | None,
|
||||
"label": Optional[str], # only some indicators
|
||||
"raw": <upstream payload> } # for debugging / re-scoring
|
||||
|
||||
Every fetcher MUST tolerate upstream failure — return {"value": None} rather
|
||||
than raise — so one dead API can't take down the whole snapshot.
|
||||
|
||||
Public, free, no-key sources only:
|
||||
|
||||
AHR999 : derived from BTC daily closes (Binance fapi)
|
||||
Altcoin Season Index : CoinGecko top-50 90-day relative performance
|
||||
Fear & Greed : api.alternative.me/fng (no auth)
|
||||
BTC Dominance : CoinGecko /global
|
||||
ETH/BTC Ratio : Binance kline ETHBTC daily
|
||||
Stablecoin Supply : DeFiLlama /stablecoins
|
||||
ETF Net Flow (1d) : Farside Investors HTML scrape
|
||||
BTC Open Interest : Binance fapi /futures/data/openInterestHist
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A vanilla User-Agent. CoinGecko + alternative.me + DeFiLlama all happily
|
||||
# serve "Mozilla/5.0"; some get suspicious of anything that looks bot-like
|
||||
# (e.g. python-httpx default UA returns 400 on /global).
|
||||
UA = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15"}
|
||||
DEFAULT_TIMEOUT = 20
|
||||
|
||||
|
||||
def _none_on_fail(name: str):
|
||||
"""Decorator: log+swallow exceptions from a fetcher and return {value: None}."""
|
||||
def deco(fn):
|
||||
async def wrapper(*a, **kw):
|
||||
try:
|
||||
return await fn(*a, **kw)
|
||||
except Exception as exc:
|
||||
logger.warning("macro fetch %s failed: %s (%s)",
|
||||
name, type(exc).__name__, exc)
|
||||
return {"value": None, "raw": {"error": f"{type(exc).__name__}: {exc}"}}
|
||||
return wrapper
|
||||
return deco
|
||||
|
||||
|
||||
def _utc_midnight_ms(now: Optional[datetime] = None) -> int:
|
||||
dt = now or datetime.now(timezone.utc)
|
||||
midnight = dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return int(midnight.timestamp() * 1000)
|
||||
|
||||
|
||||
def _drop_in_progress_daily_klines(rows: list[list], now: Optional[datetime] = None) -> list[list]:
|
||||
"""Binance daily klines are keyed by OPEN time. If the latest row opened at
|
||||
today's 00:00 UTC, that candle is still in progress and should not be used
|
||||
for daily snapshots."""
|
||||
if not rows:
|
||||
return rows
|
||||
cutoff = _utc_midnight_ms(now)
|
||||
return [row for row in rows if int(row[0]) < cutoff]
|
||||
|
||||
|
||||
def _latest_closed_daily_point(rows: list[dict], now: Optional[datetime] = None) -> Optional[dict]:
|
||||
"""Same idea as `_drop_in_progress_daily_klines`, but for daily point
|
||||
series keyed by `timestamp`."""
|
||||
if not rows:
|
||||
return None
|
||||
cutoff = _utc_midnight_ms(now)
|
||||
closed = [row for row in rows if int(row.get("timestamp", 0)) < cutoff]
|
||||
return closed[-1] if closed else None
|
||||
|
||||
|
||||
def _parse_farside_latest_total(html: str) -> dict:
|
||||
"""Extract the most recent dated row from Farside's historical table.
|
||||
|
||||
The all-data table is chronological from oldest to newest, so the first
|
||||
date row is NOT the latest one.
|
||||
"""
|
||||
m = re.search(r"<tbody[^>]*>(.*?)</tbody>", html, re.DOTALL | re.IGNORECASE)
|
||||
if not m:
|
||||
return {"value": None, "raw": {"error": "tbody not found"}}
|
||||
body = m.group(1)
|
||||
rows = re.findall(r"<tr[^>]*>(.*?)</tr>", body, re.DOTALL | re.IGNORECASE)
|
||||
latest: Optional[dict] = None
|
||||
for row in rows:
|
||||
cells = re.findall(r"<td[^>]*>(.*?)</td>", row, re.DOTALL | re.IGNORECASE)
|
||||
if not cells:
|
||||
continue
|
||||
date_text = re.sub(r"<[^>]+>", "", cells[0]).strip()
|
||||
if not re.match(r"\d{1,2}\s+[A-Za-z]+\s+\d{4}", date_text):
|
||||
continue
|
||||
last_text = re.sub(r"<[^>]+>", "", cells[-1]).strip()
|
||||
num = last_text.replace(",", "").replace("(", "-").replace(")", "")
|
||||
try:
|
||||
millions = float(num)
|
||||
row_date = datetime.strptime(date_text, "%d %b %Y").date()
|
||||
except ValueError:
|
||||
continue
|
||||
candidate = {
|
||||
"value": round(millions * 1_000_000, 2),
|
||||
"raw": {"date": date_text, "millions_usd": millions},
|
||||
"_date": row_date,
|
||||
}
|
||||
if latest is None or candidate["_date"] > latest["_date"]:
|
||||
latest = candidate
|
||||
|
||||
if latest is None:
|
||||
return {"value": None, "raw": {"error": "no parseable rows"}}
|
||||
latest.pop("_date", None)
|
||||
return latest
|
||||
|
||||
|
||||
# ── 1. AHR999 ───────────────────────────────────────────────────────────────
|
||||
# Formula: AHR999 = (price / 200d MA) × (price / age_fit_price)
|
||||
# age_fit_price = 10 ** (5.84 * log10(days_since_2009_01_03) - 17.01)
|
||||
# Below 0.45 historically marks accumulation zones; above 1.2 marks
|
||||
# "expensive" regime that invalidates a bottom thesis.
|
||||
|
||||
_AHR999_GENESIS = datetime(2009, 1, 3, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@_none_on_fail("ahr999")
|
||||
async def fetch_ahr999() -> dict:
|
||||
"""Compute AHR999 from the last 200 daily BTC closes (Binance fapi)."""
|
||||
end_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||
start_ms = end_ms - 260 * 24 * 3600 * 1000 # extra buffer after dropping in-progress day
|
||||
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as c:
|
||||
r = await c.get(
|
||||
"https://fapi.binance.com/fapi/v1/klines",
|
||||
params={"symbol": "BTCUSDT", "interval": "1d",
|
||||
"startTime": start_ms, "endTime": end_ms, "limit": 300},
|
||||
)
|
||||
r.raise_for_status()
|
||||
rows = _drop_in_progress_daily_klines(r.json())
|
||||
closes = [float(row[4]) for row in rows]
|
||||
if len(closes) < 200:
|
||||
return {"value": None, "raw": {"error": "insufficient candles", "have": len(closes)}}
|
||||
price = closes[-1]
|
||||
ma200 = sum(closes[-200:]) / 200
|
||||
|
||||
days = (datetime.now(timezone.utc) - _AHR999_GENESIS).total_seconds() / 86400
|
||||
age_fit = 10 ** (5.84 * math.log10(days) - 17.01)
|
||||
|
||||
ahr = (price / ma200) * (price / age_fit)
|
||||
return {
|
||||
"value": round(ahr, 4),
|
||||
"raw": {"price": price, "ma200": round(ma200, 2),
|
||||
"age_fit": round(age_fit, 2), "days": round(days, 1)},
|
||||
}
|
||||
|
||||
|
||||
# ── 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.
|
||||
|
||||
_STABLE_OR_WRAPPED = {
|
||||
"USDT", "USDC", "DAI", "BUSD", "TUSD", "USDD", "FDUSD", "PYUSD", "USDE",
|
||||
"WBTC", "WETH", "STETH", "WSTETH", "WEETH", "RETH",
|
||||
}
|
||||
|
||||
|
||||
@_none_on_fail("altcoin_season_index")
|
||||
async def fetch_altcoin_season_index() -> dict:
|
||||
"""Compute the Altcoin Season Index from CoinGecko /coins/markets.
|
||||
|
||||
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.
|
||||
"""
|
||||
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"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
rows = r.json()
|
||||
|
||||
# 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)}}
|
||||
|
||||
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"}}
|
||||
|
||||
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"},
|
||||
}
|
||||
|
||||
|
||||
# ── 3. Fear & Greed (alternative.me) ────────────────────────────────────────
|
||||
|
||||
|
||||
@_none_on_fail("fear_greed")
|
||||
async def fetch_fear_greed() -> dict:
|
||||
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, headers=UA) as c:
|
||||
r = await c.get("https://api.alternative.me/fng/?limit=1")
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
item = (data.get("data") or [None])[0]
|
||||
if not item:
|
||||
return {"value": None, "raw": data}
|
||||
return {
|
||||
"value": int(item.get("value", 0)),
|
||||
"label": item.get("value_classification"),
|
||||
"raw": item,
|
||||
}
|
||||
|
||||
|
||||
# ── 4. BTC Dominance (CoinGecko /global) ────────────────────────────────────
|
||||
|
||||
|
||||
@_none_on_fail("btc_dominance")
|
||||
async def fetch_btc_dominance() -> dict:
|
||||
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, headers=UA) as c:
|
||||
r = await c.get("https://api.coingecko.com/api/v3/global")
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
pct = (data.get("data", {}).get("market_cap_percentage", {}) or {}).get("btc")
|
||||
if pct is None:
|
||||
return {"value": None, "raw": data}
|
||||
return {"value": round(float(pct), 2),
|
||||
"raw": {"total_mcap_usd": data["data"]["total_market_cap"].get("usd")}}
|
||||
|
||||
|
||||
# ── 5. ETH/BTC Ratio (Binance ETHBTC daily) ──────────────────────────────────
|
||||
|
||||
|
||||
@_none_on_fail("eth_btc_ratio")
|
||||
async def fetch_eth_btc_ratio() -> dict:
|
||||
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as c:
|
||||
r = await c.get(
|
||||
"https://fapi.binance.com/fapi/v1/klines",
|
||||
params={"symbol": "ETHBTC", "interval": "1d", "limit": 3},
|
||||
)
|
||||
# fapi may 404 ETHBTC; fall back to spot kline endpoint via data-api host.
|
||||
if r.status_code == 400 or r.status_code == 404:
|
||||
r = await c.get(
|
||||
"https://data-api.binance.vision/api/v3/klines",
|
||||
params={"symbol": "ETHBTC", "interval": "1d", "limit": 3},
|
||||
)
|
||||
r.raise_for_status()
|
||||
rows = _drop_in_progress_daily_klines(r.json())
|
||||
if not rows:
|
||||
return {"value": None, "raw": rows}
|
||||
close = float(rows[-1][4])
|
||||
return {"value": round(close, 6), "raw": {"close": close, "n_rows": len(rows)}}
|
||||
|
||||
|
||||
# ── 6. Stablecoin Total Supply (DeFiLlama) ───────────────────────────────────
|
||||
|
||||
|
||||
@_none_on_fail("stablecoin_supply")
|
||||
async def fetch_stablecoin_supply() -> dict:
|
||||
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, headers=UA) as c:
|
||||
r = await c.get(
|
||||
"https://stablecoins.llama.fi/stablecoins",
|
||||
params={"includePrices": "true"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
# Sum circulating peggedUSD across all stables.
|
||||
total = 0.0
|
||||
for stable in data.get("peggedAssets", []):
|
||||
circ = stable.get("circulating", {}).get("peggedUSD")
|
||||
if isinstance(circ, (int, float)):
|
||||
total += float(circ)
|
||||
if total <= 0:
|
||||
return {"value": None, "raw": {"error": "no peggedUSD totals found"}}
|
||||
return {"value": round(total, 2),
|
||||
"raw": {"n_stables": len(data.get("peggedAssets", []))}}
|
||||
|
||||
|
||||
# ── 7. BTC Spot ETF Net Flow 1d (Farside) ────────────────────────────────────
|
||||
# Farside doesn't have a JSON API but their daily flow page is parseable. We
|
||||
# pull the most recent row from the All ETFs sum.
|
||||
|
||||
|
||||
@_none_on_fail("etf_flow")
|
||||
async def fetch_etf_flow() -> dict:
|
||||
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT, headers=UA,
|
||||
follow_redirects=True) as c:
|
||||
r = await c.get("https://farside.co.uk/bitcoin-etf-flow-all-data/")
|
||||
r.raise_for_status()
|
||||
return _parse_farside_latest_total(r.text)
|
||||
|
||||
|
||||
# ── 8. BTC Open Interest (Binance fapi) ──────────────────────────────────────
|
||||
|
||||
|
||||
@_none_on_fail("btc_open_interest")
|
||||
async def fetch_btc_open_interest() -> dict:
|
||||
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as c:
|
||||
r = await c.get(
|
||||
"https://fapi.binance.com/futures/data/openInterestHist",
|
||||
params={"symbol": "BTCUSDT", "period": "1d", "limit": 4},
|
||||
)
|
||||
r.raise_for_status()
|
||||
rows = r.json()
|
||||
latest = _latest_closed_daily_point(rows)
|
||||
if not latest:
|
||||
return {"value": None, "raw": rows}
|
||||
notional = float(latest.get("sumOpenInterestValue", 0))
|
||||
return {"value": round(notional, 2),
|
||||
"raw": {"contracts": float(latest.get("sumOpenInterest", 0)),
|
||||
"timestamp": latest.get("timestamp")}}
|
||||
Reference in New Issue
Block a user