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,149 @@
|
||||
"""Composite "regime" score over the macro indicators.
|
||||
|
||||
Goal: one -100..+100 number that says whether the overall macro setup tilts
|
||||
risk-on (positive) or risk-off (negative). Used purely as advisory display on
|
||||
the BTC page — does NOT (yet) feed sizing or trade decisions.
|
||||
|
||||
Design choices:
|
||||
* Each indicator contributes a per-indicator signal in [-1, +1].
|
||||
* Weights sum to 1.0 across the indicators we got. Missing indicators are
|
||||
excluded and the remaining weights are renormalised — so a single dead
|
||||
API doesn't drag the whole score toward zero.
|
||||
* The contrarian indicators (fear/greed, AHR999, funding) are intentionally
|
||||
inverted: extreme fear / cheap AHR999 / extreme funding all add positive
|
||||
score (buy-the-fear logic).
|
||||
|
||||
The weights here are seeded by hand from rough conviction. Re-tune after a
|
||||
few weeks of live data — see `composite_score` history vs realised forward
|
||||
returns.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# (weight, signal_function) per indicator. signal_function takes the raw value
|
||||
# and returns a number in [-1, +1].
|
||||
#
|
||||
# Inputs may be None if the upstream fetch failed; the orchestrator filters
|
||||
# them out and renormalises before summing.
|
||||
|
||||
|
||||
def _ahr999_signal(v: Optional[float]) -> Optional[float]:
|
||||
"""< 0.45 cheap → +1; 0.45–1.2 neutral; > 1.2 expensive → -1."""
|
||||
if v is None: return None
|
||||
if v < 0.45: return 1.0
|
||||
if v > 1.2: return -1.0
|
||||
# Linear interpolation between 0.45 and 1.2 → +1 to -1.
|
||||
return 1.0 - 2 * ((v - 0.45) / (1.2 - 0.45))
|
||||
|
||||
|
||||
def _altseason_signal(v: Optional[float]) -> Optional[float]:
|
||||
"""High altseason index = risk-on (+1), low = BTC season (defensive but
|
||||
not bearish, so a mild positive)."""
|
||||
if v is None: return None
|
||||
if v >= 75: return 0.7 # altseason — generally bullish risk
|
||||
if v <= 25: return 0.3 # BTC-only — defensive but not a bear signal
|
||||
return (v - 50) / 50 # linear, -0.5 to +0.5
|
||||
|
||||
|
||||
def _fear_greed_signal(v: Optional[int]) -> Optional[float]:
|
||||
"""Contrarian: extreme fear → buy (+1), extreme greed → sell (-1)."""
|
||||
if v is None: return None
|
||||
# Map 0..100 → +1..-1 (inverted, contrarian).
|
||||
return (50 - v) / 50
|
||||
|
||||
|
||||
def _btc_dominance_signal(v: Optional[float]) -> Optional[float]:
|
||||
"""Hard to read in isolation — only inform on extremes.
|
||||
Very high dominance often signals fear (risk-off into BTC) → mild bearish.
|
||||
Very low = altseason froth → also mild bearish (cycle late). Mid = neutral."""
|
||||
if v is None: return None
|
||||
if v >= 60: return -0.3
|
||||
if v <= 40: return -0.3
|
||||
return 0.0
|
||||
|
||||
|
||||
def _eth_btc_signal(v: Optional[float]) -> Optional[float]:
|
||||
"""Rising ETH/BTC = risk-on. No persistent absolute level matters; this is
|
||||
really a trend indicator. We approximate with absolute thresholds for the
|
||||
current cycle (2025-2026): > 0.04 risk-on, < 0.025 risk-off."""
|
||||
if v is None: return None
|
||||
if v >= 0.04: return 0.7
|
||||
if v <= 0.025: return -0.7
|
||||
return (v - 0.0325) / 0.0075 * 0.7 # linear in the middle
|
||||
|
||||
|
||||
def _stablecoin_supply_signal(v: Optional[float]) -> Optional[float]:
|
||||
"""Absolute supply tells us little day-over-day; we need the delta. Since
|
||||
this scorer sees only the snapshot, we treat presence as 0 and let the
|
||||
visual chart show the trend. Returns 0 if we have any value at all."""
|
||||
if v is None: return None
|
||||
return 0.0 # contribution = 0 until we wire in a trend lookup
|
||||
|
||||
|
||||
def _etf_flow_signal(v: Optional[float]) -> Optional[float]:
|
||||
"""Net inflow = institutional bid → +1, outflow → -1. Scale by magnitude."""
|
||||
if v is None: return None
|
||||
# Daily prints over $200M are notable; over $500M unusually large.
|
||||
abs_v = abs(v)
|
||||
sign = 1 if v > 0 else (-1 if v < 0 else 0)
|
||||
if abs_v >= 500_000_000: return 1.0 * sign
|
||||
if abs_v >= 200_000_000: return 0.7 * sign
|
||||
if abs_v >= 50_000_000: return 0.4 * sign
|
||||
return 0.1 * sign
|
||||
|
||||
|
||||
def _open_interest_signal(v: Optional[float]) -> Optional[float]:
|
||||
"""OI in isolation doesn't tell us direction — we'd need OI vs price
|
||||
correlation. Until we have a trend window, contribute 0."""
|
||||
if v is None: return None
|
||||
return 0.0
|
||||
|
||||
|
||||
# Weights (sum to 1.0 across all). When an indicator is missing, we drop its
|
||||
# weight and renormalise the rest.
|
||||
WEIGHTS = {
|
||||
"ahr999": 0.20,
|
||||
"altcoin_season": 0.10,
|
||||
"fear_greed": 0.20,
|
||||
"btc_dominance": 0.05,
|
||||
"eth_btc": 0.15,
|
||||
"stablecoin_supply": 0.05,
|
||||
"etf_flow": 0.20,
|
||||
"btc_open_interest": 0.05,
|
||||
}
|
||||
|
||||
|
||||
def compute_composite(values: dict) -> tuple[Optional[float], Optional[str]]:
|
||||
"""Return (score in [-100, +100], regime_label) or (None, None) if there
|
||||
isn't enough data to score.
|
||||
|
||||
`values` keys must match WEIGHTS keys (without "_signal" suffix).
|
||||
"""
|
||||
pairs = [
|
||||
("ahr999", _ahr999_signal(values.get("ahr999"))),
|
||||
("altcoin_season", _altseason_signal(values.get("altcoin_season_index"))),
|
||||
("fear_greed", _fear_greed_signal(values.get("fear_greed"))),
|
||||
("btc_dominance", _btc_dominance_signal(values.get("btc_dominance_pct"))),
|
||||
("eth_btc", _eth_btc_signal(values.get("eth_btc_ratio"))),
|
||||
("stablecoin_supply", _stablecoin_supply_signal(values.get("stablecoin_supply_usd"))),
|
||||
("etf_flow", _etf_flow_signal(values.get("etf_flow_net_usd_1d"))),
|
||||
("btc_open_interest", _open_interest_signal(values.get("btc_open_interest_usd"))),
|
||||
]
|
||||
alive = [(k, s) for k, s in pairs if s is not None]
|
||||
if not alive:
|
||||
return None, None
|
||||
|
||||
total_w = sum(WEIGHTS[k] for k, _ in alive)
|
||||
if total_w <= 0:
|
||||
return None, None
|
||||
|
||||
score = sum(WEIGHTS[k] * s for k, s in alive) / total_w * 100
|
||||
|
||||
if score >= 60: label = "BULL"
|
||||
elif score >= 20: label = "BULLISH"
|
||||
elif score > -20: label = "NEUTRAL"
|
||||
elif score > -60: label = "BEARISH"
|
||||
else: label = "BEAR"
|
||||
|
||||
return round(score, 1), label
|
||||
Reference in New Issue
Block a user