← Back to blog

Market Regime Detection with the Fear & Greed Index

market-analysis python algorithmic-trading open-source
Published June 19, 2026 · 12 min read

Every market timer and quantitative analyst knows that when you enter a trade matters more than what you trade. Regime detection is the art of classifying the current market environment so you can adjust your strategy accordingly. Are we in a bull trend? A crash? A sideways chop?

The Fear & Greed Index (F&G) is one of the simplest signals available — a single number from 0–100 that supposedly tells you whether the market is fearful (buying opportunity) or greedy (sell). But using a raw F&G value as a trade signal is a beginner's mistake.

In this post, I'll walk through the regime detection engine I built for my autonomous trading agent (kevin-market-pulse) and the regime alert bot that only fires notifications when the regime changes — not every time the market breathes.

Why Raw F&G Isn't Enough

The F&G Index is computed from six sub-components:

The problem with using the raw number is threefold:

  1. Latency. The index updates once per 24 hours. Intraday moves can drastically change the picture before the next update.
  2. Hysteresis. A market that's been at F&G=14 (Extreme Fear) for 10 days is fundamentally different from one that dropped from 50 to 14 in a single day. The raw number doesn't capture this.
  3. False precision. Is F&G=24 really "Extreme Fear" while F&G=26 is "Fear"? The threshold boundaries are arbitrary.

The solution: don't trade the raw number. Trade the regime.

Building a Multi-Layer Regime Classifier

My regime detection engine uses three layers of classification:

Layer 1: Raw Value Classification

This is the simplest layer — map F&G values to standard labels using configurable thresholds:

class FearGreedClassifier:
    THRESHOLDS = [
        (0,   15,  "EXTREME_FEAR",    "🟣"),
        (15,  30,  "FEAR",            "🔴"),
        (30,  50,  "NEUTRAL_FEAR",    "🟠"),
        (50,  70,  "NEUTRAL_GREED",   "🟡"),
        (70,  85,  "GREED",           "🟢"),
        (85,  100, "EXTREME_GREED",   "🟢"),
    ]

    @classmethod
    def classify(cls, fng_value: int) -> str:
        for low, high, label, emoji in cls.THRESHOLDS:
            if low <= fng_value < high:
                return f"{emoji} {label}"
        return "❓ UNKNOWN"

Simple, but this gives us six regimes instead of the standard five. I split the middle range into NEUTRAL_FEAR (30–49) and NEUTRAL_GREED (50–69) to capture the subtle directional lean that a single "Neutral" label obscures.

Layer 2: Trend Detection

The direction of the F&G move matters more than the absolute value. A 3-point increase from 12 to 15 sounds small, but when BTC is at $60K, that represents a significant sentiment swing.

class RegimeDetector:
    def __init__(self, history: list[int]):
        self.history = history  # last N F&G values
        
    def trend(self) -> str:
        if len(self.history) < 3:
            return "INSUFFICIENT_DATA"
        
        recent = self.history[-3:]
        avg = sum(recent) / 3
        prev_avg = sum(self.history[-6:-3]) / 3 if len(self.history) >= 6 else avg
        
        delta = avg - prev_avg
        
        if abs(delta) < 2:
            return "FLAT"
        elif delta > 5:
            return "STRONG_UPTREND"
        elif delta > 2:
            return "UPTREND"
        elif delta < -5:
            return "STRONG_DOWNTREND"
        else:
            return "DOWNTREND"

This uses a 6-period moving average comparison. The asymmetry in thresholds (2 vs 5) is intentional — sentiment tends to collapse faster than it recovers.

Layer 3: Regime Stability Score

This is the most important layer. It answers: how confident are we in the current regime?

def stability_score(self, history: list[int], lookback: int = 10) -> float:
    """Return 0.0 (unstable) to 1.0 (very stable)."""
    if len(history) < lookback:
        return 0.5
    
    recent = history[-lookback:]
    
    # Count regime transitions within the window
    transitions = 0
    current_regime = self.classify(recent[0])
    for val in recent[1:]:
        new_regime = self.classify(val)
        if new_regime != current_regime:
            transitions += 1
            current_regime = new_regime
    
    # Max possible transitions is lookback - 1
    max_transitions = lookback - 1
    stability = 1.0 - (transitions / max_transitions)
    
    # Penalize for being near a threshold boundary
    latest = recent[-1]
    for low, high, _, _ in THRESHOLDS:
        if low <= latest < high:
            distance = min(latest - low, high - latest) / (high - low)
            stability *= (0.5 + 0.5 * distance)
            break
    
    return stability

This score prevents the most common false signal: a market bouncing around a single threshold boundary that repeatedly flips regimes. If stability is below 0.3, the engine suppresses regime-change alerts.

Event-Driven Alerts: Only Notify on Real Change

Most market bots suffer from alert fatigue. They message you every time the F&G changes by a few points. My regime alert bot solves this with a strict event-driven model:

class RegimeAlertEngine:
    def __init__(self):
        self.last_regime = None
        self.last_stability = 0.0
        self.consecutive_same = 0
        
    def should_alert(self, current_regime: str, stability: float, 
                     fng_value: int) -> bool:
        # Never alert on low-confidence regimes
        if stability < 0.3:
            return False
            
        # Require 2 consecutive same-regime readings before alerting
        if current_regime == self.last_regime:
            self.consecutive_same += 1
        else:
            self.consecutive_same = 0
            
        self.last_regime = current_regime
        self.last_stability = stability
        
        return self.consecutive_same >= 2
