Files
trumpsignal-backend/app/services/macro/scoring.py
T
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

150 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.451.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