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:
k
2026-05-26 01:04:53 +08:00
parent 5fb1d52026
commit 4442e97f28
12 changed files with 1461 additions and 1 deletions
+33 -1
View File
@@ -1,9 +1,10 @@
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from typing import List, Optional
from sqlalchemy import (
BigInteger,
Boolean,
Date,
DateTime,
Float,
ForeignKey,
@@ -412,3 +413,34 @@ class TelegramBinding(Base):
last_alert_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
total_alerts_sent: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
total_alerts_failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
class MacroSnapshot(Base):
"""Daily snapshot of all macro indicators surfaced on the BTC page.
One row per calendar date. Every indicator column is nullable — any single
upstream API failing must not block the rest from being recorded.
Composite score is computed at insert time by app/services/macro/scoring.py
against whichever indicators successfully fetched (the formula degrades
gracefully if a few are missing).
"""
__tablename__ = "macro_snapshots"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
snapshot_date: Mapped[date] = mapped_column(Date, nullable=False, unique=True, index=True)
captured_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
ahr999: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
altcoin_season_index: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
fear_greed: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
fear_greed_label: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
btc_dominance_pct: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
eth_btc_ratio: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
stablecoin_supply_usd: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
etf_flow_net_usd_1d: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
btc_open_interest_usd: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
composite_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
regime_label: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
raw_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)