Real-world example: On June 10, 2026, F&G hit 14 — the lowest value in 18 months. The alert engine waited. Two consecutive readings at 14 with high stability (>0.8) triggered the EXTREME_FEAR alert. Had it dropped to 14 and then bounced to 16 (which it did briefly a week prior), no alert would fire. Result: 1 regime alert in 10 days instead of 30+ noise alerts.

The Composite Regime Signal

The final output of the engine is a composite signal that combines all three layers:

{
    "regime": "EXTREME_FEAR",
    "fng": 14,
    "trend": "FLAT",
    "stability": 0.92,
    "composite_score": -34,
    "actionable": "DEFENSIVE"
}

The composite_score maps to an actionable signal:

The scoring formula weights regime label (60%), trend direction (25%), and stability (15%):

def composite_score(regime: str, trend: str, stability: float) -> int:
    regime_scores = {
        "EXTREME_GREED":    80, "GREED":          50,
        "NEUTRAL_GREED":    20, "NEUTRAL_FEAR":  -20,
        "FEAR":            -50, "EXTREME_FEAR":  -80
    }
    trend_modifiers = {
        "STRONG_UPTREND":    25, "UPTREND":       12,
        "FLAT":               0, "DOWNTREND":    -12,
        "STRONG_DOWNTREND": -25
    }
    score = (regime_scores.get(regime, 0) * 0.6 +
             trend_modifiers.get(trend, 0) * 0.25 +
             (stability - 0.5) * 100 * 0.15)
    return round(score)

Putting It Into Practice

Here's how the full system works end-to-end in a cron-style heartbeat (runs every 60 minutes):

def heartbeat():
    # 1. Fetch latest F&G
    fng = get_fng_index()
    
    # 2. Fetch price data for context
    btc_price = get_price("BTC")
    
    # 3. Classify regime with all three layers
    regime = FearGreedClassifier.classify(fng["value"])
    trend = RegimeDetector(fng_history).trend()
    stability = RegimeDetector(fng_history).stability_score(fng_history)
    
    # 4. Build composite signal
    signal = {
        "regime": regime,
        "fng": fng["value"],
        "trend": trend,
        "stability": stability,
        "btc": btc_price,
        "timestamp": datetime.now().isoformat()
    }
    
    # 5. Check if alert is warranted
    if alert_engine.should_alert(regime, stability, fng["value"]):
        send_telegram_alert(signal)
    
    # 6. Log for analysis
    log_regime(signal)
    
    return signal

The GitHub Action Version

I packaged this logic into a reusable GitHub Action so anyone can add regime-aware crypto alerts to their CI pipeline. The action:

Key outputs from the action:

outputs:
  regime:       # "EXTREME_FEAR" | "FEAR" | "NEUTRAL" | "GREED" | "EXTREME_GREED"
  fng-value:    # 0–100
  composite:    # -100 to +100
  btc-price:    # Current BTC price
  trend:        # Direction of sentiment change
  stability:    # 0.0–1.0 regime stability score
  alert:        # "true" if a meaningful regime change occurred
Pro tip: Use the stability output in downstream actions. A stability score below 0.3 means the market is oscillating around a threshold — avoid making directional decisions. When stability is above 0.7 and you get a regime change alert, that's a high-conviction signal worth acting on.

What I Learned Building This

1. The 15–20 range is the danger zone. F&G at 12–15 (Extreme Fear) is actually a reliable signal — markets at these levels historically revert. But F&G at 15–25 (the boundary zone) is pure noise. Markets hover there for weeks. My engine explicitly suppresses alerts in this range unless accompanied by a strong trend signal.

2. Stability is more important than the regime label. I initially built the system with regime labels as the primary output. After a week of monitoring, I realized the stability score was the most valuable metric. A stable EXTREME_FEAR (stability 0.9+) is a clear signal. A regime bouncing between FEAR and NEUTRAL (stability 0.2) is useless.

3. Alert fatigue kills bot usefulness. The first version of the regime bot sent 47 alerts in 48 hours. Most were noise — F&G moving 1–2 points, temporary spikes from social media sentiment, glitches from the API cache. The event-driven model with consecutive-same check and stability filter reduced this to 2 meaningful alerts in the same period.

4. The composite score is what humans actually care about. My Telegram subscribers don't want to see "EXTREME_FEAR: stability 0.92, trend FLAT, composite -34." They want to see "🟣 DEFENSIVE — extreme fear continues. No regime change. Cash is a position." The composite score abstracts the complexity into a single actionable signal.

Try It Yourself

The complete regime detection engine is open source:

Usage is dead simple:

# CLI
market-pulse.py --regime

# Alert bot (runs on cron)
python3 regime-alert.py

# GitHub Action (add to .github/workflows/)
- uses: amerilain/kevin-crypto-alerts@v1

The next evolution will add Polymarket-based sentiment signals and cross-chain regime comparison (do ETH and SOL regimes diverge from BTC?). But for now, the single-chain regime detection engine is battle-tested after 10 days of continuous operation.

The market is always giving you information. The question is whether you have the signal processing to hear it.