☰
3D股票曲线可视化:从Three.js到Plotly的多维对比实现
2026/10/2 15:22:47 网站建设 项目流程

做过股票数据分析的朋友,应该都遇到过这种尴尬:想在一张图里同时看清楚几只股票近一年的走势对比,结果2D折线图堆了七八条线,颜色一多,几条行情曲线缠在一起,根本分不清谁是谁,更别说叠加成交量和均线了。我当初就是因为被这种“线缠线”的图折磨得够呛,才认真折腾起了股票曲线的3D可视化——把时间、价格、股票本体分别放到三个轴上,让每一条线都活在独立的空间位置上,视觉上彻底错开。这篇文章不是我拿PPT讲概念,而是把整个实现过程里踩过的坑、试过的方案、最后跑通的代码全部摊开,从坐标系设计到Three.js落地,再给出一套Python+Plotly的快速原型方案,最后聊聊我在性能优化和交互设计上的真实体会。

1. 被2D图表逼到3D:股票曲线可视化的真实需求在哪

很多人一听到“3D可视化”就觉得是噱头,是炫技,其实不完全是。至少在我做的这个项目里,3D是实打实解决了一个2D场景下很难绕开的问题——同一个面板里同时呈现多条股票曲线的走势,并且还要让观众一眼就能锁定某一只股票的完整形态。

1.1 2D图表的容量瓶颈

2D图表天生只有两个连续轴,通常就是时间和价格。如果是单只股票,这两个轴完全够用,成交量用副图,MACD再做一层副图,一层一层往下堆,逻辑清晰,大家也都习惯了。但问题出在“多股票对比”上:当你把十只股票画在同一个坐标系里,X轴共享时间,Y轴共享价格,那些股价不在一个量级的股票就会被强行压在一起。贵州茅台两百多块的时候,旁边可能还有一只三块五的股票,两者画在同一张图里,低价股的波动曲线几乎变成一条水平线,任何细节都丢失了。

就算你对每只股票做归一化处理,全部换算成区间涨幅,曲线之间依然会大量重叠。每年A股四千多只股票里,走势高度相关的板块随便挑出三五只,趋势图拉到一起简直像一捆毛线。2D图表不是不能放多线,而是它把“相互遮挡”这个问题完全抛给了用户,只能靠交互层的图例开关、悬停高亮来补救,本质上是把画布做薄了。

1.2 3D到底能多塞哪些维度

3D可视化的核心价值,就是多了一个可以用作“类别区分”的Z轴。在做股票曲线3D化的时候,最常见的语义分配是这样:

  • X轴:时间,向前延伸。
  • Y轴:股票价格(或区间涨幅)。
  • Z轴:不同股票,或者同一股票的不同指标。

这个设计的直接效果是:每只股票的曲线就像一堵墙里的独立骨架,彼此错开一个深度位置。只要相机角度合适,你能同时看见“每只股票的走势全貌”和“多只股票之间的相对位置关系”。这不是单纯的炫技,而是信息容量的提升。

再多说一句,Z轴还可以用来放“非时间维度的指标数据”,比如用Z轴表示波动率,X轴放时间,Y轴放价格,加上颜色映射,这样能在一张3D空间里看到一个股票的动态风险变化。我实际项目里做的就是前一种——多股票曲线堆叠,因为展示效果更直观,也方便复现。

1.3 谁需要这种东西,它输出的价值是什么

我把这个需求拆成三类人:

  • 做投研分析的个人或机构分析师:需要快速比较一组候选股票的走势形态,3D画面可以用空间纵深代替记忆,不需要反复切换图例。
  • 前端数据可视化开发:接到“股票行情大屏”需求,想找一个能让领导觉得“看得清楚”的展示方案。
  • 对图形学感兴趣的技术爱好者:想知道行情数据怎么变成3D世界里的曲线,坐标如何映射,交互如何实现。

如果你只是看单只股票的K线,2D一定更高效。只有当“对比”和“全局形态感知”成为核心诉求时,3D才是更合理的选择。做技术选型最怕什么都想上3D,先把需求想透,再动手不迟。

2. 数据准备与坐标系设计:先想清楚三个轴的语义

这个环节看起来不起眼,但它是整个项目的地基。我见过太多人兴致勃勃把数据塞进3D引擎,结果渲染出来的画面完全不是那么回事,问题几乎都出在坐标系设计上。

2.1 行情数据的获取与清洗

