4442e97f28
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>
87 lines
2.9 KiB
Python
87 lines
2.9 KiB
Python
"""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],
|
|
}
|