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