先解决数据从哪来。公开渠道的行情接口很多,国内外的都有,比如用Python可以很方便地拉取股票历史行情,常见字段无非就是日期、开盘价、收盘价、最高价、最低价、成交量。我自己做原型时通常用Python一次性把数据抓下来存成CSV,后续不管是走Three.js还是Plotly,都从同一份数据源读。

数据清洗这一步容易被忽略,但它直接影响最终图形质量。需要处理的典型问题包括:

  • 停牌导致的日期缺失:不同股票的交易日不总是对齐,需要基于全市场的交易日历做一次 outer join,缺口部分填充空值。
  • 价格异常值:前复权处理。如果股票发生过除权除息,原始价格会出现断崖式跳空,不处理的话图形里会出现不合理的垂直落差。
  • 上市时间不一致:有些股票2015年就上市了,有些是2021年才上市。做多股票3D对比时,要么统一截取共同区间,要么保留各自的完整区间并且缺失部分用空数据带过。我建议统一截取一段大家都存在的时间窗口,否则Z轴纵深上一会长线一会短线,视觉上会显得很乱。

如果你只拉一只股票做3D曲面或者K线柱体,清洗压力会小很多,但只要你做“多股票对比”,这一步必须认真。

2.2 多股票的归一化策略

接下来是坐标系设计里最核心的一步:不同股票价格量级差异巨大,怎么放在同一个Y轴上?

我前面说过,如果直接把原始价格丢进去,触发价十几块的股票和一百多块的股票共用一个Y轴,低价股的曲线会平坦到完全失去信息量。常用的解决办法有三种:

  1. 区间涨幅归一化:把每只股票的起始价格作为基准点,后续价格全部除以基准价再减一,得到的是“从起点开始涨跌了多少百分比”。所有股票的起点都对齐到Y轴的0%位置。这样做的好处是方便比较相对强弱,但会丢失绝对价格的认知。

  2. 对数坐标:如果股票价格横跨几个数量级,直接用原始价格会让低价部分被压扁。取对数之后,倍率关系变成线性的,能改善很多。

  3. 分段错位:不归一化,而是在Y轴上为每只股票分配一个基准偏移段。股票A中心在80的位置,股票B中心在50的位置,然后用价格偏离中心值的大小来显示波动。这种方式适合展示“每只股票自己的形态”,不需要知道相对收益。

我实际做多股票3D时选的是“区间涨幅归一化”,也就是每只股票的曲线都从同一水平线出发。这样观众一眼能看出哪只股票涨得多、哪只跌得深,不同股票之间还能在Z轴纵深的帮助下看清先后顺序,比较效果很直观。

2.3 从DataFrame到三维坐标点

数据准备好之后,就要把每一行数据映射成3D场景里的一个坐标点。假设有三只股票stock_a、stock_b、stock_c,数据时间是2023年1月到2024年1月,每个交易日一个点,坐标映射逻辑如下:

  • X:交易日序号。因为X轴需要等距,直接用日期转时间戳会有周末和节假日造成的坑,如果照常画在X轴上,周一到周五有5个点,接着周末跳空,下一周的多个交易日之间距离不相等,整条曲线会呈现“锯齿状密度不均”。所以X轴统一用“第几个交易日”的序号,再在标签层显示出对应的真实日期。
  • Y:区间涨幅或价格。
  • Z:股票索引。这里要留一个关键间距,间距值是这只股票曲线波动的最大幅度乘以1.5左右。如果Z轴间距太小,不同股票在视角旋转时还会遮挡;间距太大,场景会显得空,浪费了屏幕空间。

把所有映射算完,输出成JSON数组,结构大概是每个点一个三元组,加上一个颜色字段。到这里,3D世界需要的素材就齐了。

3. 用Three.js搭一套可交互的3D股票曲线

Three.js是Web端做3D图形的实际标准库,底层走WebGL,浏览器直接打开就能跑,不需要额外安装运行时。我的方案里,前端负责渲染和交互,数据通过JSON传入。这一整套下来,能实现的效果是:页面加载后看到三只股票的3D曲线悬浮在空间里,可以用鼠标拖拽任意旋转视角,滚轮缩放,鼠标悬停在曲线上时,显示该点的股票名称、日期和价格。

3.1 场景搭建与基础坐标系

先创建一个基础的Three.js场景。Camera用透视相机,加了OrbitControls之后就能获得轨道控制器,鼠标拖拽旋转、右键平移、滚轮缩放全搞定。以下是基础骨架代码:

import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x111827); const camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 1000 ); camera.position.set(30, 25, 35); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.08;

