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
+86
View File
@@ -0,0 +1,86 @@
"""Macro indicators API.
GET /api/macro/snapshot
Latest macro snapshot (today's row, or the most recent available).
GET /api/macro/history?days=30
Time series of every indicator over the last N days. Used by the
sparklines on the BTC page macro panel.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import MacroSnapshot
router = APIRouter(prefix="/macro", tags=["macro"])
def _row_to_dict(r: MacroSnapshot) -> dict:
"""Snapshot row → JSON-friendly dict, in the canonical indicator order
the frontend lists them in.
Order rule (see UI mock-up in BtcPageClient MacroPanel):
1. AHR999
2. Altcoin Season Index
3. Fear & Greed
4. BTC Dominance
5. ETH/BTC Ratio
6. Stablecoin Total Supply
7. ETF Net Flow (1d)
8. BTC Open Interest
"""
return {
"date": r.snapshot_date.isoformat() if r.snapshot_date else None,
"captured_at": r.captured_at.replace(tzinfo=timezone.utc).isoformat() if r.captured_at else None,
"indicators": {
"ahr999": r.ahr999,
"altcoin_season_index": r.altcoin_season_index,
"fear_greed": r.fear_greed,
"fear_greed_label": r.fear_greed_label,
"btc_dominance_pct": r.btc_dominance_pct,
"eth_btc_ratio": r.eth_btc_ratio,
"stablecoin_supply_usd": r.stablecoin_supply_usd,
"etf_flow_net_usd_1d": r.etf_flow_net_usd_1d,
"btc_open_interest_usd": r.btc_open_interest_usd,
},
"composite_score": r.composite_score,
"regime_label": r.regime_label,
}
@router.get("/snapshot")
async def get_snapshot(db: AsyncSession = Depends(get_db)) -> dict:
"""Latest macro snapshot. May be null if poll hasn't run yet."""
row = (await db.execute(
select(MacroSnapshot).order_by(MacroSnapshot.snapshot_date.desc()).limit(1)
)).scalar_one_or_none()
if not row:
return {"ok": False, "error": "no snapshots yet — poll has not run"}
return {"ok": True, **_row_to_dict(row)}
@router.get("/history")
async def get_history(
days: int = Query(default=30, ge=1, le=365),
db: AsyncSession = Depends(get_db),
) -> dict:
"""Time series across the last N days — for the panel sparklines."""
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).date()
rows = (await db.execute(
select(MacroSnapshot)
.where(MacroSnapshot.snapshot_date >= cutoff)
.order_by(MacroSnapshot.snapshot_date.asc())
)).scalars().all()
return {
"ok": True,
"days": days,
"count": len(rows),
"items": [_row_to_dict(r) for r in rows],
}