最近帮朋友做了一个股票行情可视化分析的小系统,从爬虫抓数据、清洗落库、再到页面图表展示,一条龙走下来,踩了不少坑,也攒了不少经验。正好有人问我这类项目应该怎么从零搭,干脆把整个设计和实现思路整理出来。如果你正想找一个Python爬虫+数据处理+可视化相结合的项目练手,或者需要给团队搭一套轻量级的行情监控工具,这篇内容应该能帮你省不少时间。
先说这个系统能干什么:定时从公开财经数据源抓取股票实时行情和K线数据,自动做数据清洗和均线、涨跌幅等指标计算,最后通过Web页面以图表形式展示个股走势和自选股监控。整体只用Python生态完成,不用写复杂的JavaScript,前端图表全部由ECharts渲染。适合有一定Python基础、想往数据采集和可视化方向深入的开发者参考。
1. 项目整体设计与技术选型
1.1 为什么选Python做股票数据采集
做股票行情采集这件事,技术栈的选择其实很关键。你可以用Java、Go甚至Node.js写爬虫,但我在实际对比之后还是选了Python。原因有三个:一是生态成熟,requests、BeautifulSoup、Scrapy这些库对网页抓取和信息提取的支持非常完善,写一个针对性的爬虫代码量可以压缩到很小的范围;二是数据分析链条是闭环的,抓下来的数据直接用pandas清洗、计算,画图用pyecharts或者把处理结果通过API喂给前端,Python能一路管到底;三是调试效率高,特别是应对数据源返回格式变化的情况,脚本语言改起来比编译型语言快得多。
当然Python爬虫有性能短板,遇到大规模分布式采集时会吃力,但对股票信息这种单机、低频率、小数据量的场景完全够用。用官方一句话说就是:先让事情跑起来,再考虑优化。这个系统本身就是轻量级定位,不需要上分布式爬虫集群,单线程定时轮询就能满足绝大部分个人和小团队的需求。
1.2 爬虫方案对比与选型
市面上获取股票数据的方式大概分三类。第一类是直接用现成的Python库,比如akshare、tushare、baostock,这些库封装好了数据获取逻辑,调用接口就能拿数据,省事是真的,但缺点是依赖第三方维护,有些库的接口更新滞后,自由度也不够。第二类是调用券商或财经网站开放的HTTP接口,这类接口数据全、实时性好,但需要自己处理请求参数和返回格式。第三类是写通用的HTML爬虫去解析页面,这种方式最麻烦,因为要面对页面结构变动和反爬机制。
我最终选的是第二类路线,即直接请求财经网站公开行情接口。以新浪财经为例,它的股票行情接口hq.sinajs.cn是一个标准的HTTP GET接口,传入股票代码即可返回一条包含股票名称、当前价、涨跌额、成交量等字段的逗号分隔字符串。这种接口本质上是为网页前端服务的,相比解析整个HTML页面,它传输的数据量小、字段结构相对固定,解析起来非常简单,稳定性也远高于抓取HTML页面。
用这类接口还有一个好处:请求成本低,单次请求就能拿到一只股票的完整行情快照,连续请求几十只股票做轮询也没有压力。而且只要控制好频率,在正常使用强度下基本不会触发对方的风控逻辑。
1.3 可视化工具选型:ECharts是主力
可视化部分我对比过两套方案。一套是Python生态内的Matplotlib和Pyecharts,另一套是前端JavaScript的ECharts。Matplotlib在静态分析报告场景很好用,画K线、均线图都能胜任,但它生成的图片不支持交互,鼠标悬停看不到具体数值,也没有时间范围拖拽这类功能。Pyecharts本质是把ECharts的配置能力包装成Python接口,它生成的HTML里还是运行着ECharts。
对于股票查看场景,交互性是刚需。用户需要看某个时间段的走势,必须能缩放、拖拽、悬浮提示。ECharts在这方面的体验是所有方案里最好的,而且它支持的数据格式非常灵活,我只需要在后端通过Flask提供JSON接口,把pandas处理好的数据按ECharts要求的格式返回,前端完全不用写复杂的业务逻辑。
这里我采用的架构是后端返回标准JSON、前端ECharts直接渲染。Flask负责提供数据查询接口,数据从数据库读取后经过简单的字段映射和格式转换,以JSON形式输出。前端页面用原生HTML+JavaScript构建,通过fetch请求数据然后交给ECharts实例渲染。这个模式看起来是多了一层前后端分离,但实际上代码简洁,后期维护也很方便。
2. 爬虫模块的实现细节
2.1 数据源选择与请求参数构造
股票数据源我同时接了两个:新浪财经做实时行情,腾讯财经做K线历史数据。新浪接口格式如下:
http://hq.sinajs.cn/list=sh600000,sz000001这个是标准A股代码格式,sh代表上海交易所、sz代表深圳交易所,后跟六位数字代码。注意返回的数据编码是GBK,请求时必须在响应上做解码处理,否则中文会出现乱码。腾讯的K线接口格式是:
http://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param=sh600000,day,2023-01-01,2023-12-31,320,qfq参数依次是股票代码、K线周期、开始日期、结束日期、返回数量、复权方式。qfq表示前复权,这样拉到的历史价格已经处理过除权除息的影响,直接可以用来算均线和收益率。
请求参数的构造有几个细节需要注意。一是必须在请求头中带上Referer字段,否则新浪接口会拒绝响应。我用的是https://finance.sina.com.cn作为Referer。二是不需要伪装成浏览器,和搜索类站点不同,财经数据接口主要检查的是来源,不是浏览器身份。三是返回的字段是固定的,用逗号分隔,但要注意部分字段可能为空,比如停牌期间成交量字段可能是空字符串。
2.2 数据解析与清洗的完整流程
以新浪实时行情接口为例,返回的原始数据格式大约长这样:
var hq_str_sh600000="浦发银行,10.50,10.52,10.48,10.55,10.40,...";我的解析思路分三步走。第一步用正则表达式剥离前面的var hq_str_前缀和结尾的分号,只保留引号里的内容。第二步按逗号分割成一个列表。第三步根据新浪字段顺序建立映射关系。这里贴一段核心解析代码:
import requests import re def fetch_stock_real_time(stock_code): url = f"http://hq.sinajs.cn/list={stock_code}" headers = { "Referer": "https://finance.sina.com.cn", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" } resp = requests.get(url, headers=headers, timeout=5) resp.encoding = "gbk" text = resp.text.strip() # 从响应中提取引号内的数据 match = re.search(r'"(.*?)"', text) if not match: return None fields = match.group(1).split(",") if len(fields) < 32: return None return { "code": stock_code, "name": fields[0], "open": float(fields[1] or 0), "pre_close": float(fields[2] or 0), "current": float(fields[3] or 0), "high": float(fields[4] or 0), "low": float(fields[5] or 0), "volume": int(fields[8] or 0), "amount": float(fields[9] or 0), "date": fields[30], "time": fields[31] }清洗环节有几个容易出坑的地方。第一是字段的空值判断,某只股票停牌时,价格字段可能为空字符串,直接float()会抛异常,所以我在每个转换前都加了or 0兜底。第二是单位统一问题,新浪接口的成交量单位是股,成交额单位是人民币元,入库和计算时必须统一转换成万手和亿元,否则后续可视化会出现数据量级错乱。第三是去重策略,同一股票在同一个交易日可能被轮询多次,为了避免重复入库,我在数据库表设计时给交易日期加唯一索引,写入时使用INSERT OR REPLACE语义,天然保证数据不会重复。
2.3 动态频率控制与断线重试
爬虫跑起来容易,但要让它长期稳定地跑下去,频率控制和异常处理必须做扎实。我遇到过的情况是:开始写爬虫时用死循环每秒抓一次,跑了十几分钟后开始被数据源限流,返回的响应变成异常内容。这个问题后来通过两个手段解决。
第一个手段是动态调整请求间隔。开盘时段每10秒轮询一次,收盘后数据不再变化则切换到10分钟一次。通过判断当前时间是否落在交易时段内来决定请求频率,既不浪费请求资源,又能保证页面展示的实时性。核心逻辑不复杂:
import time from datetime import datetime def should_fetch_high_frequency(): now = datetime.now() weekday = now.weekday() hour_minute = now.strftime("%H:%M") # 工作日且处于交易时段(早盘9:30-11:30,午盘13:00-15:00) if weekday < 5: if "09:30" <= hour_minute <= "11:30": return True if "13:00" <= hour_minute <= "15:00": return True return False第二个手段是重试机制。网络请求不可能每次成功,遇到超时、连接重置这类问题时,我的做法是连续重试三次,每次间隔指数递增,分别是1秒、2秒、4秒。如果三次都失败,本轮跳过该股票,记录日志后继续处理下一只,不阻塞整个采集循环。这里不建议无限重试,因为被限流时重试越频繁,越容易加重封禁风险。
另外一个容易忽略的点是请求顺序。我实测发现,用同一个session对象顺序请求多只股票,比每次新建连接效率高出不少。requests库的Session会复用底层的TCP连接,对同一host的请求可以节省握手开销。这个优化虽然看不出来质的飞跃,但在请求几千只股票时会明显减少总耗时。
3. 存储、分析计算与可视化实现
3.1 数据存储方案:SQLite适合轻量需求
存储层我用了SQLite而不是MySQL。原因是这个系统不需要多用户并发读写,SQLite单文件方式的部署成本最低,不需要单独装数据库服务。实测几千只股票的日K线数据量也就在几十MB级别,SQLite完全扛得住。
建表时我分了两张表,一张存日K线,一张存实时行情快照。关键字段如下:
CREATE TABLE daily_kline ( code TEXT, date TEXT, open REAL, close REAL, high REAL, low REAL, volume INTEGER, amount REAL, PRIMARY KEY (code, date) ); CREATE TABLE realtime_quotes ( code TEXT PRIMARY KEY, name TEXT, current REAL, open REAL, high REAL, low REAL, pre_close REAL, change_rate REAL, volume INTEGER, amount REAL, update_time TEXT );实时行情的表加了update_time字段,每次轮询都更新,前端展示时通过这个字段判断数据是否过期。日K线表用code+date做联合主键,天然可以防止同一交易日的数据重复插入。这里有一个实操心得要分享:SQLite对大量写入操作有锁机制,多线程同时写会报database is locked。我的方案是爬虫线程只负责写,Web请求线程只负责读,避免写冲突。
3.2 分析指标计算:移动平均线是核心
可视化页面上光有裸的K线还不够,我加了几条移动平均线指标。MA5、MA10、MA20、MA60这四条均线分别代表短、中、长周期的平均成本线。计算方式很简单,就是滚动求平均:
import pandas as pd def calculate_ma(df, window): return df["close"].rolling(window=window, min_periods=1).mean() df["ma5"] = calculate_ma(df, 5) df["ma10"] = calculate_ma(df, 10) df["ma20"] = calculate_ma(df, 20) df["ma60"] = calculate_ma(df, 60)需要注意min_periods=1这个参数。如果没有它,数据量不够5条时前四天都是NaN,而ECharts在前端渲染时遇到NaN会出现数据断点,K线图中间缺一段,观感非常差。加了这个参数之后,前四天的均线值就是已有数据的平均值,图形更平滑。
除了均线,我还计算了涨跌幅和换手率两个衍生指标。涨跌幅用今天的收盘价减去昨天的收盘价再除以昨天的收盘价,这个计算有一个细节:第一天的涨跌幅没有历史参照,需要置零或者直接丢弃,不能参与可视化。换手率指标需要额外的流通股本数据,这个不是每个接口都提供,我干脆跳过流通股本,用相对成交量和成交额来替代展示。
3.3 后端接口设计与ECharts渲染
后端服务用Flask搭建,只提供两个核心接口:一个是查某只股票的历史K线数据,一个是查自选股的实时行情。这里贴一段K线接口的代码:
from flask import Flask, jsonify, request import sqlite3 app = Flask(__name__) def query_kline(code, limit=120): conn = sqlite3.connect("stock.db") cur = conn.cursor() sql = "SELECT date, open, close, high, low, volume FROM daily_kline WHERE code=? ORDER BY date DESC LIMIT ?" rows = cur.execute(sql, (code, limit)).fetchall() conn.close() # 逆序得到按日期升序的数据 rows = rows[::-1] dates = [r[0] for r in rows] kline = [[r[1], r[2], r[3], r[4]] for r in rows] # [open, close, high, low] volumes = [r[5] for r in rows] return { "dates": dates, "kline": kline, "volumes": volumes } @app.route("/api/kline") def api_kline(): code = request.args.get("code", "sh600000") return jsonify(query_kline(code))ECharts接收这个JSON结构后,用dataZoom组件配合K线图渲染。前端关键配置如下:
const chart = echarts.init(document.getElementById("chart")); chart.setOption({ tooltip: { trigger: "axis" }, dataZoom: [ { type: "inside", start: 60, end: 100 }, { type: "slider", start: 60, end: 100 } ], xAxis: { type: "category", data: data.dates }, yAxis: { scale: true }, series: [{ type: "candlestick", data: data.kline, itemStyle: { color: "#ef232a", color0: "#14b143", borderColor: "#ef232a", borderColor0: "#14b143" } }] });一个细节是ECharts的K线数据结构,它要求每根K线是四元组,顺序是[开盘价、收盘价、最低价、最高价],注意不是[开、高、低、收],我在这里搞反过一次,结果画出来的K线影线全部反了。另一个细节是涨跌颜色,A股习惯红涨绿跌,ECharts默认是国际惯例绿涨红跌,必须通过color和color0手动设置。
3.4 可视化大屏布局与交互体验
页面布局我设计了三个核心区域。左侧面板展示自选股列表和实时涨跌数据,点击某只股票后中间区域刷新K线图,右侧区域展示均线示意图和分析指标卡片。自选股列表使用定时器每10秒向后端拉取一次最新行情,并把涨跌幅超过3%的股票用特殊颜色标出,相当于一个简单的异动提醒功能。
页面整体没有用前端框架,就是原生HTML+CSS,这样不需要引入Node.js构建工具链。CSS采用了深色背景主题,因为K线图在深色背景下的视觉反馈明显更好,分辨涨跌也更轻松。页面用flex布局实现三栏结构,中间图表区域弹性缩放,在小屏设备上也基本能用。视觉呈现上少用花哨的元素,图表区域保持足够大,信息层级清楚。
4. 系统架构整合与定时调度
4.1 分层架构设计思路
整个系统按职责分成四层:数据采集层、存储层、分析计算层、展示层。四层之间通过接口衔接,每一层都可以独立替换。比如数据采集层目前用新浪的HTTP接口,哪天想切到tushare,只需要改采集层的代码,上层存储和分析逻辑完全不用动。
这个分层设计在处理需求变更时优势很明显。朋友后来提出要增加港股数据,我只需要在采集层增加一个港股数据源的适配器,把返回字段统一映射成现有结构的字典即可,分析计算和展示层感知不到新增了数据源。如果你是给自己做工具用,分层虽然多写几个接口函数,但后续维护的幸福感会翻倍。
数据采集层另外做了一个抽象处理:把新浪接口、腾讯接口的返回结果统一转成一个标准的字典结构,字段名固定为code、name、open、close、high、low、volume、amount。这样一来,即使切换数据源,也不用每次都去修改数据库层和展示层的字段映射关系。
4.2 定时任务调度方案
系统不可能一直靠手动运行脚本,必须要有自动调度能力。我用了APScheduler这个Python库来做定时任务,而不是依赖系统crontab。理由很简单:APScheduler在进程内管理调度,不依赖操作系统环境,而且可以直接操作已经初始化的数据库连接和日志系统。
调度策略如下:交易时段内每10秒跑一次实时行情采集任务,每天收盘后跑一次日K线更新任务,每周做一次数据完整性检查。这三个任务用APScheduler配置成本地触发器,服务启动时自动加载。核心写法不复杂:
from apscheduler.schedulers.blocking import BlockingScheduler from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.cron import CronTrigger scheduler = BlockingScheduler() # 交易时段轮询实时行情(通过job内逻辑判断是否真正执行) scheduler.add_job( fetch_realtime_job, trigger=IntervalTrigger(seconds=10), id="realtime_fetch", max_instances=1, coalesce=True ) # 每天15:10更新日K线 scheduler.add_job( update_daily_kline_job, trigger=CronTrigger(hour=15, minute=10), id="daily_kline_update" ) scheduler.start()max_instances=1和coalesce=True这两个参数是并发保护的关键。前者保证同一任务前一个实例没跑完时不会启动新实例,后者处理任务积压场景,比如程序挂了一小时后恢复,之前的触发表会被合并成一次执行,而不是补跑几百次。这对于爬虫任务非常重要,避免重复请求导致数据源限流。
4.3 自选股管理与配置化
自选股列表不应该写死在代码里。我把它做成了一个配置文件加一个前端编辑入口,用户可以在页面上增删自选股票,配置保存后采集线程在下一轮自动感知变更。实现上采用一个简单的JSON文件存储自选股代码列表,采集任务每次迭代前重新读取一次配置,这样不需要重启服务。
{ "stocks": [ {"code": "sh600000", "name": "浦发银行"}, {"code": "sz000001", "name": "平安银行"}, {"code": "sh600519", "name": "贵州茅台"} ] }这里有一个坑要提醒:股票代码的交易所前缀不能搞混。用sh开头的去请求深圳接口,虽然不会报错,但返回的代码段对不上实际股票,数据全部错误。所以配置校验逻辑必须做两层:一层检查代码格式是否合法,另一层校验返回的数据名称是否和配置名称一致,不一致直接告警。
5. 实操中遇到的问题与排查实录
5.1 数据源偶发请求超时
运行一段时间后,我注意到日志里偶尔出现requests.exceptions.Timeout。排查发现,在午间休市前后,数据源服务器会短暂调整,部分请求响应变慢。解决办法是在请求中设置超时时间,不设置read超时的话,一个挂起的连接可能卡住线程好几分钟。
正确的超时设置是连接超时和读取超时都设置:
resp = requests.get(url, headers=headers, timeout=(3.05, 5))连接超时3.05秒,读取超时5秒。这里建议连接超时不要用整数3秒,因为底层TCP重传机制可能导致刚好在3秒边缘的连接被提前误杀。3.05这个数值是Python官方文档推荐的,相比3秒可以降低一些误判概率。
5.2 中文乱码是编码转换坑
第一次从新浪接口拿数据时,打印出来的股票名称全是乱码。问题的根源在于新浪返回的是GBK编码,而requests库默认按响应头猜测编码,但该接口的响应头中没写清楚charset,导致requests错误地按UTF-8解码。
解决方案是手动指定编码:
resp.encoding = "gbk"这里还想多说一句经验总结:凡是处理国内股票数据源的响应,不管对方声称什么编码,最好都以手动方式指定编码,不要依赖自动检测。自动检测在大部分场景可靠,但遇到返回头缺失编码声明、数据本身又不是纯文本的时候就会掉链子。腾讯的行情接口也存在类似情况,返回UTF-8但偶尔夹杂转义字符,同样需要手动归一化处理。
5.3 触发访问限制后的处理策略
我在调试阶段因为频繁请求触发过一次数据源的限制,特征是连续几个请求全部返回异常或空内容。排查下来发现原因是调试循环里遗漏了time.sleep,导致每秒请求十几次。
我的规避策略分三层。第一层是请求频率上限:单只股票的间隔不低于10秒,整个轮询循环结束后额外sleep 2秒。第二层是异常惩罚机制:连续失败5次后,自动进入冷却状态,暂停该任务5分钟,让数据源的风控策略冷却下来。第三层是观察响应内容:如果返回内容明显不符合正常数据模式,直接判定被限流,此时立刻停止当前批次的请求,而不是硬着头皮继续抓。
下面是一个精简版的带冷却机制的采集循环:
fail_count = 0 cooling = False def fetch_with_guard(code): global fail_count, cooling if cooling: return None try: data = fetch_stock_realtime(code) fail_count = 0 return data except Exception as e: fail_count += 1 if fail_count >= 5: cooling = True # 启动冷却计时器,5分钟后自动恢复 threading.Timer(300, reset_cooling).start() return None def reset_cooling(): global fail_count, cooling fail_count = 0 cooling = False5.4 前端图表卡顿与数据量优化
全量展示上证几千只股票的数据时,前端加载数据明显变慢,页面操作也有卡顿。这个问题的根源不是ECharts渲染力不足,而是我给前端的接口返回了全部股票的全字段数据,每次传输几十万个数据点,浏览器的JSON解析和图表重绘压力自然大。
解决方案是分维度优化。第一,K线接口默认只返回最近120个交易日的数据,超过这个范围需要前端主动请求更多;第二,实时行情列表用分页方式,第一屏只显示前50只自选股,加载更多时再拉下一批;第三,指标卡片的计算放到后端完成,前端只拿计算结果展示,不在浏览器里跑大数据量计算。
还有一个体验优化的小细节:前端定时刷新时,负责拉数据的setInterval请求如果上一次请求还没返回,会造成请求堆积,进而拖慢页面。我在方案中加入了请求锁:
let loading = false; async function refreshQuotes() { if (loading) return; loading = true; try { const data = await fetchQuotes(); renderQuotes(data); } finally { loading = false; } } setInterval(refreshQuotes, 10000);这样即使后端响应偶尔变慢,前端也不会同时堆积多个请求。实测下来,页面长时间运行也不会出现内存持续增长的现象。
5.5 数据库偶发写入失败的处理
SQLite在高频轮询场景下,偶尔出现database is locked错误。排查后确认是写入事务和读取事务产生了锁竞争。解决办法是在连接SQLite时增加超时设置,并开启WAL模式:
conn = sqlite3.connect("stock.db", timeout=10) conn.execute("PRAGMA journal_mode=WAL")WAL模式让读写协调共存,读操作不会阻塞写操作。配合单写多读的使用方式,这个配置让数据库运行得非常稳定,从加上这个PRAGMA之后没有再出现过锁错误。
这个场景给我一个启发:不要一上来就追求MySQL或PostgreSQL这样的重型数据库。SQLite搭配正确的配置,在单机低并发场景下完全不输给网络数据库,而且不占内存、不占额外进程,作为爬虫项目的存储层非常理想。
6. 项目扩展方向与个人心得
做完了这套基础版本之后,我还在盘算几个扩展方向。第一个是增加技术指标图形,比如MACD指标、RSI相对强弱指标、布林带通道,这些在ECharts里都有对应的渲染方式,核心工作只是指标计算。第二个是增加指数汇总视图,把沪深主要指数放在一个页面上统一监控,以便看整体趋势。第三个是引入机器学习做简单预测,用历史K线数据训练一个分类模型,判断后续走势概率,这是一个有趣的实验方向,但实盘参考价值有限,只能当技术实验。
分享一下我在整个项目开发中的体会。爬虫项目最重要的不是花哨的抓取技巧,而是稳定的数据治理能力。你把数据抓下来只是完成了第一步,让数据始终保持干净、准确、可持续更新,才是系统真正能跑起来的关键。写代码的时候给自己提一个问题:这个模块如果运行三十天不出人工干预,会不会出问题?有时候变量名写得再漂亮,不如把异常处理好来得实在。
另外一个体会是数据源的选择直接影响开发效率。第一次做类似项目时,总想抓那些需要复杂解析的页面,觉得这样才有含金量。实际经验告诉我,选择一个稳定的公开数据接口,比写复杂的解析逻辑重要得多。这不是偷懒,是把精力花在更有价值的地方。低耦合、高内聚的分层设计让我在后面每次新增功能时都省了很多事。
最后再分享一个实用习惯:给爬虫项目加上日志记录。我在采集函数入口和出口分别打了两条日志,记录时间、股票代码、成功与否、耗时。这个日志平时看着没什么用,但系统出现问题的时候,它是唯一能帮你快速定位是网络原因、数据源异常还是自己代码bug的信号。没有日志的爬虫项目,出问题基本靠猜,有日志的项目,排查起来是有手就行的活。