一个容易忽略的细节是场景的轴向显示。股票图表的三个轴都有明确业务含义,X轴是时间,Y轴是涨跌幅,Z轴是股票。我直接用Three.js的AxesHelper做辅助轴,默认的红绿蓝颜色分别对应X/Y/Z,但业务上最好自己在贴图或者标签里写清楚“时间/涨幅/股票”,否则用户容易迷惑。我在场景里额外加载了一个TextGeometry的三轴标签,分别标注Time、Return、Stock,这几行文字的成本不高,但信息传达效率提升非常大。

3.2 曲线生成:Line还是Tube

Three.js画一条3D曲线的入门做法是BufferGeometry加Line,性能极好,代码也短。但在实际渲染股票曲线时,单纯用Line有个问题:线太细,在3D空间里旋转视角后很难看清走势,而且没有体积感,光线照不到它。对着一条细线看几十秒,眼睛特别容易累。

我后来改用TubeGeometry方案。TubeGeometry可以让曲线变成一根有粗细的管子,本质是把一条中心线膨胀为带半径的圆柱体表面。它的效果明显更“实”,配合环境光后会有立体感。代价是顶点数量暴增——一条股票曲线如果有250个交易日的点,Line只需要250个顶点,而TubeGeometry会沿着路径分段,每段再生成数十个环绕顶点。股票曲线通常不止一条,所以这里需要做一个取舍。

我的建议是:原型阶段用Line,展示和汇报场景用Tube。如果你想两者兼顾,可以把粗细做成uniform,只在当前选中高亮的那条曲线用Tube,其余保持Line,交互效果反而更好。示例代码如下:

function createCurveLine(points) { const curve = new THREE.CatmullRomCurve3(points); const geometry = new THREE.TubeGeometry(curve, 200, 0.12, 8, false); const material = new THREE.MeshPhongMaterial({ color: 0x60a5fa, emissive: 0x1e3a8a, shininess: 30 }); const mesh = new THREE.Mesh(geometry, material); return mesh; }

注意,这里我用CatmullRomCurve3对点集做了样条插值。原始股票数据只有每个交易日的收盘价点位,直接连起来会呈现折线感,不够平滑。用样条插值之后,曲线自然圆润,视觉上更接近“连续变化的趋势”。不过插值需要谨慎:如果原始点很少,插值会过度圆滑,可能让人误判价格转折;如果原始点有噪声,插值也会把噪声放大。一般来说,股票历史日线数据的点足够密,Catmull-Rom的张力控制在默认值就行。

3.3 曲线底部投影:加一层辅助参考线

有了3D曲线之后,我从一个实际演示场景里发现:光看悬浮在半空中的曲线,判断具体数值仍然很难。人的视觉系统对“悬浮高度”的感知远远不如“相对于参照物的投影位置”。

所以我给每条曲线加了一个投影——把曲线上的每个点投影到Y=0的平面上(也就是去掉Y坐标,保留X和Z),再形成一条细线。观众可以从上往下或者从侧视角看到这条投影线,当曲线升高或降低时,投影线依然保留在基准面上,对比起来就像看心电图的地面投影一样直观。这个操作还可以延伸为在投影面上画出K线的实体柱或收盘价阶梯,方便确认具体数值。

实现投影的代码思路很简单:复制点位数组,把每个点的y值改成0,然后用同样方式生成Line。

function createProjectionLine(points, color) { const projected = points.map(p => new THREE.Vector3(p.x, 0, p.z)); const geometry = new THREE.BufferGeometry().setFromPoints(projected); const material = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.35 }); return new THREE.Line(geometry, material); }

这条投影线一加,整体的空间感和可读性都明显提高。它还有一个隐藏好处:不同股票的Z轴位置不同,投影线在平面上自然形成前后几条平行的参考轨迹,用户能直观感受到“哪只股票在前面,哪只股票在后面”,立体布局的感知更清晰了。

3.4 交互细节:悬停采样与信息面板

静态的3D场景做得再好,缺少交互就像一个模型摆件。我加的第一个核心交互是鼠标悬停显示数据点信息。由于TubeGeometry经过插值后,几何体的顶点并不直接对应原始交易日数据,如果直接去做射线检测拿到顶点坐标,拿到的可能不是真实的行情值,而是插值出来的中间值。

这里我采用了两个并行的数据结构:一个数组保留原始数据点,另一个数组保留对应三维坐标。做射线检测时,检测的是隐藏的Line对象(它保留原始顶点),而不是显示用的Tube对象。隐藏Line不可见,但参与射线检测,这样命中时能拿到原始点的索引,回头去原始数据里取日期和价格,显示完全准确。

