【AI+量化实战 #03】散户焦虑还是看好?用Python给互动易问答做情绪测温
系列进度:AI+量化实战 #03 / 共 12 篇
痛点开场
你有没有这种感觉:某只股票突然跌了,你去互动易平台一搜,发现散户一个月前就在追问"公司是不是要暴雷"——信息就在那里,但你没看到。
互动易是A股散户向上市公司提问的官方渠道。每天都有人问业绩、问减持、问分红、问"什么时候涨"。这些提问本身就是一种情绪信号:
- 大量追问"跌""亏损""质押"——焦虑在蔓延;
- 集中问"分红""回购""增长"——信心在积聚。
问题是,互动易上几千只股票、每天上百条问答,你不可能手动翻。能不能用 Python 批量拉取、自动打标、聚合成一只股票的情绪温度计?
能。今天我们就做这件事。
本文你将得到什么
stock_interactiveqa接口的真实字段说明与调用方式。- 一个可复用的问答情绪打分函数(关键词版,零依赖)。
- 一段已跑通的代码:批量拉互动易 → 打情绪标签 → 聚合温度计 → 对比股价收益。
- 关键词法的局限与升级到 LLM 的路线。
一、互动易数据从哪来
stock_interactiveqa 接口返回单只股票的互动易问答历史。核心字段:
| 字段 | 含义 |
|---|---|
q |
散户提问内容 |
qt |
提问日期(YYYY-MM-DD) |
a |
公司回答内容 |
at |
回答日期 |
id |
记录 ID |
调用示例:
import mairui
import os
api = mairui.Client("<你的证书>") # 演示证书,请替换成你自己的正式证书
qa = api.stock_interactiveqa("600519", lt=10) # 最近 10 条
for item in qa:
print(item["qt"], item["q"][:50])
互动易的价值在于:提问是散户自发行为,反映真实关切。不像公告是公司主动披露(经过包装),互动易上的问题往往更直接——"为什么跌""会不会退市""什么时候分红"。
二、情绪打分:先跑通,再迭代
和公告情绪分析一样,先用关键词把链路跑通。我们定义两组词表:
# 焦虑/负面词
ANXIETY_WORDS = (
"跌", "亏损", "减持", "退市", "暴雷", "违规", "处罚", "问询",
"风险", "担忧", "下滑", "停牌", "质押", "爆仓", "诉讼", "索赔",
"被套", "套牢", "割肉", "补仓", "缩量", "破发", "破净", "商誉",
"坏账", "计提", "滞涨", "跳水", "闪崩", "操纵", "调查",
)
# 乐观/正面词
OPTIMISM_WORDS = (
"增长", "回购", "增持", "分红", "利好", "前景", "突破", "创新",
"布局", "扩张", "合作", "订单", "中标", "预增", "预盈", "送转",
"龙头", "低估", "机会", "潜力", "新能源", "芯片", "AI",
)
def score_question(q_text: str) -> int:
"""对单条提问打情绪分:+1 乐观,-1 焦虑,0 中性。"""
if not q_text:
return 0
t = q_text.lower()
pos = sum(1 for w in OPTIMISM_WORDS if w in t)
neg = sum(1 for w in ANXIETY_WORDS if w in t)
if pos > neg:
return 1
if neg > pos:
return -1
return 0
为什么不用 FinBERT 或 LLM?因为第一版的目标是验证信号是否存在,不是追求精度。关键词法零依赖、可解释、5 分钟跑通,适合做 MVP。
三、完整代码:情绪温度计 + 股价对比
import os
import sys
import time
from datetime import datetime, timedelta
from collections import Counter
import pandas as pd
import mairui
STOCK_POOL = {
"600519": "贵州茅台", "000858": "五粮液", "601318": "中国平安",
"600036": "招商银行", "000333": "美的集团", "002594": "比亚迪",
"601012": "隆基绿能", "600809": "山西汾酒",
}
QA_LIMIT = 30
LOOKBACK_DAYS = 180
PRICE_WINDOW = 30
ANXIETY_WORDS = (
"跌", "亏损", "减持", "退市", "暴雷", "违规", "处罚", "问询",
"风险", "担忧", "下滑", "停牌", "质押", "爆仓", "诉讼", "索赔",
"被套", "套牢", "割肉", "补仓", "缩量", "破发", "破净", "商誉",
"坏账", "计提", "滞涨", "跳水", "闪崩", "操纵", "调查",
)
OPTIMISM_WORDS = (
"增长", "回购", "增持", "分红", "利好", "前景", "突破", "创新",
"布局", "扩张", "合作", "订单", "中标", "预增", "预盈", "送转",
"龙头", "低估", "机会", "潜力", "新能源", "芯片", "AI",
)
def score_question(q_text: str) -> int:
t = q_text.lower()
pos = sum(1 for w in OPTIMISM_WORDS if w in t)
neg = sum(1 for w in ANXIETY_WORDS if w in t)
return 1 if pos > neg else (-1 if neg > pos else 0)
def fetch_interactiveqa(api, codes):
rows = []
cutoff = (datetime.now() - timedelta(days=LOOKBACK_DAYS)).strftime("%Y-%m-%d")
for code, name in codes.items():
items = api.stock_interactiveqa(code, lt=QA_LIMIT) or []
for item in items:
q_text, q_date = item.get("q", ""), item.get("qt", "")
if q_date >= cutoff:
rows.append({
"code": code, "name": name, "q_date": q_date,
"question": q_text, "has_answer": 1 if item.get("a") else 0,
"sentiment": score_question(q_text),
})
time.sleep(0.15)
return pd.DataFrame(rows)
def compute_thermometer(qa_df):
g = qa_df.groupby(["code", "name"])
result = g.agg(
total_qa=("question", "count"),
anxiety=("sentiment", lambda x: (x == -1).sum()),
optimism=("sentiment", lambda x: (x == 1).sum()),
).reset_index()
result["temperature"] = (
(result["optimism"] - result["anxiety"]) / result["total_qa"]
).round(3)
result["anxiety_ratio"] = (
result["anxiety"] / result["total_qa"]
).round(3)
return result
def fetch_recent_returns(api, codes, days=30):
et = datetime.now().strftime("%Y%m%d")
st = (datetime.now() - timedelta(days=int(days / 0.7))).strftime("%Y%m%d")
rows = []
for code, name in codes.items():
kline = api.stock_history(code, "d", "n", st=st, et=et) or []
if len(kline) >= 2:
df = pd.DataFrame(kline)
df["c"] = pd.to_numeric(df["c"], errors="coerce")
df = df.sort_values("t").reset_index(drop=True)
ret = round((float(df.iloc[-1]["c"]) / float(df.iloc[0]["c"]) - 1) * 100, 2)
rows.append({"code": code, "name": name, "ret_30d": ret})
time.sleep(0.1)
return pd.DataFrame(rows)
api = mairui.Client("<你的证书>") # 演示证书,请替换成你自己的正式证书
qa_df = fetch_interactiveqa(api, STOCK_POOL)
thermo = compute_thermometer(qa_df)
ret_df = fetch_recent_returns(api, STOCK_POOL, PRICE_WINDOW)
merged = thermo.merge(ret_df, on=["code", "name"], how="left")
merged = merged.sort_values("temperature", ascending=False)
print(merged[["name", "total_qa", "anxiety", "optimism",
"temperature", "anxiety_ratio", "ret_30d"]].to_string(index=False))
四、真实运行结果
以 8 只大盘股、近 180 天互动易问答为例,共获取 156 条问答:
| 股票 | 问答数 | 焦虑 | 乐观 | 温度计 | 焦虑占比 | 近30日收益 |
|---|---|---|---|---|---|---|
| 贵州茅台 | 3 | 0 | 2 | +0.667 | 0.0% | +12.86% |
| 比亚迪 | 30 | 1 | 10 | +0.300 | 3.3% | +12.08% |
| 美的集团 | 17 | 1 | 5 | +0.235 | 5.9% | +10.40% |
| 五粮液 | 30 | 2 | 6 | +0.133 | 6.7% | +1.22% |
| 隆基绿能 | 30 | 2 | 5 | +0.100 | 6.7% | -2.12% |
| 中国平安 | 18 | 5 | 6 | +0.056 | 27.8% | +6.36% |
| 山西汾酒 | 28 | 9 | 9 | 0.000 | 32.1% | +9.82% |
高频关键词:
- 焦虑词 Top5:跌(22次)、破净(2)、下滑(2)、违规(1)、亏损(1)
- 乐观词 Top5:回购(15)、分红(10)、合作(6)、增长(6)、布局(5)
典型问答:
- 焦虑:五粮液散户追问"半年报业绩同比波动超过50%需要披露业绩预告的,7月15日就是强制披露日..."
- 乐观:贵州茅台散户问"什么时候分红"
结果解读
温度计与股价收益呈现弱正相关:茅台/比亚迪情绪温度高、涨幅也大;隆基绿能温度低、收益为负。但山西汾酒焦虑占比最高(32.1%)却涨了 9.82%——情绪不是择时信号。焦虑可能反映的是"跌怕了想抄底"的心理,反而出现在底部区域。
五、常见坑
- "跌"字过于敏感:关键词法会把"没跌""抗跌"也判为焦虑。可以加入否定词过滤("不/未/没" + 焦虑词 → 不计为负面)。
- 互动易活跃度不均:茅台近 180 天仅 3 条问答,统计意义有限。实际应用中需要更大的样本窗口或更活跃的股票池。
- 温度计 ≠ 择时信号:情绪温度计衡量的是散户关注度与情绪倾向,不是买卖信号。焦虑高可能意味着底部(散户恐慌),乐观高可能意味着顶部(散户狂热)。
- 问答质量参差:有些提问是无效噪音("公司电话多少"),需要在打分前做一轮过滤。
六、从关键词到 LLM:升级路线
关键词法的天花板很明显:
- "公司会不会因为减持暴跌"——命中"减持"和"跌",但语义是询问风险,不是恐慌;
- "回购完了是不是该涨了"——命中"回购",但语义是催涨预期,隐含焦虑。
升级方案:
- 关键词 + 上下文窗口:看焦虑词前后 5 个字是否有否定词,减少误判;
- LLM 情绪分类:把问答文本丢给大模型,让它输出情绪标签 + 一句话理由,准确率更高;
- 时间序列分析:把每日情绪得分做成时间序列,观察情绪的变化趋势而非绝对值——情绪从焦虑转向乐观的拐点,可能比情绪本身更有价值。
小结与下篇预告
今天我们搭了一条互动易情绪测温流水线:
- 用互动易接口批量拉取散户问答;
- 用关键词给提问打情绪标签;
- 聚合成个股情绪温度计,与股价收益对比;
- 讨论了关键词法的局限与 LLM 升级路径。
下篇(#04)我们换个角度:让 AI 当你的研报审计员——输入多家券商对同一只股票的业绩预测,让 LLM 指出数字矛盾与隐含前提。
风险提示:本文仅供技术学习交流,不构成投资建议。互动易情绪分析存在样本偏差、语义误判和滞后性风险,历史结果不代表未来收益。



