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:
k
2026-05-26 01:04:53 +08:00
parent 5fb1d52026
commit 4442e97f28
12 changed files with 1461 additions and 1 deletions
View File
+335
View File
@@ -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 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")}}
+102
View File
@@ -0,0 +1,102 @@
"""Daily macro snapshot orchestrator.
Runs every fetcher in parallel (each is independently fail-tolerant), computes
the composite score against whatever came back, and UPSERTs one row keyed by
today's date. Subsequent same-day invocations overwrite the row with newer
data — i.e. you can re-run by hand to refresh after fixing a fetcher without
duplicating snapshots.
Schedule: app/main.py wires this to cron at 03:00 UTC (after KOL polls).
"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from app.database import AsyncSessionLocal
from app.models import MacroSnapshot
from . import fetchers, scoring
logger = logging.getLogger(__name__)
async def run_macro_poll() -> dict:
"""Fetch every indicator and write today's snapshot row. Idempotent per
calendar day — re-runs overwrite the existing row instead of inserting."""
logger.info("[macro] poll starting")
# Run every fetcher in parallel — they're all independent.
results = await asyncio.gather(
fetchers.fetch_ahr999(),
fetchers.fetch_altcoin_season_index(),
fetchers.fetch_fear_greed(),
fetchers.fetch_btc_dominance(),
fetchers.fetch_eth_btc_ratio(),
fetchers.fetch_stablecoin_supply(),
fetchers.fetch_etf_flow(),
fetchers.fetch_btc_open_interest(),
return_exceptions=False, # the decorator already catches everything
)
(ahr, alt, fg, dom, ebr, stab, etf, oi) = results
# Pack into a values dict matching MacroSnapshot's columns.
values = {
"ahr999": ahr["value"],
"altcoin_season_index": alt["value"],
"fear_greed": fg["value"],
"fear_greed_label": fg.get("label"),
"btc_dominance_pct": dom["value"],
"eth_btc_ratio": ebr["value"],
"stablecoin_supply_usd": stab["value"],
"etf_flow_net_usd_1d": etf["value"],
"btc_open_interest_usd": oi["value"],
}
composite, regime = scoring.compute_composite(values)
values["composite_score"] = composite
values["regime_label"] = regime
raw_blob = json.dumps({
"ahr999": ahr.get("raw"),
"altcoin_season": alt.get("raw"),
"fear_greed": fg.get("raw"),
"btc_dominance": dom.get("raw"),
"eth_btc_ratio": ebr.get("raw"),
"stablecoin_supply": stab.get("raw"),
"etf_flow": etf.get("raw"),
"btc_open_interest": oi.get("raw"),
}, default=str)[:8000] # cap to a sane Text column size
today = datetime.now(timezone.utc).date()
now = datetime.now(timezone.utc).replace(tzinfo=None)
async with AsyncSessionLocal() as db:
existing = (await db.execute(
select(MacroSnapshot).where(MacroSnapshot.snapshot_date == today)
)).scalar_one_or_none()
if existing:
# Update in place — same-day re-run.
for k, v in values.items():
setattr(existing, k, v)
existing.captured_at = now
existing.raw_json = raw_blob
else:
db.add(MacroSnapshot(
snapshot_date=today,
captured_at=now,
raw_json=raw_blob,
**values,
))
await db.commit()
n_alive = sum(1 for v in [ahr, alt, fg, dom, ebr, stab, etf, oi] if v["value"] is not None)
logger.info("[macro] poll done: %d/8 indicators OK, composite=%s (%s)",
n_alive, composite, regime)
return {"date": today.isoformat(), "alive": n_alive, "of": 8,
"composite": composite, "regime": regime,
"values": values}
+149
View File
@@ -0,0 +1,149 @@
"""Composite "regime" score over the macro indicators.
Goal: one -100..+100 number that says whether the overall macro setup tilts
risk-on (positive) or risk-off (negative). Used purely as advisory display on
the BTC page — does NOT (yet) feed sizing or trade decisions.
Design choices:
* Each indicator contributes a per-indicator signal in [-1, +1].
* Weights sum to 1.0 across the indicators we got. Missing indicators are
excluded and the remaining weights are renormalised — so a single dead
API doesn't drag the whole score toward zero.
* The contrarian indicators (fear/greed, AHR999, funding) are intentionally
inverted: extreme fear / cheap AHR999 / extreme funding all add positive
score (buy-the-fear logic).
The weights here are seeded by hand from rough conviction. Re-tune after a
few weeks of live data — see `composite_score` history vs realised forward
returns.
"""
from __future__ import annotations
from typing import Optional
# (weight, signal_function) per indicator. signal_function takes the raw value
# and returns a number in [-1, +1].
#
# Inputs may be None if the upstream fetch failed; the orchestrator filters
# them out and renormalises before summing.
def _ahr999_signal(v: Optional[float]) -> Optional[float]:
"""< 0.45 cheap → +1; 0.451.2 neutral; > 1.2 expensive → -1."""
if v is None: return None
if v < 0.45: return 1.0
if v > 1.2: return -1.0
# Linear interpolation between 0.45 and 1.2 → +1 to -1.
return 1.0 - 2 * ((v - 0.45) / (1.2 - 0.45))
def _altseason_signal(v: Optional[float]) -> Optional[float]:
"""High altseason index = risk-on (+1), low = BTC season (defensive but
not bearish, so a mild positive)."""
if v is None: return None
if v >= 75: return 0.7 # altseason — generally bullish risk
if v <= 25: return 0.3 # BTC-only — defensive but not a bear signal
return (v - 50) / 50 # linear, -0.5 to +0.5
def _fear_greed_signal(v: Optional[int]) -> Optional[float]:
"""Contrarian: extreme fear → buy (+1), extreme greed → sell (-1)."""
if v is None: return None
# Map 0..100 → +1..-1 (inverted, contrarian).
return (50 - v) / 50
def _btc_dominance_signal(v: Optional[float]) -> Optional[float]:
"""Hard to read in isolation — only inform on extremes.
Very high dominance often signals fear (risk-off into BTC) → mild bearish.
Very low = altseason froth → also mild bearish (cycle late). Mid = neutral."""
if v is None: return None
if v >= 60: return -0.3
if v <= 40: return -0.3
return 0.0
def _eth_btc_signal(v: Optional[float]) -> Optional[float]:
"""Rising ETH/BTC = risk-on. No persistent absolute level matters; this is
really a trend indicator. We approximate with absolute thresholds for the
current cycle (2025-2026): > 0.04 risk-on, < 0.025 risk-off."""
if v is None: return None
if v >= 0.04: return 0.7
if v <= 0.025: return -0.7
return (v - 0.0325) / 0.0075 * 0.7 # linear in the middle
def _stablecoin_supply_signal(v: Optional[float]) -> Optional[float]:
"""Absolute supply tells us little day-over-day; we need the delta. Since
this scorer sees only the snapshot, we treat presence as 0 and let the
visual chart show the trend. Returns 0 if we have any value at all."""
if v is None: return None
return 0.0 # contribution = 0 until we wire in a trend lookup
def _etf_flow_signal(v: Optional[float]) -> Optional[float]:
"""Net inflow = institutional bid → +1, outflow → -1. Scale by magnitude."""
if v is None: return None
# Daily prints over $200M are notable; over $500M unusually large.
abs_v = abs(v)
sign = 1 if v > 0 else (-1 if v < 0 else 0)
if abs_v >= 500_000_000: return 1.0 * sign
if abs_v >= 200_000_000: return 0.7 * sign
if abs_v >= 50_000_000: return 0.4 * sign
return 0.1 * sign
def _open_interest_signal(v: Optional[float]) -> Optional[float]:
"""OI in isolation doesn't tell us direction — we'd need OI vs price
correlation. Until we have a trend window, contribute 0."""
if v is None: return None
return 0.0
# Weights (sum to 1.0 across all). When an indicator is missing, we drop its
# weight and renormalise the rest.
WEIGHTS = {
"ahr999": 0.20,
"altcoin_season": 0.10,
"fear_greed": 0.20,
"btc_dominance": 0.05,
"eth_btc": 0.15,
"stablecoin_supply": 0.05,
"etf_flow": 0.20,
"btc_open_interest": 0.05,
}
def compute_composite(values: dict) -> tuple[Optional[float], Optional[str]]:
"""Return (score in [-100, +100], regime_label) or (None, None) if there
isn't enough data to score.
`values` keys must match WEIGHTS keys (without "_signal" suffix).
"""
pairs = [
("ahr999", _ahr999_signal(values.get("ahr999"))),
("altcoin_season", _altseason_signal(values.get("altcoin_season_index"))),
("fear_greed", _fear_greed_signal(values.get("fear_greed"))),
("btc_dominance", _btc_dominance_signal(values.get("btc_dominance_pct"))),
("eth_btc", _eth_btc_signal(values.get("eth_btc_ratio"))),
("stablecoin_supply", _stablecoin_supply_signal(values.get("stablecoin_supply_usd"))),
("etf_flow", _etf_flow_signal(values.get("etf_flow_net_usd_1d"))),
("btc_open_interest", _open_interest_signal(values.get("btc_open_interest_usd"))),
]
alive = [(k, s) for k, s in pairs if s is not None]
if not alive:
return None, None
total_w = sum(WEIGHTS[k] for k, _ in alive)
if total_w <= 0:
return None, None
score = sum(WEIGHTS[k] * s for k, s in alive) / total_w * 100
if score >= 60: label = "BULL"
elif score >= 20: label = "BULLISH"
elif score > -20: label = "NEUTRAL"
elif score > -60: label = "BEARISH"
else: label = "BEAR"
return round(score, 1), label