const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); function onMouseMove(event) { mouse.x = (event.clientX / window.innerWidth) * 2 - 1; mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(mouse, camera); const hits = raycaster.intersectObjects(hiddenLines); if (hits.length > 0) { const pointIndex = hits[0].index; updateInfoPanel(stockMeta[pointIndex]); } }

这个方案的代码量不大,但它避开了“插值点不等于原始点”的经典陷阱。很多初次做3D图表的人没意识到这一点,做出来的悬停数值偶尔偏差几十块钱,还以为是精度问题,实际上就是射线检测命中了插值点。

3.5 动画:从平面到立体的入场效果

最后让整个场景动起来。我做了一个入场动画:3D曲线刚开始是全压缩在Z轴一个平面上的一条平面曲线,2秒内逐渐在Z轴方向上展开,变成多层的3D形态。这个动画配合上相机视角的缓缓旋转,观众先看到的是熟悉的2D股票折线,接着画面“立体化”,新的维度逐渐展开,信息冲击力很强,也不会让第一次看的人迷失在陌生透视里。

实现方式极其简单:动画循环里把每条曲线的Z轴坐标乘以一个随时间从0到1变化的factor,重新更新几何体位置即可。也可以用GSAP之类动画库配合tween实现,但如果你不想引入额外依赖,直接在requestAnimationFrame里用线性插值算就行。

4. 快速原型方案:Python + Plotly 半小时出图

如果你不想陷进WebGL的世界,只是想快速验证“3D股票曲线”这个想法是否可行,或者需要在短时间内出一张静态效果图给同事看,我强烈推荐先用Python+Plotly做验证。Plotly的图类型里有一个scatter3d,画多条3D折线几乎是零门槛,配合它的离线导出功能,甚至可以直接生成独立的HTML交互页面。

4.1 利用go.Scatter3d画多股票曲线

Plotly的用法很直白,把数据整理成DataFrame之后,直接传给go.Scatter3d。核心代码大致是:

import plotly.graph_objects as go import pandas as pd # df: columns = ['symbol', 'trade_date', 'normalized_return'] fig = go.Figure() for symbol in df['symbol'].unique(): sub = df[df['symbol'] == symbol] fig.add_trace(go.Scatter3d( x=sub['trade_index'], y=sub['normalized_return'], z=[symbol_index] * len(sub), mode='lines', name=symbol, line=dict(width=6, color=colors[symbol_index]) )) fig.update_layout( scene=dict( xaxis_title='Date Index', yaxis_title='Return (%)', zaxis_title='Stock' ), margin=dict(l=0, r=0, b=0, t=40) ) fig.write_html('stocks_3d.html')

这里的z如果不同股票给不同数值,就可以实现和我前面用Three.js做的多股票堆叠完全一样的效果。Plotly自带的交互已经不错了,有缩放、旋转、悬浮提示。我实际做的时候发现,Plotly对于几千个数据点毫无压力,上到几万个点也能保持流畅,但如果一次性塞几十万点,建议抽稀后再画。

4.2 用曲面图做波动率透视

3D散点折线还只是第一个层次。Plotly的另一个利器是go.Surface,适合做“波动率透视”。把X轴设为时间,Y轴设为区间收益分段,Z轴设为一支股票在该时间段和收益区间内的成交量或波动率。这样形成的是一个3D曲面,凸起越高的地方代表市场在该区域交易踊跃或波动激烈。

举个例子:贵州茅台近一年日线数据,每天能算出一个收益率和对应成交量。我把收益率离散成20个分箱,时间轴按月分箱,每个格子的Z值填成交量的对数。渲染出来的曲面不会特别平滑,但能看到“某几个月份某几个收益区间交易量特别大”,这是2D热力图做不到的立体效果。你在演示时旋转曲面,观众能更直观看到风险偏好的聚集。

fig = go.Figure(data=go.Surface( x=time_bins, y=return_bins, z=volume_grid, colorscale='Viridis' )) fig.update_layout(scene=dict( xaxis_title='Month', yaxis_title='Return Bin', zaxis_title='Log Volume' ))

4.3 哪个方案更适合落地

对比下来,我的建议是:

  • 任务紧急、只要验证效果:直接用Plotly,半小时出图,改数据也方便,写HTML给别人看非常省事。
  • 要做成产品功能、嵌入自己的前端系统、需要高度定制交互:用Three.js,可控性更强,性能上限也更高,但前期开发量明显更大。
  • 对图形效果要求极高、后续要加数据大屏整体特效:Three.js起步,Canvas纹理、后期特效、光照阴影这些都可以扩展。

