Files
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

103 lines
3.7 KiB
Python

"""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}