情绪是市场的短期定价因子之一。价格可以被操纵,成交量可以被对倒,但情绪指标往往能提供另一维度的参考。这篇文章记录我在自己的量化系统里实现市场情绪分析模块的过程,包括 F&G 指数获取、综合情绪分数计算、极端值信号,以及定时推送。
一、为什么要做情绪分析
做趋势策略的时候,最难受的不是震荡,而是趋势末端的情绪亢奋期。价格连续拉升,系统不断给出买入信号,但实际风险在快速积累。纯粹依赖价格和成交量的策略,很难识别"市场已经过热"这件事。
情绪指标的价值在于:它提供的是与价格相关性较低的信息。当综合情绪分数进入极端区域,可以作为仓位管理的辅助因子------不是用来反向开单,而是用来调整仓位系数。
我的实现思路:
- 抓取外部情绪数据(以 Fear & Greed Index 为主)
- 计算本地可量化的情绪代理指标(波动率、涨跌比、资金费率等)
- 加权合成一个 0-100 的综合情绪分数
- 在极端区间产生信号
- 定时推送到 Telegram / 邮件
二、F&G 指数数据获取
Alternative.me 提供的 F&G 数据是最常用的免费源,覆盖加密货币市场。接口简单,返回 JSON。
python
import requests
import pandas as pd
from datetime import datetime
FG_URL = "https://api.alternative.me/fng/"
def fetch_fear_greed(limit: int = 30) -> pd.DataFrame:
"""
获取恐惧贪婪指数历史数据
limit: 返回最近 N 天的数据
"""
params = {"limit": limit, "format": "json"}
resp = requests.get(FG_URL, params=params, timeout=10)
resp.raise_for_status()
data = resp.json()["data"]
df = pd.DataFrame(data)
df["timestamp"] = pd.to_datetime(df["timestamp"].astype(int), unit="s")
df["value"] = df["value"].astype(int)
df = df[["timestamp", "value", "value_classification"]]
df = df.sort_values("timestamp").reset_index(drop=True)
return df
if __name__ == "__main__":
fg = fetch_fear_greed(limit=10)
print(fg.tail())
返回字段里 value_classification 是官方给的文字标签(Extreme Fear / Fear / Neutral / Greed / Extreme Greed),可以直接用,也可以自己按区间划分。
注意这个接口偶尔会超时,生产环境建议加一层重试和本地缓存:
python
import time
import json
from pathlib import Path
CACHE_FILE = Path("fg_cache.json")
def fetch_fg_with_cache(limit: int = 30) -> pd.DataFrame:
try:
df = fetch_fear_greed(limit)
df.to_json(CACHE_FILE, orient="records", date_format="iso")
return df
except Exception as e:
print(f"[warn] fetch failed: {e}, fallback to cache")
if CACHE_FILE.exists():
return pd.read_json(CACHE_FILE)
raise
三、综合情绪分数计算
F&G 只有一个维度,而且更新频率是日级。要构建更有弹性的情绪分数,我会把几个本地可计算的指标合成进来。
3.1 情绪代理指标
选取四个维度:
| 指标 | 含义 | 情绪方向 |
|---|---|---|
| 波动率分位 | 近期 ATR / 长期 ATR | 高波动 → 情绪极端 |
| 涨跌比 | 近 N 根 K 线上涨占比 | 越高越贪婪 |
| 动量偏离 | 当前价相对 MA20 的偏离 | 偏离越大越亢奋 |
| 资金费率 | 永续合约资金费率 | 正费率越高越贪婪 |
3.2 归一化到 0-100
每个指标先做 min-max 或分位数归一化,再加权。
python
import numpy as np
def normalize_series(s: pd.Series, window: int = 90) -> pd.Series:
"""滚动 min-max 归一化到 0-100"""
roll_min = s.rolling(window, min_periods=10).min()
roll_max = s.rolling(window, min_periods=10).max()
rng = (roll_max - roll_min).replace(0, np.nan)
return ((s - roll_min) / rng * 100).clip(0, 100)
def compute_volatility_score(df: pd.DataFrame, window: int = 90) -> pd.Series:
"""ATR 比率 -> 情绪分"""
high, low, close = df["high"], df["low"], df["close"]
tr = pd.concat([
high - low,
(high - close.shift()).abs(),
(low - close.shift()).abs()
], axis=1).max(axis=1)
atr_short = tr.rolling(14).mean()
atr_long = tr.rolling(60).mean()
ratio = atr_short / atr_long
return normalize_series(ratio, window)
def compute_breadth_score(df: pd.DataFrame, n: int = 20, window: int = 90) -> pd.Series:
"""近 n 根 K 线上涨占比"""
up = (df["close"].diff() > 0).astype(int)
breadth = up.rolling(n).mean() * 100
return normalize_series(breadth, window)
def compute_momentum_score(df: pd.DataFrame, window: int = 90) -> pd.Series:
"""价格相对 MA20 的偏离"""
ma20 = df["close"].rolling(20).mean()
dev = (df["close"] - ma20) / ma20
return normalize_series(dev, window)
3.3 合成综合分数
权重我给的是经验值:波动率 0.25、涨跌比 0.25、动量 0.3、F&G 0.2。可以根据自己的策略回测调整。
python
def compute_composite_score(df: pd.DataFrame, fg_df: pd.DataFrame) -> pd.DataFrame:
"""
df: 含 open/high/low/close 的日线数据,index 为 datetime
fg_df: F&G 数据,含 timestamp / value
"""
out = pd.DataFrame(index=df.index)
out["vol_score"] = compute_volatility_score(df)
out["breadth_score"] = compute_breadth_score(df)
out["mom_score"] = compute_momentum_score(df)
# 对齐 F&G 数据
fg = fg_df.set_index("timestamp")["value"].rename("fg_score")
out = out.join(fg, how="left")
out["fg_score"] = out["fg_score"].ffill()
weights = {
"vol_score": 0.25,
"breadth_score": 0.25,
"mom_score": 0.30,
"fg_score": 0.20,
}
out["composite"] = sum(out[k] * w for k, w in weights.items())
out["composite"] = out["composite"].clip(0, 100)
return out
跑完之后,composite 就是 0-100 的综合情绪分数。低于 20 视为极度恐慌,高于 80 视为极度贪婪。
四、极端值作为交易参考
情绪分数本身不是开仓信号,它是仓位调节器。我的做法是:
- 综合分数 > 80:多头仓位系数 × 0.5,做空信号权重 × 1.2
- 综合分数 < 20:多头仓位系数 × 1.2,做空信号权重 × 0.5
- 其他区间:系数 1.0
python
def sentiment_position_factor(score: float) -> float:
"""根据情绪分数返回仓位系数"""
if score >= 80:
return 0.5
if score >= 65:
return 0.8
if score <= 20:
return 1.2
if score <= 35:
return 1.1
return 1.0
def detect_extreme(score_series: pd.Series, high: float = 80, low: float = 20) -> pd.Series:
"""标记情绪极端点"""
signal = pd.Series(0, index=score_series.index)
signal[score_series >= high] = 1 # 贪婪
signal[score_series <= low] = -1 # 恐慌
return signal
更稳妥的用法是结合极值持续时间。单日进入 80 以上意义不大,连续 3 天以上进入极值区,才说明情绪确实被推到了极端。可以加一个滚动窗口:
python
def persistent_extreme(score_series: pd.Series, days: int = 3) -> pd.Series:
high_flag = (score_series >= 80).rolling(days).sum() == days
low_flag = (score_series <= 20).rolling(days).sum() == days
out = pd.Series(0, index=score_series.index)
out[high_flag] = 1
out[low_flag] = -1
return out
五、定时推送情绪报告
用 schedule 或者 APScheduler 都可以,这里用最轻量的 schedule。推送渠道以 Telegram 为例,邮件类似。
python
import schedule
import time
import requests
TG_TOKEN = "YOUR_BOT_TOKEN"
TG_CHAT_ID = "YOUR_CHAT_ID"
def send_telegram(text: str):
url = f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage"
payload = {"chat_id": TG_CHAT_ID, "text": text, "parse_mode": "Markdown"}
try:
requests.post(url, json=payload, timeout=10)
except Exception as e:
print(f"[error] telegram push failed: {e}")
def build_report(df: pd.DataFrame, scores: pd.DataFrame) -> str:
last = scores.iloc[-1]
prev = scores.iloc[-2]
delta = last["composite"] - prev["composite"]
fg_value = int(last["fg_score"]) if not pd.isna(last["fg_score"]) else -1
label = "极度贪婪" if last["composite"] >= 80 else \
"贪婪" if last["composite"] >= 65 else \
"极度恐慌" if last["composite"] <= 20 else \
"恐慌" if last["composite"] <= 35 else "中性"
arrow = "↑" if delta > 0 else "↓" if delta < 0 else "→"
report = (
f"*市场情绪日报* {last.name.date()}\n"
f"综合分数: `{last['composite']:.1f}` {arrow} ({delta:+.1f})\n"
f"状态: *{label}*\n"
f"F&G: `{fg_value}`\n"
f"波动率分: `{last['vol_score']:.1f}`\n"
f"涨跌比分: `{last['breadth_score']:.1f}`\n"
f"动量分: `{last['mom_score']:.1f}`\n"
f"仓位系数: `{sentiment_position_factor(last['composite']):.2f}`"
)
return report
def job():
fg_df = fetch_fg_with_cache(limit=180)
# df 从你自己的行情源获取,这里省略
# df = load_ohlcv("BTCUSDT", "1d", limit=500)
scores = compute_composite_score(df, fg_df)
report = build_report(df, scores)
send_telegram(report)
if __name__ == "__main__":
schedule.every().day.at("08:05").do(job)
while True:
schedule.run_pending()
time.sleep(30)
推送时间建议放在你所在时区的早晨,数据源刚完成更新,同时你也方便看一眼再决定当天的操作。
六、几个实现细节
数据对齐:F&G 是 UTC 时间戳,本地 K 线可能是 UTC+8,join 之前统一时区,否则会出现 NaN。
缺失值处理 :F&G 接口偶尔抽风,ffill() 是必须的,但不要 bfill,那会引入未来数据。
权重不要过拟合:我给的权重是拍的,但不要为了回测好看去优化它。情绪分数的意义是稳健,不是精确。
分数不要单独用:单独看情绪分数会经常误判,它必须和价格结构、趋势状态一起判断。我的系统里它只占仓位系数的 20% 权重。
日志留存:每次计算完把分数写进数据库或 CSV,方便事后复盘哪次情绪信号有效、哪次失效。
七、小结
这套实现的代码量不大,但补上了纯价格策略的一个盲区。核心逻辑是:
- 用 F&G 提供外部情绪锚点
- 用本地指标补充高频信息
- 加权合成 0-100 分数
- 极端值 + 持续性判断产生信号
- 定时推送,人工复核
代码可以直接跑,替换数据源和推送渠道就能接入自己的系统。后续可以考虑把情绪分数做成因子,回测它和未来 N 日收益的相关性,验证权重是否合理。
更多关于 Python 量化实战、自动化交易系统的内容,请关注本站。
</div>
<div class="post-tags"><span class="tag-badge">市场情绪</span><span class="tag-badge">F&G指标</span><span class="tag-badge">情绪分析</span><span class="tag-badge">Python</span></div>
<div class="post-footer">
<span>作者: mattleeee</span>
<span><a href="https://github.com/mattleeee">GitHub</a></span>