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,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}
|
||||
Reference in New Issue
Block a user