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>
103 lines
3.7 KiB
Python
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}
|