这两个方案我都在实际项目里用过,没有谁绝对碾压谁,关键看你的约束条件——时间紧不紧、交互定制到什么程度、最终要在什么环境里跑。

5. 3D股票图表的折腾记录:那些不吐不快的坑

这部分是我最想写的内容。整个项目做下来,3D图表真正难的不是三维数学,而是很多看似不起眼的细节,每一个都能让你浪费一下午调试。

5.1 视角遮挡比想象中严重

3D图表的通病是“前景挡后景”。三只股票放在不同的Z深度,默认相机角度一偏,前面的曲线可能正好挡掉后面曲线的关键拐点。观众想看的那个被挡住的波峰,无论怎么旋转都找不到合适的角度。

我做了两个补偿措施。第一,给每条曲线设置不同的颜色,并且把后面曲线的材质设成半透明,这样即使重叠也能透出后方形态。第二,在场景里加一个“亮度跟随深度”的效果,远处的曲线调暗一点,近处的亮一点,人为制造景深感。这本质上是用视觉心理学强化深度线索,比单纯依赖几何遮挡要靠谱得多。

还有一个更实用的做法:在三维场景旁边固定放一个“当前相机视角”的小地图或者俯视图,观众能知道自己在从哪个角度看这些曲线,不至于旋转半天迷失方向。这个小改动在小屏演示的场景里特别管用。

5.2 性能优化的三板斧

第一条曲线数据如果两三千个点,场景毫无压力。但如果你做的是全市场几千只股票的曲线堆叠,几百万个点直接在浏览器里画,帧率会掉到没法看。我的优化经验主要这三板斧:

  • 按需渲染:只有相机发生移动、旋转、缩放时才触发render,静止时跳过渲染帧,省CPU。
  • 数据抽稀:在视觉差异不大的时间区间内,每隔N个点采样一个点,曲线外观几乎不变,但顶点数量可以降到原来的十分之一。
  • 合并几何体:多条股票曲线的TubeGeometry逐一单独渲染会创建大量draw call,把它们按材质合并成同一个Geometry再渲染,性能可以从个位数帧率直接拉回60帧。

第二板斧抽稀这里有一点要提醒:抽稀会丢失日内细节,如果原始数据本身已是日线,再抽稀你会发现图形的锯齿消失了,其实是细节被吞了。建议抽稀倍数不超过4,而且只对“非当前高亮”的曲线抽稀,被用户悬停锁定的曲线保持全量数据渲染。

5.3 配色与标签的取舍

3D场景里配色比2D更苛刻。2D图表白底黑字怎么做都不难看,但3D场景有背景色、光照、透明度,还有多根曲线前后的遮挡,颜色一旦选不好,整个画面就是一团乱麻。

我的经验是:背景用深色(深灰蓝、墨色系),曲线用高饱和度但明度不刺眼的颜色,比如蓝、橙、青、紫。避免使用红配绿这种容易被色弱用户混淆的组合,同时在图例区用文字和符号辅助标注颜色。

标签的处理也要注意:文字放在3D空间里,旋转视角时很容易被曲线挡住。可选方案是把“股票名称”标签以Sprite的形式吸附在股票曲线末端,隔着多远都能看见;但一旦曲线数量超过5条,Sprite标签就全是密密麻麻的字,这时候更好的做法是只在“当前选中”的曲线上显示标签,其余一律隐藏。

5.4 业务解读:3D曲线好看,但别让它说谎

最后说一点业务层面的体会。3D可视化确实能让股票数据展现得更立体、更冲击,但如果观众不能快速定位数值,反而会降低信息传递效率。我做过一次小范围测评,找了几个不太熟悉3D图表的同学看同一组数据:2D折线图他们平均5秒内能说出“哪只股票涨了”,3D图则需要15秒左右,而且认知误差更大。结论是:3D适合做形态观察和横向感知,不适合做精确读值。

所以我最后的成品里,保留了2D的辅助视图:右上角一个小的2D窗口,同步展示当前选中股票的2D趋势线,精确数值在面板里直接显示。3D负责宏观展示,2D负责微观精读,这两个组合起来才是完整可用的工具。做数据可视化最忌讳为了酷炫牺牲信息准确性,如果你也在做类似项目,请务必把这个原则记在心里。

回到开头那个场景,我现在给朋友演示多股票走势时,已经习惯先把画面从平面拉升到立体,让曲线各自归位,再旋转半圈让他们看清纵深关系。每次看到大家“哦——”的一声,我就知道当初这个折腾是值的。技术上没有什么神秘的皇冠,就是坐标系、颜色、交互,以及无数个被遮挡和性能逼出来的细节优化。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询