Files
trumpsignal-backend/app/services/macro/fetchers.py
T
k 4442e97f28 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>
2026-05-26 01:04:53 +08:00

336 lines
14 KiB
Python
Raw 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.
"""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 0100 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")}}