The Fear & Greed index is a daily composite scaled 0–100, built from volatility, momentum, social signals, dominance and trend components. The temptation is to trade it directly: buy at 10, sell at 90. That rarely survives contact with real data — the index spends weeks in "extreme" territory during trending markets, and each reading is a lagging summary of moves that already happened.
The more defensible use is as a context layer. You fetch it once a day, combine it with market internals you already track, and let it bias — not dictate — your execution. In practice that means three things:
- A scheduled job that pulls the index plus internals and computes a composite score.
- A morning summary pushed to wherever you read it.
- A soft filter that suppresses new entries in the direction the crowd is already leaning, while leaving exits and existing positions alone.
This post walks through the pipeline, the scoring design, the logging that lets you test whether the filter deserves to exist, and the discipline of killing it when the data says so.
The data source and caching
The public Fear & Greed endpoints (crypto and equities variants) return a small JSON payload: a value, a classification string, and a timestamp. Two rules apply to any external sentiment source:
- Cache aggressively. The index updates once a day. Hitting the endpoint on every strategy tick is wasteful and gets you rate-limited.
- Never let a fetch failure break the trading loop. A missing sentiment value should degrade to neutral, not crash the process.
A simple file-backed cache with a TTL and a stale-fallback path covers both:
import json
import time
from datetime import datetime, timezone
from pathlib import Path
import requests
CACHE_PATH = Path("data/fng_cache.json")
TTL_SECONDS = 6 * 60 * 60
ENDPOINT = "https://api.alternative.me/fng/?limit=1"
def fetch_fng(timeout: float = 10.0) -> dict | None:
"""Return {'value': int, 'label': str, 'ts': iso} or None on failure."""
if CACHE_PATH.exists():
cached = json.loads(CACHE_PATH.read_text())
age = time.time() - cached["fetched_at"]
if age < TTL_SECONDS:
return cached["payload"]
try:
resp = requests.get(ENDPOINT, timeout=timeout)
resp.raise_for_status()
row = resp.json()["data"][0]
payload = {
"value": int(row["value"]),
"label": row["value_classification"],
"ts": datetime.fromtimestamp(
int(row["timestamp"]), tz=timezone.utc
).isoformat(),
}
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
CACHE_PATH.write_text(json.dumps(
{"fetched_at": time.time(), "payload": payload}
))
return payload
except Exception:
# Fall back to whatever is on disk, even if stale.
if CACHE_PATH.exists():
return json.loads(CACHE_PATH.read_text())["payload"]
return None
The stale fallback matters more than it looks. On a network blip you'd rather filter on yesterday's reading than skip the filter entirely and take a full-size entry into extreme greed.
Note that the timestamp in the payload is the index's own publication time, not your fetch time. Store both. You'll want the index timestamp when you later align sentiment with trade timestamps.
Designing the composite score
The raw index is one number. A composite that blends it with internals you trust is more stable and more explainable. The design goal is not to find the single best score — it's to build something whose components you can inspect when it misbehaves.
A reasonable structure:
- Base component (weight 0.5): the Fear & Greed value, rescaled to −1..+1.
- Market internals (weight 0.3): breadth-style measures, e.g. the fraction of your universe above a moving average, mapped to −1..+1.
- Trend/volatility regime (weight 0.2): realized volatility percentile inverted, so calm regimes score positive.
from dataclasses import dataclass
def rescale(value: float, lo: float, hi: float) -> float:
"""Map [lo, hi] to [-1, 1], clamped."""
x = (value - lo) / (hi - lo)
return max(-1.0, min(1.0, 2 * x - 1))
@dataclass
class SentimentScore:
composite: float # -1 .. +1
fng: float
internals: float
regime: float
label: str
def compute_composite(fng_value: float | None,
breadth: float | None,
vol_pct: float | None) -> SentimentScore:
fng = rescale(fng_value, 0, 100) if fng_value is not None else 0.0
internals = rescale(breadth, 0.0, 1.0) if breadth is not None else 0.0
# High vol percentile -> negative regime score.
regime = rescale(1.0 - vol_pct, 0.0, 1.0) if vol_pct is not None else 0.0
composite = 0.5 * fng + 0.3 * internals + 0.2 * regime
if composite >= 0.6:
label = "extreme_greed"
elif composite <= -0.6:
label = "extreme_fear"
else:
label = "neutral"
return SentimentScore(composite, fng, internals, regime, label)
Two properties make this worth the extra code. First, missing inputs collapse to neutral rather than skewing the score. Second, when the composite disagrees with your gut, you can print the three components and see which one moved. A single opaque number gives you nothing to debug.
The thresholds (±0.6) are arbitrary starting points. They should be tuned against your own trade history, not borrowed from a blog post — including this one.
The morning summary job
The scheduled job does the fetch, the composite, and the push. Keep it dumb: no order placement, no strategy state mutation. It writes a snapshot to disk and sends a message.
def morning_job() -> None:
fng = fetch_fng()
breadth = load_breadth() # your own internals
vol_pct = load_vol_percentile() # your own regime calc
score = compute_composite(
fng["value"] if fng else None, breadth, vol_pct
)
snapshot = {
"date": datetime.now(timezone.utc).date().isoformat(),
"fng_value": fng["value"] if fng else None,
"fng_ts": fng["ts"] if fng else None,
"composite": round(score.composite, 3),
"label": score.label,
"components": {
"fng": round(score.fng, 3),
"internals": round(score.internals, 3),
"regime": round(score.regime, 3),
},
}
append_snapshot(snapshot) # one JSON line per day
push_summary(snapshot) # Slack, Telegram, email — your call
append_snapshot should write JSON lines to a dated file. That file becomes the historical series you'll join against trades later. If you skip this step and only push the summary to chat, you've thrown away the dataset you need to evaluate the filter.
The filter as a soft constraint
The filter's job is narrow: block new entries in the crowded direction. In extreme greed, suppress new long entries. In extreme fear, suppress new shorts. Exits, stops, and position sizing are untouched — you never want sentiment to trap you in a losing position.
Wire it in as a gate in the signal path, not inside the strategy logic:
def allow_new_entry(side: str, score: SentimentScore) -> bool:
if side == "long" and score.label == "extreme_greed":
return False
if side == "short" and score.label == "extreme_fear":
return False
return True
"Soft" is the key word. A hard filter that flips entire strategies off based on a daily number will produce long flat periods that are hard to attribute. A gate on new entries only is easier to reason about and easier to A/B.
If you want a middle ground, scale size instead of blocking:
def size_multiplier(score: SentimentScore) -> float:
if score.label in ("extreme_greed", "extreme_fear"):
return 0.5
return 1.0
Both approaches are testable. Pick one, don't stack them, and log which one was active.
Logging sentiment with every trade
This is the part people skip, and it's the only part that decides whether the filter survives. Every trade record needs the sentiment state that was in force when the entry was decided.
def log_trade(trade: dict, score: SentimentScore) -> None:
record = {
**trade,
"sentiment_composite": round(score.composite, 3),
"sentiment_label": score.label,
"sentiment_fng": score.fng,
"sentiment_internals": score.internals,
"sentiment_regime": score.regime,
}
with open("data/trades.jsonl", "a") as f:
f.write(json.dumps(record) + "\n")
Log the components, not just the label. When you later ask "did the filter help?", you'll want to slice by which component was driving the extreme, because the index component and the internals component may behave very differently.
Once trades carry the sentiment snapshot, evaluation is a groupby:
import pandas as pd
def evaluate_filter(path: str = "data/trades.jsonl") -> pd.DataFrame:
df = pd.read_json(path, lines=True)
df["pnl"] = df["exit_price"] / df["entry_price"] - 1
df.loc[df["side"] == "short", "pnl"] *= -1
gated = df["sentiment_label"].isin(["extreme_greed", "extreme_fear"])
blocked = gated & (
((df["side"] == "long") & (df["sentiment_label"] == "extreme_greed")) |
((df["side"] == "short") & (df["sentiment_label"] == "extreme_fear"))
)
return df.groupby("sentiment_label").agg(
trades=("pnl", "size"),
avg_pnl=("pnl", "mean"),
hit_rate=("pnl", lambda s: (s > 0).mean()),
blocked_by_filter=("pnl", lambda s: blocked.loc[s.index].sum()),
)
The question you're answering is concrete: among trades taken in extreme greed, what was the average PnL of longs versus shorts? If longs in extreme greed performed the same as longs in neutral, the filter blocked trades for nothing. If they underperformed, it earned its place. Same logic for shorts in extreme fear.
Watch the sample size. Extreme readings cluster — you may get twenty relevant trades in a year. Treat any conclusion from that with suspicion until you have more, or pool across related markets.
Demoting a filter when the data disagrees
Filters accumulate. Someone adds a sentiment gate, someone else adds a volatility gate, and two years later nobody can say which one contributes. The discipline is to give every filter a review date and a demotion path.
A simple workflow:
-
Quarterly review. Re-run
evaluate_filterwith the latest trades. Compare gated versus ungated cohorts. - Define the kill criterion up front. Example: "If the blocked cohort's average PnL is within ±10 bps of the allowed cohort over at least 50 trades, demote to a size multiplier."
- Demote, don't delete. Move the rule from a hard gate to a size multiplier, or from a size multiplier to a logged-only annotation. Keep the logging running. You may promote it again in a different regime.
- Record the decision. A one-line note in the repo — date, criterion, outcome — saves the next person from re-litigating it.
The demotion step is where most systems rot. A filter that no longer earns its place but stays in the code silently shrinks your opportunity set. Keeping the sentiment fields in the trade log means demotion costs you nothing: the data keeps accumulating while the constraint loosens.
One more habit worth building: log the sentiment snapshot even when the filter is in "annotation only" mode. The cheapest way to evaluate a hypothetical filter is to run it in shadow for a quarter and compare what it would have blocked against what actually happened.
Wrapping up
The Fear & Greed index is a decent input and a poor signal. Used as a context layer — cached, composited with internals, applied as a soft gate on new entries, and logged on every trade — it becomes something you can measure. The measurement is the point. A filter that can't be evaluated is just an opinion with a cron job attached.
More notes like this ship every week on this site.
Daily Picks
The following pairs are selected from the multi-timeframe trend scanner (Gate.io futures) and are for technical-analysis study only — not investment advice.
Data updated: 2026-11-04 08:46:36
Long
| Pair | Signal | Price | Take Profit | Stop Loss | R/R |
|---|---|---|---|---|---|
| AMD | 高质量标的做多 | $507.55 | $528.8671 | $496.8915 | 1:2.0 |
1 picks selected. Scanner runs every 15 minutes.
Top comments (0)