ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零搭建Tick级实时行情面板:数据采集到前端渲染全链路实践

从零搭建Tick级实时行情面板:数据采集到前端渲染全链路实践 做个人行情面板这个想法其实是被一次盯盘卡顿逼出来的。打开某软件看集合竞价结果分时线一顿一顿的五档盘口刷新居然比行情慢了两三秒。当时就想着手里的 tick 级别数据到底该怎么落地能不能自己搭一个“tick-stock-panel”来真正看清市场每一笔成交的节奏。顺着这个标题拆下去它并不复杂但需要你把数据采集、实时推送、前端渲染这条链路全部打通。这个面板的定位很明确一个以逐笔成交数据为核心的实时股票行情监控面板适合量化研究员盯 tick 数据、散户做短线盘口分析或者像我这种纯粹对行情底层好奇的技术玩家。它解决的核心问题是当行情推送达到每秒几十甚至上百条时浏览器里怎么稳定、低延迟地展示出这些变化而不是成了卡顿的幻灯片。1. 拆解tick-stock-panel这到底是个什么项目1.1 先从“tick”说起逐笔行情和数据粒度在讲项目之前得先把 tick 这个概念掰开。tick 在股票市场里有两个层面的含义一是行情变化的最小单位比如 A 股常见的最小报价单位是 0.01 元二是逐笔成交记录也就是交易所每产生一笔真实成交就形成一条数据里面包含成交时间、成交价格、成交数量、买卖方向等。我们平时在软件上看到的“分笔成交明细”就是 tick 数据的最直观呈现。为什么盯 tick 比盯分钟线更累因为分钟线是汇总过的一根 K 线吞掉了里面几百笔成交的细节。tick 数据则保留了一笔一笔的原始轨迹你能看到大单是怎么拆着进场、价格是怎么一步步被堆上去的。tick-stock-panel 这个名字本身就暗示了作者要的是一个比普通分时更底层的观察窗口而不是简单的 K 线图。但 tick 数据也带来一个现实问题数据量太大。A 股一天的逐笔成交单只活跃股可能就有几万笔全市场加起来是千万级别。如果直接全部推给浏览器再强大的前端也撑不住。所以在设计面板时不能无脑接原始数据得在数据源端做预处理、聚合、抽样才能让面板既流畅又有足够信息量。1.2 面板的需求定位盯盘工具和量化监控台之间我见过不少新手拿着 tick 数据就往前端塞结果页面直接冻结。这里要区分两类典型的 panel 需求场景第一类是短线盯盘面板。核心诉求是低延迟、高刷新。界面不需要花哨但必须保证五档盘口、最新成交价、买卖队列变化能在 100 毫秒内反映出来。用户盯着的是价格跳动的节奏和盘口挂单的变化。第二类是量化监控台。核心诉求不完全一样它更关心 tick 数据的连续性、完整性和落库质量。面板只是辅助关键是数据流有没有断、有没有乱序、有没有因为网络抖动导致成交信息缺失。tick-stock-panel 在我的理解里更偏向第一种但它必须为第二种留好接口。也就是说面板的数据层不能只是实时展示完就丢掉最好同时落一份本地存储方便事后复盘。这个设计决策直接影响后面数据管道的搭建方式。1.3 技术选型为什么不是现成的开源方案可能有人会问Grafana、Superset 不都能做监控面板吗非要自己写一个吗说实话现成的面板工具做时序数据可视化确实很强但你套进去就会发现它们对 tick 数据这种高频、不规则、强交互的行情展示并不友好。逐笔行情的盘口数字是每秒在变浏览器需要高频局部更新而不是整页轮询刷新。另一个考虑点是私有化。很多行情源有鉴权、有私有协议甚至数据本身就不适合经过第三方平台。自己写后端从数据接入到 WebSocket 推送完全可以控在自己的手里。虽然工作量大了但换回来的是自由度想加一个“大单累计买入量”视图、想自定义告警规则、想按自己的逻辑计算主力资金流向这些在通用面板里都要绕很多弯路。所以我最终定的技术路线是Python 做数据采集和后端推送、Redis 做临时缓存、WebSocket 做实时通道、Vue3 Canvas / ECharts 做前端面板。这套组合的养护成本比想象中低。2. 数据层设计行情源接入和tick处理管线2.1 行情数据源怎么选公开接口、延续订阅还是券商Level-2数据源是整个面板的地基这一层选错了后面全是白搭。常见的选择有三类我按可靠程度和成本拆开讲。第一种是免费公开的行情接口比如常见的财经网站提供的分时接口。这类接口通常会提供 5 秒或 3 秒刷新一次的快照数据包含最新价、累计成交量、五档盘口。优点是不花钱、接入快缺点也明显数据不是逐笔级别存在延迟和一定程度的聚合只能算“准 tick”。第二种是券商或行情服务商提供的 Level-1 行情订阅一般通过 TCP 长连接推送。它能做到毫秒级推送数据也比较规范包含连续成交信息但可能需要相应账户权限。个人开发者如果手头有券商账户可以尝试从服务商那申请接口。这一档已经足够支撑 tick-stock-panel 的大部分功能。第三种是 Level-2 行情也就是真正的逐笔委托和逐笔成交数据。这是最理想的 tick 来源但数据权限门槛高、费用也不低。对个人项目来说前期不建议一上来就接 Level-2先用 Level-1 快照或公开接口跑通架构后面再平滑升级。这个思路很重要先让面板动起来再追求数据精度。2.2 数据预处理清洗、去重、排序与频率控制原始行情数据是粗糙的。我接入后发现几个典型问题重复推送、时间戳乱序、字段空缺。如果这些脏数据直接进 Redis 再推给前端面板上的数字会闪来闪去价格还会回跳盯盘体验很差。所以数据接入层必须做一个 pipeline。第一步是去重用“(股票代码 交易所时间戳 成交序号) ”作为唯一键建立窗口去重第二步是时间戳排序因为数据从上游到达本地后可能不是严格按时间递增的尤其多路连接并发接收时更明显第三步是丢弃明显异常的数据比如成交价为负、盘口卖一价低于买一价这类非法情况。频率控制也是容易被忽略的点。有些公开接口返回的频率不高但推送层会做重复补发导致前端每秒收到 20 次完全一样的数据。我通常会在处理管线里加一个“值变化过滤”模块——只有价格、累计成交量、盘口发生真实变化时才往 WebSocket 推。这能极大降低前端渲染压力也减少网络带宽消耗。下面是我在采集端用的简易处理思路用 Python 写的伪代码展示核心逻辑import redis from datetime import datetime r redis.Redis(hostlocalhost, port6379, db0) def process_tick(raw): # 唯一键去重相同 tick 直接丢弃 dedup_key ftick:{raw[code]}:{raw[seq]} if r.set(namededup_key, value1, nxTrue, ex5): # 时间戳排序用 Redis 的有序集合做临时时间窗 r.zadd(fpending:{raw[code]}, {raw[timestamp]: raw[timestamp]}) # 值变化过滤只对比最新推送的快照哈希 last_key flast:{raw[code]} last_snapshot r.get(last_key) current_snapshot f{raw[price]}:{raw[volume]}:{raw[bid1]}:{raw[ask1]} if current_snapshot ! last_snapshot: r.set(last_key, current_snapshot) return raw这段代码看起来简单但它把多路 tick 流里最常见的三个问题一次性解决了去重、排序窗口的雏形、无效重复推送的拦截。Redis 在这里不是当数据库用而是当“数据缓冲垫”它的原子操作和高并发读写在处理高频行情时非常顺手。2.3 存储取舍内存快照、Redis缓存和历史归档实时面板不需要把所有历史数据都加载到内存。我的做法是三层存储分工第一层是 Redis保存每个股票的最新快照和近 N 分钟的 tick 缓冲。面板刚打开时不需要等服务端推送而是直接从这层拿最近一段数据回放让页面秒开。第二层是即时归档tick 数据按“日期 股票代码”分目录以 CSV 或 Parquet 格式落盘。这里要注意归档动作不能阻塞实时推送所以最好丢给后台任务队列异步处理。第三层是复盘分析用的本地 SQLite 或 ClickHouse。如果项目规模不大SQLite 就够了但注意 tick 数据量上来后SQLite 写入锁会比较烦人数据量大时建议直接上 ClickHouse它的列式存储对这类时序数据非常友好。在这个阶段容易犯的错是想把所有 tick 都塞到 Redis 里供前端查询。Redis 虽然快但内存不是无限的而且 tick 数据的价值在于区间分析不在单条随机查询。所以 Redis 里只留窗口数据长周期数据一律走文件或数据库这个边界要划清楚。3. 后端实时通道WebSocket推送的正确姿势3.1 为什么选WebSocket而不是轮询传统的心跳轮询做法是前端每隔几秒请求一次最新行情看起来也能实现“实时”但它有几个硬伤一是有固定频率延迟行情永远晚半拍二是每打开一个页面就多一路请求股票数量一多请求频率成倍上涨三是不方便做增量推送每次都返回全量快照浪费带宽。WebSocket 的优势在于它一旦建立连接就是一条全双工通道。服务端可以主动把最新 tick 数据“塞”给浏览器前端只需要监听 message 事件。订阅多个股票时也只需在建立连接后发一条订阅协议服务端就能按需推送。但 WebSocket 也不是没有坑。最典型的是代理超时和连接假死。如果中间经过 Nginx默认的超时时间可能只有 60 秒连接一断前端还没反应过来行情已经停了。我的解决方法是前端做心跳监测每隔 30 秒发一次 ping服务端返回 pong同时 Nginx 的 proxy_read_timeout 调到 300 秒以上双保险。3.2 后端推送架构FastAPI Redis 发布订阅我在后端用的是 FastAPI它天然支持 WebSocket异步模型也非常适合行情这种高并发、短消息场景。整体链路是采集管道把处理好的 tick 数据写入 Redis同时执行 publish 操作FastAPI 的 WebSocket 处理器订阅 Redis 频道收到消息后立刻推给当前连接的前端。这里有个细节不能每来一条 tick 就全量推送所有前端连接那会把服务端忙死。正确做法是维护一个订阅关系表也就是说每个 WebSocket 连接知道自己订阅了哪几只股票服务端只向相关连接推送对应股票的数据。from fastapi import FastAPI, WebSocket, WebSocketDisconnect import redis.asyncio as aioredis import json app FastAPI() r aioredis.from_url(redis://localhost:6379) class ConnectionManager: def __init__(self): self.connections {} # 股票代码 - set of WebSocket async def subscribe(self, code: str, ws: WebSocket): if code not in self.connections: self.connections[code] set() self.connections[code].add(ws) async def unsubscribe(self, code: str, ws: WebSocket): self.connections.get(code, set()).discard(ws) manager ConnectionManager() app.websocket(/ws/stock) async def stock_ws(ws: WebSocket): await ws.accept() try: while True: data await ws.receive_text() payload json.loads(data) # 前端发来的订阅协议{action: subscribe, codes: [600000, 000001]} if payload[action] subscribe: for code in payload[codes]: await manager.subscribe(code, ws) except WebSocketDisconnect: # 清理连接 pass async def redis_listener(): pubsub r.pubsub() await pubsub.subscribe(tick_channel) async for message in pubsub.listen(): if message[type] message: data json.loads(message[data]) code data[code] if code in manager.connections: for ws in manager.connections[code]: await ws.send_text(json.dumps(data))注意上面的代码里WebSocket 端和 Redis 监听端是同时运行的两个异步协程。FastAPI 的启动事件里要把 redis_listener 挂上去。实际项目中还要处理订阅后首批数据回放也就是连接刚建立时先去 Redis 拿该股票最近的快照推给前端否则面板要等下一次 tick 推送才有内容体验会差一截。3.3 推送协议的字段设计WebSocket 推送不是什么数据都往 JSON 里塞。字段越多前端解析越慢网络包越大。我最终把行情消息设计成紧凑格式字段含义code股票代码ts行情产生时间秒级时间戳px最新成交价vol本日累计成交量手b1p/b1v买一价/买一量a1p/a1v卖一价/卖一量b2p..b5v五档盘口可省略时用二级价格数组前端拿到这种结构能直接更新表格和图表不用再做字段映射。这里再提醒一句批量推送比逐条推更高效。如果短时间内累积了多笔 tick可以先合并成一个数组再推送前端一次渲染比反复高频更新更流畅。代价是延时会略高一点但 200 毫秒内的批量推送体感上还是“实时”的。4. 前端面板实现让行情在浏览器里飞起来4.1 自研UI组件还是用现成图表库前端的核心矛盾是既要高频更新又要保持交互流畅。tick 数据的特性决定了我们不能每来一条消息就把整个表格重新渲染一遍。很多现成 UI 组件库在data变化时会重新生成大量 DOM 节点几百次更新后页面就开始卡。我的做法是行情表格用原生 DOM 操作来做局部更新只为需要变化的单元格更新文本内容这比框架的双向绑定高效得多。分时图和盘口分布图则配合 Canvas 来画利用 Canvas 的局部重绘能力每次只重绘数据点所在区域而不是整张图。图表库方面ECharts 虽然好用但它对高频数据的优化其实有限。如果你要推的是每秒几十次的更新建议先试 ECharts 的性能如果还扛得住就直接用扛不住就得用轻量的 ZRender 或直接手绘 Canvas。这里没有银弹只有实测数据说了算。// 轻量级的表格更新方式避免整表重渲染 function updateQuoteRow(row, tick) { // 只更新价格变化的那一格 const priceEl row.querySelector(.price); const lastPx parseInt(priceEl.dataset.px || 0, 10); const newPx tick.px; if (newPx ! lastPx) { priceEl.textContent newPx.toFixed(2); priceEl.classList.toggle(price-up, newPx lastPx); priceEl.classList.toggle(price-down, newPx lastPx); priceEl.dataset.px newPx; } // 只更新买一卖一文本 row.querySelector(.bid1).textContent ${tick.b1p} / ${tick.b1v}; row.querySelector(.ask1).textContent ${tick.a1p} / ${tick.a1v}; }这个例子看着简单却是面板流畅度的关键。行情的展示不在乎你用多酷炫的框架而在于你是不是精确地知道哪些 DOM 节点需要变、哪些不能动。4.2 分时图和盘口深度图的绘制思路分时图本质上是一条价格随时间变化的折线。用 Canvas 画的时候要解决两个问题一是怎么把高频数据平滑地画出来二是怎么避免无意义的重复重绘。我在项目里采用的做法是服务端推送的数据先进入一个前端缓冲区Canvas 渲染使用 requestAnimationFrame 来控制帧率保证每秒最多重绘 30 次。不管数据多快渲染频率是稳定的这样 CPU 占用率不会飙高。窗口内的数据点如果太多就做降采样比如每 5 个 tick 取一个平均值视觉上依然是平滑曲线细节损失对分时图来说不明显。盘口深度图推荐用“阶梯面积图”来画。十档盘口在买侧展示为买一到买十的累计挂单量卖侧同理。把买卖两侧的累计量绘成两条对称的阶梯曲线一眼就能看出买卖双方的挂单重心偏在哪边。这个视图在普通行情软件里通常收费但自己做面板的好处就是想画就画。4.3 前端连接管理订阅、重连、退订前端连接管理是我觉得做得最值的模块。如果在页面刷新时没有退订旧连接服务端就积压一堆幽灵连接白白占用资源。所以我在页面卸载前统一走一遍退订协议前端向服务端发送{action: unsubscribe, codes: [...]}服务端把这些代码从连接管理器的字典里移除。网络波动导致的 WebSocket 断开是逃不掉的。重连策略需要做得聪明一点不能断开后立刻重连那样如果服务端正在重启前端会陷入疯狂重连的死循环。我的策略是指数退避加最大重连次数第一次等 1 秒第二次 2 秒第三次 4 秒最多等 30 秒。重连成功后前端要重新发送订阅协议并请求最近快照回放。另外页面上一定要有一个连接状态指示。别小看这个细节我在实际使用中就遇到过行情停了好几分钟结果因为大盘没波动页面看起来还是正常直到我主动刷新才发现 WebSocket 已经断了。加一个“已连接/连接断开/重连中”的状态灯能省很多不必要的怀疑。5. 常见问题与排查技巧实录5.1 行情面板常见故障速查表现象可能原因排查方向面板价格长时间不动WebSocket 断开且重连失败看浏览器 Network 面板检查 ws 连接状态、心跳日志数据更新但页面闪烁前端全量重渲染检查是否有 shouldComponentUpdate 或局部 DOM 更新逻辑服务端 CPU 突然飙高推送风暴单条推送循环里做了繁重 JSON 序列化用批量推送替代逐条推送序列化结果缓存盘口数字回跳tick 乱序检查数据层的时间戳排序窗口是否生效连接一直断开重连Nginx/网关超时或后端崩溃查看 proxy_read_timeout、worker 进程日志Redis 内存膨胀tick 在 Redis 里堆积未清理确认过期时间设置定期清理缓冲窗口这张表是我自己踩坑总结出来的每个问题都真实发生过。其中“推送风暴”这个问题最阴险因为它不长驻只在行情剧烈波动、个股迅速拉升时出现平时测试根本发现不了。所以我在开发阶段特意写了一个模拟高波动的压测脚本每分钟往推送管道灌 6000 条 tick看服务端能不能扛住前端会不会掉帧。5.2 开发期调试的独门技巧写行情面板数据和调试工具都得自己造。我发现最有用的调试技巧是录制真实行情流做成回放文件。比如把一天某只活跃股的真实 tick 数据全部落盘之后的开发测试都用这个文件作为固定数据源回放这样每次调试的输入是完全一致的问题复现和修复验证都特别有确定性。另一个技巧是闹钟日志。按股票代码分别打日志每当某只股票出现价格大于前值 1% 的跳变时就把最新 tick、前一条 tick、两条 tick 的时间戳差都记录下来。这个“大跳变日志”是检验数据源质量、定位丢数据的宝藏比看一堆 DEBUG 日志直观得多。还有一个容易被忽略的问题是前端时间线。前端展示时应该用服务器推送时间还是本地接收时间我用的是前者也就是 tick 自带的交易所时间戳。这样即使 WebSocket 有延迟画出来的分时图也是真实的时间轴不会出现“未来价格”的错乱感。5.3 性能优化的几点心得第一能合并的消息不要拆开发。前端每收到一条消息都要执行 JSON 解析、路由分发、DOM 更新这是成本很高的链路。把同一股票的多个 tick 攒成一个数组延迟增加很小性能提升却很大。第二行情快照和增量要分离。页面初始加载时先拿一个全量快照后续只接收增量更新。这个策略能大幅减少首屏等待时间。对于分时图快照里带上近 240 分钟的历史分时点前端一次画完就行不需要从零开始累积。第三订阅关系要有上限。个人项目不要贪心一次性订阅几千只股票的推送后端的连接字典会很大前端表格渲染也会卡。把功能设计成“核心关注池 普通池”核心池最多 50 只普通池交叉轮询这个体验和资源消耗比全线铺开好太多。6. 一些过来人的实操体会这次 tick-stock-panel 从零搭起来最大的体会就是“实时”这两个字不是一个点而是一条完整的链路。数据源延迟再低后端推送再快只要前端表格用了一次低效的重渲染整体体验就掉链子。链路里任何一环慢用户感知的就是卡、慢、不实时。如果你也想自己搭这么一个面板我建议先不要追求 Level-2 那种极致数据用免费接口或 Level-1 快照先跑通全链路。把采集、清洗、推送、渲染、重连这一套流程理顺再考虑上更高频的数据。当你把千篇一律的行情从黑盒变成自己能掌控的一条管线时那种“看懂了”的清晰感是别人给不了你的。最后分享一个细节给面板加个本地快照落盘功能。我一开始没做后来有一天复盘策略时发现当时的 tick 数据已经没了想回看某只股票某一分钟的逐笔成交完全没有证据。从那之后所有 tick 都异步落一份本地存档不占实时链路却给后续分析留了很大余地。做行情工具的乐趣也正在于此你永远能在下一轮迭代中把之前发现的小问题补得越来越漂亮。
返回列表