Python股市情感分析实战:从股吧评论到情绪指数与行情验证
简介股市情感分析源代码以Python编写面向普通投资者、量化研究爱好者以及金融领域的Python开发者旨在从互联网评论中提取投资者情绪构建情绪指数为买卖决策提供量化参考。资源总计16个文件压缩包约58.46MB包含6个CSV数据文件、4个Python脚本、2个情感词典文本、2张结果展示图片、1份说明文档和1个数据集压缩包。CSV中可以看到原始评论、分词结果、模型评分、情绪指数等中间数据脚本覆盖机器学习建模、深度学习建模、情绪指数计算与绘图词典给出积极与消极词汇图片展示情绪指数同大盘走势的对比。按机器学习建模、情绪指数计算、结果图绘制三步顺序执行即可从评论数据一路得到可视化结论项目中附带的词典和多阶段CSV结果还能帮助检查每一步输出是否合理方便替换数据或调整参数。已有1884人学习下载适合希望把自然语言处理真正落地到金融场景的开发者。1. 股市情感分析不是玄学这套 Python 源代码把投资者情绪变成可复用的指标做短线的人大多有过这种体验同一个利好出来有人解读成“涨停”有人解读成“出货”盘面最后朝哪走很大程度取决于多数人怎么想。股市情感分析就是把这种“怎么想”从股吧帖子、新闻标题和讨论区里捞出来量化成 0 到 1 的情绪分再用汇总指标描述一段时间的投资者情绪。这份 Python 源代码是一套能跑通的完整流程评论抓取、文本清洗、情感打分、指数聚合最后还能和历史行情做对齐验证。适合想给自己交易决策加量化佐证的散户也适合做情绪面研究的 Python 开发者和策略初学者。它不是预测股价的黑匣子而是把“市场情绪”从一个模糊感觉变成一份可以复盘、可以回测的中间产物。2. 评论抓取与入库先解决股吧页面反爬和重复文本再谈打分这一章的数据源我用过几种东方财富股吧的讨论接口最直观。这套源码里能看到的功能主线是crawler.py负责抓列表页和详情页cleaner.py负责清洗去重storage.py负责把结果推进 CSV 或 SQLite。运行前提只有一个Python 3.8 以上装好requests、beautifulsoup4、pandas、snownlp。下面按模块拆开讲。2.1 抓取单页请求头、编码和重试是三个默认要写对的地方先看列表页抓取函数。以贵州茅台600519为例访问股吧列表页时要带User-Agent和Referer页面编码要设成gb2312不要交给requests自动猜否则 10 次里有 8 次在中文标题上丢字符。import requests import time def fetch_guba_page(stock_code, page1, max_retry3): url fhttps://guba.eastmoney.com/list,{stock_code},f_{page}.html headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Referer: fhttps://guba.eastmoney.com/list,{stock_code}.html, } for i in range(max_retry): try: resp requests.get(url, headersheaders, timeout10) if resp.status_code 200: resp.encoding gb2312 # 股吧页面是 GB2312别让 requests 去猜 return resp.text except requests.RequestException as e: print(f第 {i 1} 次请求失败: {e}) time.sleep(2 * (i 1)) return 这里timeout10是给连接和读取设上限避免页面卡住时进程永远挂着。max_retry3配合递增等待时间是比“把超时改成 60 秒”更健康的失败策略。注意resp.encoding gb2312这行必须在resp.text之前设置设置晚了一部分字符已经被解码改不回来。拿到 HTML 之后解析不要死磕某一个 CSS 选择器。股吧改版过好几次我一般先打印soup.prettify()找帖子列表的容器。下面是一个相对通用的解析函数from bs4 import BeautifulSoup import re def parse_guba_posts(html, stock_code): soup BeautifulSoup(html, html.parser) posts [] li_items [] # 不同版本页面结构不一样多备几个选择器兜底 for sel in (div.article_list li, ul.item_list li, div.tab_article li): li_items soup.select(sel) if li_items: break if li_items: for item in li_items: title_node item.select_one(span.l3 a, a.title) time_node item.select_one(span.l6, div.time) if not title_node: continue posts.append({ code: stock_code, title: title_node.get_text(stripTrue), url: title_node.get(href, ), created_at: time_node.get_text(stripTrue) if time_node else , }) else: # 选择器全失效时的后备方案从 read 链接里捞 for a in soup.find_all(a, hrefTrue): if re.search(r/read\., a[href]): posts.append({ code: stock_code, title: a.get_text(stripTrue), url: a[href], created_at: , }) return posts逻辑说明先用三个候选选择器找列表li找到了就解析标题、链接和发布时间找不到就退化成页面里所有/read.开头的链接宁可多抓几条也不要空手而归。title_node.get_text(stripTrue)会把标题里的空白字符去掉url留待后续进详情页抓正文用。列表页的抓取循环里一定要控制节奏我一般用随机间隔而不是固定sleep(1)import random def dump_posts_to_csv(): all_posts [] for page in range(1, 16): html fetch_guba_page(600519, page) if not html: break posts parse_guba_posts(html, 600519) if not posts: break all_posts.extend(posts) time.sleep(random.uniform(0.8, 1.5)) return all_postsrandom.uniform(0.8, 1.5)让请求间隔在 0.8 到 1.5 秒之间浮动比固定 1 秒更难被识别成数据采集脚本。这个函数跑完得到的是带标题和 URL 的原始列表还没到情感打分那一步。注意股吧列表页通常只有标题正文在详情页里。源码里会把url存下来下一步按 URL 二次抓正文再走清洗逻辑。标题也可以用来分析但标题党的干扰比正文更重单标题跑出来的情绪分经常偏乐观。详情页正文抓取我习惯这样写def fetch_post_body(url): headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Referer: https://guba.eastmoney.com/, } try: resp requests.get(url, headersheaders, timeout10) resp.encoding gb18030 # 详情页偶尔会出现 GB2312 之外的扩展字符 soup BeautifulSoup(resp.text, html.parser) body_node soup.select_one(div.read-content, div.article-body, div.post-content) if body_node: return clean_post(body_node.get_text( , stripTrue)) except requests.RequestException: pass return 这个函数返回的是清洗后的正文clean_post在下一小节定义。这里用gb18030是因为详情页有时会混入少数生僻字gb2312会报编码错误。2.2 清洗与去重表情标签、空帖和重复转发一起处理原始 HTML 里带[大笑]、[摊手]这类表情标签还有举报、转发、只看楼主等页面控件文本。如果不洗直接丢给 SnowNLP它会把这些无意义词当作中文参与计算前后都是噪音。清洗函数我一般这样写import re import hashlib def clean_post(raw_text): if not isinstance(raw_text, str): raw_text str(raw_text) text raw_text text re.sub(r\[.*?\], , text) # [大笑] [失望] 这类表情占位符 text re.sub(rhttps?://\S, , text) # 网址和图片链接 text re.sub(r\b(举报|转发|只看楼主)\b, , text) text re.sub(r\s, , text) return text.strip() def dedup_id(text): return hashlib.md5(text.encode(utf-8, ignore)).hexdigest()\[.*?\]用非贪婪匹配把中括号包裹的表情标签清掉但不会误伤“超预期”这类正常词。re.sub(r\s, , text)这步很关键页面里标题和正文经常被换行拆成多段后续情感打分要保持同一句话在连续字符串里。清洗后的文本统一走一次长度过滤和正文去重import pandas as pd raw_df pd.read_csv(raw_posts.csv) raw_df[clean_content] raw_df[content].fillna().apply(clean_post) raw_df raw_df[raw_df[clean_content].str.len() 5] raw_df[dedup_id] raw_df[clean_content].apply(dedup_id) raw_df raw_df.drop_duplicates(subset[dedup_id], keepfirst) raw_df[date] pd.to_datetime(raw_df[created_at]).dt.date raw_df.to_csv(clean_posts.csv, indexFalse)str.len() 5是我个人惯用的阈值。5 个字以下多数是“哈哈”“牛”这类无信息量文本但你要是做超短文本专题可以把阈值降到 3反正去重已经在后面兜底。date列尽量用created_at转出来后面章节对齐行情时它会救你一命。另一个容易踩的坑是股吧时间显示成“09-28 13:25”这种跨年不清晰格式所以清洗时最好用“当年日期补全”而不是直接pd.to_datetime否则 12 月和 1 月的评论会被排到同一年。关于存储格式我建议 CSV 先用起来别一上来就接 MySQL。情绪分析项目通常每天只有几千条评论CSV 足够等到你要做多股票监控再迁 SQLite。存储方式优点适合场景CSV肉眼可见、pandas 直接读单股票、短期实验SQLite多线程写、去重查询方便多股票、常驻采集首版从 CSV 开始改起来成本最低。SQLite 去重查询可以写成SELECT COUNT(DISTINCT dedup_id) FROM posts WHERE code?不用每轮全量加载内存更适合长时间运行。3. 情感打分从通用到财经SnowNLP 词典修正与情绪指数生成SnowNLP 是现成可用的中文情绪库sentiments返回 0 到 1 的概率值。把评分分成三档我们就先建立第一个情绪映射。3.1 把默认模型跑通分清它在哪些财经场景会说反话from snownlp import SnowNLP def basic_score(text): if not text or len(text.strip()) 2: return 0.5 return SnowNLP(text).sentimentsSnowNLP 默认模型是在商品评论这类语料上训练的对“哈哈”“不错”这类词很敏感对“减持”“爆雷”“质押”却基本无感。比如“公司大股东减持股价承压”默认模型很可能给出 0.6 以上的偏正分因为“公司”和“股价”在训练语料里并不负面。这种时候直接做策略等于把利空当成利好测试时相关性会直接翻车。我一般先建一张阈值表固定解读口径得分区间解读备注0.4偏空不是确凿的“看跌”只能算负面情绪占优0.4-0.6震荡空方和多方的声音都要继续看0.6偏多需要和来源数量一起看不能单条下结论这张表的价值是让后续策略对齐。你不需要让模型给每条评论一个绝对正确标签只需要保证得分在多数情况下方向和市场感觉一致剩下的交给指数聚合。3.2 词典修正与否定词兜底把财经词汇焊进评分函数默认模型不动我们在它外面盖一层财经词典。正面词和负面词按“命中的词越多偏移越大”的方式叠加POS_WORDS {利好, 超预期, 涨停, 增持, 突破, 强势, 龙虎榜} NEG_WORDS {利空, 跌停, 减持, 质押, 爆雷, 退市, 不及预期, 立案} def finance_sentiment(text): if not text or len(text.strip()) 2: return 0.5 base SnowNLP(text).sentiments score base pos_hit [w for w in POS_WORDS if w in text] neg_hit [w for w in NEG_WORDS if w in text] if pos_hit and not neg_hit: score min(1.0, score 0.15 * len(pos_hit)) elif neg_hit and not pos_hit: score max(0.0, score - 0.15 * len(neg_hit)) elif pos_hit and neg_hit: last_pos max(text.rfind(w) for w in pos_hit) last_neg max(text.rfind(w) for w in neg_hit) if last_neg last_pos: score max(0.0, score - 0.15) else: score min(1.0, score 0.15) if 不 in text and any(w in text for w in (利好, 看涨, 增持)): score max(0.0, score - 0.2) return round(score, 4)逻辑说明base是默认模型打底pos_hit和neg_hit分别收集命中的财经关键词。只有正面词时加 0.15 乘命中数只有负面词时减 0.15 乘命中数。两边都命中时用rfind比较最后出现的词位置谁在后听谁的因为“利好兑现是利空”这种句子最后的落点是利空。最后处理否定词“不是利好”应该向负面偏移。参数方面0.15和0.2属于经验值。样本量大的时候可以自己在源码里改成 0.1 或 0.25然后观察对分组收益差的影响。这个函数是整个项目最重要的黑匣子建议保留一份不改动的基线每次只改一个参数。接着是聚合。聚合时不要简单求平均平均会被刷帖数量稀释。我比较习惯用“看多占比减去看空占比”import pandas as pd def investor_sentiment_index(df, date_coldate, content_colclean_content): df df.copy() df[sent] df[content_col].apply(finance_sentiment) daily df.groupby(date_col).agg( total(sent, size), pos(sent, lambda s: int((s 0.6).sum())), neg(sent, lambda s: int((s 0.4).sum())), ) daily[score] (daily[pos] - daily[neg]) / daily[total] return daily[[total, pos, neg, score]]total是当天有效帖子数用来做可信度权重。某一天只有两条评论时score容易是 1.0 或 -1.0后面验证前要用total 10过滤。groupby(date_col)默认按字符串日期聚合如果你的created_at带时分秒先统一用dt.date取到日否则同一自然日会被拆成多条。如果你想上更重的模型这套源码里保留了 BiLSTM 的训练入口但前提是有标注好的训练集。没有标注时我建议别碰深度学习几百条样本训练出来的 LSTM 容易被两三条特例带偏反而不如上面的规则加词典稳定。4. 避坑记录股市情感分析最容易翻车的五个现场这五个问题我在不同股票上都反复见过也算血泪经验。每一条都是“现象”开头直接告诉你代码跑歪时大概长什么样。4.1 现象默认模型把“减持公告”聊成利好现象跑完第一版情绪指数在爆雷日不降反升个股负面公告越多指数打分越高。原因SnowNLP 的语料偏向购物评价“公司”“股价”“市场”在电商语料里整体中性偏正财经负面词根本没进词典。解决按第 3 章的finance_sentiment加词典同时对“减持”“质押”“立案”这类词单独测试。固定写一个冒烟测试把几条典型文本丢进去看输出再决定要不要调权重。不要只靠肉眼扫一遍 CSV我吃过这个亏。4.2 现象同一条帖子被反复计三次情绪指数虚高现象某天total异常大score被重复帖子的情绪值抬高。原因热门列表、最新列表和详情页推荐位互相重叠同一个帖子可能被抓进三个批次URL 上带的不同查询参数也会让“按 URL 去重”失效。解决用正文dedup_id去重而不是标题或 URL。聚合前再跑一次drop_duplicates(subset[dedup_id], keepfirst)保证每个内容只计入一次。这个动作要放在情感打分之前否则重复文本会被重复赋分。4.3 现象爬虫跑 5 分钟后连续超时现象前几页秒回再往后fetch_guba_page连续返回空字符串或超时异常。原因请求间隔太短或者固定sleep(1)反而被识别成机器节奏另一个常见原因是把timeout当成万能解药无脑调大到 60 秒进程卡死在等待上。解决回到random.uniform(0.8, 1.5)并且每次失败递增等待。更关键的是限制单次任务页数比如最多抓 50 页就主动停下次再继续。4.4 现象情绪指数与行情相关性跑出来是负的现象用当日情绪和当日涨跌幅做 Pearson 相关系数结果显著为负怎么看都不符合直觉。原因当日收盘价在大多数评论之前就已经定下来了。盘后情绪和当日行情本来就不应该有强正相关你硬把这两个时间对齐得到的是因果错位的数字。解决情绪用当天收益用下一个交易日即ret_next close.pct_change().shift(-1)。第 5 章会专门写这条验证链路。4.5 现象停牌日情绪照常更新指标和价格错位现象复牌第一天的收益特别极端相关性被一天拉崩。原因停牌期间股吧讨论还在继续情绪指数一直在累积但行情没有对应价格如果用前向填充把停牌缺价补上pct_change()会把复牌跳空当成正常波动。解决合并行情时用howinner只保留两边都有的交易日不要对价格做fillna(methodffill)。停牌期的评论本身可以单独做事件研究但不要直接混进常规情绪指数。把去重和日期对齐这两个问题抓好能少白跑两个版本。5. 情绪与行情对齐一个不建模也能验证的信号检查方法验证情绪指数有没有价值比急着上模型更重要。最短路径是拿情绪指数的日频值和这只股票的后一个交易日收益做 Pearson 相关系数再分组看收益差。5.1 行情数据准备先用交易日把两表对齐这里用 AkShare 拉日线装一下akshare就能用import akshare as ak import pandas as pd price_df ak.stock_zh_a_hist( symbol600519, perioddaily, start_date20240101, end_date20241231, adjustqfq, ) price_df[date] pd.to_datetime(price_df[日期]).dt.date price_df[close] price_df[收盘].astype(float) price_df price_df[[date, close]]参数解释symbol是 6 位股票代码perioddaily是日线adjustqfq表示前复权避免分红除权造成价格跳空。AkShare 返回的列名是中文的日期和收盘转换后存成小写字段统一处理。再把情绪日频数据sent_daily和行情做 inner joindf pd.merge(sent_daily, price_df, ondate, howinner) df df.sort_values(date).reset_index(dropTrue) df[ret_same] df[close].pct_change() df[ret_next] df[close].pct_change().shift(-1)howinner只保留两边都有的交易日不要用fillna(methodffill)去补行情停牌日根本没有成交价补出来的假价格会让 next-day 收益出现伪跳跃。ret_same是当日收益ret_next是次日收益。要测“情绪领先价格”核心看sent对ret_next的相关性。5.2 相关系数和分组差两个数字就能判断能不能用from scipy.stats import pearsonr df df.dropna(subset[sent, ret_next]) r_same, p_same pearsonr(df[sent], df[ret_same]) r_next, p_next pearsonr(df[sent], df[ret_next]) print(f当日相关: r{r_same:.3f} p{p_same:.3f}) print(f次日相关: r{r_next:.3f} p{p_next:.3f}) high df[df[sent] df[sent].quantile(0.7)][ret_next].mean() low df[df[sent] df[sent].quantile(0.3)][ret_next].mean() print(f高情绪组次日收益均值: {high:.4f}) print(f低情绪组次日收益均值: {low:.4f})pearsonr返回皮尔逊相关系数和 p 值。p 值大于 0.05 时相关性说不上显著哪怕 r 有 0.2 也别急着拿去实盘。分组差比相关系数更直观把情绪得分最高的 30% 和最低的 30% 分开看次日收益均值差值。如果高情绪组次日收益明显更高说明这个指数确实带了增量信息如果两组差接近零问题多半不在代码而在语料源本身。样本量也要卡一下。少于 20 个交易日的验证结果基本没有统计意义我一般要求至少 40 个交易日。情绪指数可以按total 10过滤后再算否则当天只有两三条帖子的得分方差极大会把相关系数往零方向拉。从那以后我每次换一个股票做情绪研究都强制走一遍这条链路抓一周评论、清洗、聚合情绪分、对次日收益做相关性。哪怕结果不显著我也能确定不是日期对齐错了而是这个标的的股吧情绪本来就带不来增量信息。希望帮到你。本文还有配套的精品资源点击获取