☰
C#与ZedGraph绘制股票K线:从数据到交互的完整方案
2026/10/18 18:38:43 网站建设 项目流程

简介:一套基于C#的股票K线图开发示例,适合正在学习Windows桌面应用、数据处理与ZedGraph绘图库的开发者。项目包含完整可运行的股票行情监控程序:后端负责获取与解析行情数据,前端利用ZedGraph将开盘、收盘、最高、最低价映射为K线图形,并配有实时行情接口与自定义绘制控件,可作为金融类小工具或课程设计的起步模板。

压缩包共933个文件,约9.46MB;源码以424个cs文件为主,辅以11个csproj工程文件、50个dll库、66个resx资源文件,另有77个day历史数据、18个txt说明及13个exe可执行文件等,涵盖工程配置、运行环境与数据样本。包内包含StockMonitor、WYStockRealView等模块,目录结构可直接在Visual Studio中打开sln编译运行。

目前已有440人学习下载。通过这套代码,读者可以了解C#行情数据接入、对象映射、ZedGraph图表定制以及单元测试验证等实践细节,对照可运行程序加深理解。

1. 用 C# 和 ZedGraph 画股票 K 线:这条技术路线到底值不值得走

很多开发者第一次在 C# 桌面端画股票 K 线,都是先拖一个 ZedGraph 控件,然后按折线图的思路把收盘价塞进去,结果画出来是一根心电图,而不是一根根蜡烛。K 线图看着简单,但它的数据模型、坐标轴和交互跟普通曲线有本质区别,你需要的不只是“画线”,而是一套能承载开盘、收盘、最高、最低四重价格信息的绘制方案。本文讲的是一条经过验证的路径:用 C# 的 ZedGraph 控件配合 StockCurve 蜡烛类型,把股票 K 线从数据到交互完整落地,包含能直接运行的代码、关键参数和实际开发中反复出现的坑。适合正在做桌面行情客户端、选型图表控件、或者想快速跑通 K 线 Demo 但不想从零造轮子的开发者。

2. 先把数据弄干净:K 线数据模型与日期时间轴的三个开关

2.1 从数据模型说起:为什么 DateTime 存放日期比 double 靠谱

K 线开发的第一步往往不是画图,而是把数据结构定好。指数、个股、期货的日 K 线虽然来源不同,但核心字段是固定的:交易日期、开盘价、最高价、最低价、收盘价、成交量。我见过有的 Demo 为了省事,把日期字段设计成 double 类型,直接在序列化的时候用 Unix 时间戳,结果画图时要么坐标轴显示一串乱码数字,要么日期格式转换来回折腾。稳妥的做法是直接用 DateTime,由 UI 层负责格式化。

public class KLineItem { public DateTime Date { get; set; } // 交易日期,注意不是自然日,是交易日 public double Open { get; set; } // 开盘价 public double High { get; set; } // 最高价 public double Low { get; set; } // 最低价 public double Close { get; set; } // 收盘价 public long Volume { get; set; } // 成交量,用 long 避免溢出 }

这段代码本身不复杂,但有几个点值得说清楚。High和Low在数据源清洗时要保证High >= Max(Open, Close)、Low <= Min(Open, Close),否则画出来的 K 线会出现影线穿过实体这种逻辑错误,这类脏数据在免费接口里并不少见。Volume用 long 是因为成交量的单位是“手”,一只活跃股票一天的成交量经常超过 int 上限,尤其是做分钟 K 线时累计值更大。日期字段我强烈建议保留到毫秒以外的精度,日 K 线只需要到天,分钟 K 线把时间部分带上,后续切换周期就不用再改模型。

数据模型定好后,接下来要解决的是“画布认识日期”的问题。ZedGraph 是一个通用图表控件,它本身不认识股票交易日历,它只知道 X 轴的数据值。如果你直接把 DateTime 换算成数字传进去,ZedGraph 会按自然日连续排列 K 线,周末和节假日就会在图上留下空洞,这就是很多 K 线图“左密右疏”的根源。要让 ZedGraph 正确处理日期,需要调整 X 轴的三个关键开关。

2.2 日期时间轴的三个开关:AxisType.Date、DateAsOrdinal 与 Format

ZedGraph 的 X 轴默认是线性数值轴,要让它按日期工作,第一个开关是XAxis.Type = AxisType.Date,这告诉 ZedGraph 当前 X 轴的数据值是日期格式;第二个开关是XAxis.Scale.DateAsOrdinal = true,这决定日期在刻度上的排列方式;第三个是XAxis.Scale.Format,它控制刻度标签显示成什么样。

GraphPane pane = zedStock.GraphPane; // 第一个开关:告诉 ZedGraph 这是日期轴 pane.XAxis.Type = AxisType.Date; // 第二个开关:按交易日序号排列,避免周末出现大缺口 pane.XAxis.Scale.DateAsOrdinal = true; // 第三个开关:刻度标签的显示格式 pane.XAxis.Scale.Format = "yy-MM-dd"; // 让 X 轴范围自动跟随数据,后续滚轮缩放才能生效 pane.XAxis.Scale.MinAuto = true; pane.XAxis.Scale.MaxAuto = true; // Y 轴标题,界面标识用 pane.YAxis.Title.Text = "价格"; pane.XAxis.Title.Text = ""; // 计算坐标轴范围并强制重绘 zedStock.AxisChange(); zedStock.Invalidate();

这里最容易被忽略的是DateAsOrdinal。这个属性的作用是让 ZedGraph 把数据点的 X 值按等差序号排列,而不是按真实时间间隔排列。国内 A 股只有周一到周五开盘,周一和周五之间隔了 48 小时,如果不开启这个开关,K 线图上每隔四根就出现一条明显的空白带,整个图看起来跟梳子一样。开启后 ZedGraph 会把相邻交易日等距排列,视觉上就和行情软件一致了。代价是横轴不再严格对应真实时间长度,但对于股票日 K 线场景,这个取舍是行业的通用做法。

Format的设置也有讲究,日 K 线用"yy-MM-dd"足够,做分钟 K 线时建议改成"HH:mm",避免每一根刻度都把年月日显示出来。MinAuto和MaxAuto这两个属性建议一直保持为 true,它们决定了 X 轴的边界是否跟随数据变化。如果不开启,数据更新后新 K 线很可能被截在画布外面,视觉上像是“丢数据”,实际上只是坐标轴没有刷新范围。这一组配置是所有 ZedGraph K 线图的基础,后面的蜡烛绘制和交互都建立在它的上面。

3. 从 PointPair 到 StockCurve:画出第一根红绿蜡烛

3.1 PointPair 的六个参数与 StockCurve 的构造方式

很多人第一次用 ZedGraph 画 K 线,会不自觉地去搜“Candlestick”或者“CandleChart”,但 ZedGraph 里对应的类型是StockCurve,翻译过来叫股票曲线。它不是把收盘价连成折线,而是用一组高低开收数据画出一根带实体的蜡烛。每根蜡烛的数据载体是PointPair,这个普通用户经常忽略的类,在 K 线场景里要同时承载六个值。

private void DrawKLine(List<KLineItem> klines) { GraphPane pane = zedStock.GraphPane; // 每次重绘前清理旧曲线和旧标注,避免叠加 pane.CurveList.Clear(); pane.GraphObjList.Clear(); // 按涨跌拆成两个列表,一个画阳线,一个画阴线 PointPairList upList = new PointPairList(); PointPairList downList = new PointPairList(); for (int i = 0; i < klines.Count; i++) { KLineItem k = klines[i]; // 关键:X 用 XDate 包装的日期,Y 用收盘价,其余四值分别对应最低、最高、开盘、收盘 PointPair pp = new PointPair( new XDate(k.Date), k.Close, k.Low, k.High, k.Open, k.Close); // 国内习惯红涨绿跌,收盘大于等于开盘视为阳线 if (k.Close >= k.Open) upList.Add(pp); else downList.Add(pp); } // 阳线用偏红色,阴线用偏绿色,RGB 值可以配置到界面设置里 StockCurve upCurve = new StockCurve("阳线", upList, Color.FromArgb(220, 80, 80)); StockCurve downCurve = new StockCurve("阴线", downList, Color.FromArgb(60, 160, 120)); pane.CurveList.Add(upCurve); pane.CurveList.Add(downCurve); // 坐标轴配置沿用上一章的三个开关 pane.XAxis.Type = AxisType.Date; pane.XAxis.Scale.DateAsOrdinal = true; pane.XAxis.Scale.Format = "yy-MM-dd"; zedStock.AxisChange(); zedStock.Invalidate(); }

这里解释一下PointPair构造函数的参数顺序,它是 K 线绘制中最容易写错的地方。第一个参数是 X 值,在日期轴模式下必须传XDate类型,直接用DateTime会触发隐式转换失败;第二个参数是 Y 值,StockCurve 会把 Y 当作收盘价的主值;剩下四个参数依次是 Low、High、Open、Close。你不需要刻意背顺序,但要记住一个规律:先是最低价,再是最高价,然后是开盘价和收盘价。

我拆分阳线和阴线两个列表,是用两条StockCurve分别绘制,这比试图让单条曲线自动变色要可靠得多。StockCurve 支持对整个曲线统一设置颜色,但不支持按数据点自动判断涨跌后切换颜色,如果硬要用一条曲线画,画出来的 K 线全部是同一个颜色,这在行情软件里是不可接受的。拆成两条曲线后,涨跌颜色由两条曲线的Color分别控制,逻辑清晰,也不依赖任何版本特异性的 API。

3.2 涨跌配色的实现边界与颜色参数选择

配色这件事看着是小事,实际牵扯到视觉习惯和后续维护。国内主流行情软件是红涨绿跌,海外一些平台是绿涨红跌,你的代码里只需要把两个Color.FromArgb的参数对调,就能完成切换,我把这个参数收敛成配置文件里的两个键,而不是写死在绘制函数里。颜色值本身也有讲究,我用的是(220, 80, 80)这种偏亮的红,在深色背景和白色背景下都有辨识度;阴线绿色用(60, 160, 120),比纯绿色的视觉冲击小,长时间盯盘不刺眼。

还有一个容易被忽略的边界情况:平盘,也就是收盘价等于开盘价的那根 K 线。我的处理是把平盘归入阳线,毕竟它没有下跌;但在某些数据源里,平盘可能被标记为阴线,所以我在绘制前会对数据源做一次规范化:Close >= Open一律算阳线。这个规则必须跟数据清洗逻辑保持一致,否则同一套代码跑不同的数据源,画出来的红绿分布会前后不一致,排查起来很费劲。

两条曲线同时存在时,图例会显示“阳线”“阴线”两个条目。如果你不希望图例干扰看图,直接pane.Legend.IsVisible = false就行,多数行情软件主图并不显示图例,价格和日期信息更多靠十字线反馈。

4. 让 K 线窗口像行情软件:滚轮缩放、拖拽与悬浮十字线

4.1 交互参数配置:什么该开,什么该关

K 线图不能动就等于一张死图。ZedGraphControl 自带滚轮缩放和鼠标拖拽能力,但默认参数是为普通图表设计的,直接用在股票场景会差几个意思。比如默认的缩放比例是 2 倍,滚一下轮子 K 线就变得太密,很难精确回到某个时间窗口;再比如用户想左右平移,结果鼠标一拖画出了一个选区。下面这组配置是我在多个项目中固定使用的参数。

ZedGraphControl zed = zedStock; // 滚轮缩放:锚定鼠标位置,缩放倍数 1.2 比默认的 2.0 更跟手 zed.IsEnableZoom = true; zed.ZoomFraction = 0.8; zed.ZoomStep = 1.2; // 左键拖拽改为平移,右键保持弹出菜单,区域缩放交给 Ctrl+左键 zed.IsEnablePan = true; zed.IsShowContextMenu = true; // 让 X 轴范围始终受限,滚轮不会滚到数据范围之外 zed.GraphPane.XAxis.Scale.MinAuto = true; zed.GraphPane.XAxis.Scale.MaxAuto = true; // Y 轴跟随当前可视区间自动缩放,价格范围始终贴合屏幕 zed.GraphPane.YAxis.Scale.MinAuto = true; zed.GraphPane.YAxis.Scale.MaxAuto = true;

这段配置里面,ZoomStep是我后来才加上的。ZedGraphControl 默认滚轮一次缩放是 2 倍,对日 K 线来说太激进:数据量一两千根时,滚一下可能就跨过两三百根,根本停不在目标位置。设成 1.2 后,滚动节奏平滑很多,和主流行情软件的手感接近。IsEnablePan默认其实是关闭的,不打开的话用户只能缩放不能平移,一旦滚轮把 K 线放到局部,就无法左右查看历史区间,这是新手第一次用 ZedGraph 时最容易困惑的点。

Y 轴的MinAuto和MaxAuto也是必须开启的。默认情况下 ZedGraph 会在每次AxisChange()时按全部数据的最高最低计算 Y 轴范围,缩放 X 轴后 Y 轴不会自动跟着当前可视区间的价格范围调整,结果就是看到一根扁平的 K 线。开启这两个属性后,Y 轴会随着 X 轴可视范围联动,局部放大时价格波动幅度也会跟着放大,体验才接近专业软件。

4.2 圆圈附近找点:悬浮显示日期与开高低收

行情软件的十字线一移动,右上角就显示当前 K 线的日期和价格,这个交互在 ZedGraph 里需要自己接事件。核心思路是监听鼠标移动,把鼠标屏幕坐标转换成数据坐标,再在曲线里找距离最近的数据点。这一步对数据量的性能要求不算高,几千根 K 线下每次移动做一次查找是完全没有压力的。

// 窗口初始化时挂接事件 zedStock.MouseMoveEvent += OnKLineMouseMove; private bool OnKLineMouseMove(ZedGraphControl control, MouseEventArgs e) { GraphPane pane = control.MasterPane.FindNearestPane(e.Location) as GraphPane; if (pane == null) return false; // 屏幕坐标 -> 数据坐标 double x, y; if (!pane.ReverseTransform(e.Location, out x, out y)) return false; // 在曲线中查找最近的数据点 CurveItem nearestCurve; int index; if (!pane.FindNearestPoint(e.Location, out nearestCurve, out index)) return false; PointPair pp = nearestCurve[index]; if (pp == null) return false; // 把 X 值还原成 DateTime,XDate.FromOADate 是对 ZedGraph 内部数值的还原 DateTime dt = XDate.FromOADate(pp.X); // 更新界面上的文本标签,WinForms 里用 Label 控件即可 priceLabel.Text = string.Format( "日期:{0:yy-MM-dd} 开:{1:F2} 高:{2:F2} 低:{3:F2} 收:{4:F2}", dt, pp.OpenVal, pp.HighVal, pp.LowVal, pp.CloseVal); return false; }

这段代码里,ReverseTransform是把鼠标位置换算成轴上的数据坐标,FindNearestPoint是在距离鼠标最近的数据点上命中索引,两个 API 配合起来就能实现“鼠标停在 K 线附近,界面跟随显示信息”的效果。XDate.FromOADate是把内部存储的 OLE Automation 日期还原成DateTime,这一步不能省,直接拿pp.X当数字读,显示出来就是一个天文数字。

这里我要强调一个容易踩的细节:pane.ReverseTransform默认拿到的 x 是数据坐标系的值,而不是屏幕像素值。有些开发者拿到 x 后直接去数组里按 index 找 K 线,这在 X 轴序号和数据索引刚好一致时能用,一旦 X 轴用了真实日期,或者数据做了头尾裁剪,就会错位。最稳妥的方式就是我上面的写法:pp.X配合XDate.FromOADate。

5. 股票 K 线开发的避坑记录:周末缺口、标签错位与空数据崩溃

5.1 周末大缺口:周五到周一之间出现一条大空白

现象:画出来的 K 线图左边几根很密集,右边越来越稀疏,周五和周一之间隔了一条明显的白色地带,整体就像一把梳子。 原因:DateAsOrdinal没开,ZedGraph 把日期当成连续时间值,周六周日被算成真实的时间间隔铺在横轴上。数据量越大,空白越明显,尤其是包含多年日 K 线时,每个周末的空隙累计起来能把整段历史拉得特别长。 解决:在绘制前强制设置pane.XAxis.Scale.DateAsOrdinal = true;。如果开了这个属性仍有空白,就要检查数据源是否混入了非交易日。某些免费数据源会把停牌日也输出一条空 K 线,日期连续但 Open、High、Low、Close 都是 0 或者空值,这类数据在加载时就应该过滤掉,而不是留给绘制层处理。

另一个相关的现象是节假日缺口:国庆、春节这类长假前后,K 线之间的间隔特别大。这在DateAsOrdinal = true之后会自动消失,因为每个交易日变成等距序号,ZedGraph 不再参考真实时间差。如果你做的周期不是日 K,而是分钟 K,还需要额外判断盘中休市时段,这个开关并不能解决日内非交易时段的问题,那是另一套处理逻辑。

5.2 日期标签重复、空数据崩溃与图例冗余

现象一:X 轴刻度标签显示“2024-07-01”出现了六次,或者标签完全不显示,只有一串数字。 原因:Scale.MajorStep设置不当。ZedGraph 的自动刻度算法在数据点多时会在日期轴上取约数,可能让同一日期被重复标记。管子里的老办法是手动指定步长,但日 K 线数据量不定,硬编码步长在数据少时又会出现标签过稀。 解决:设置pane.XAxis.Scale.MajorStep = 1;并配合pane.XAxis.Scale.MajorUnit = DateUnit.Day;,让 ZedGraph 至少每天保留一个刻度。如果想要周级别的标签,就把MajorUnit改成DateUnit.Week。注意,MajorStep和MajorUnit是配对使用的,只改一个不会生效。

现象二:程序启动时数据还没下载完,先调了一次绘制函数,传入一个空的List<KLineItem>,结果 ZedGraph 抛异常,界面白屏,严重时直接闪退。 原因:PointPairList为空时,StockCurve 无法计算数据边界,AxisChange()内部会崩溃。这个问题在带网络加载的行情客户端里尤其常见,初始化时数据源还没返回,UI 层就先调用了绘制。 解决:在绘制函数开头做空列表保护,klines == null || klines.Count == 0时直接清空曲线并返回。另外要养成习惯:每次清理画布时调用pane.CurveList.Clear()之后一定要跟一次zedStock.Invalidate(),否则曲线虽然清空了,界面残留上一帧的画面,看起来像是“画出重影”。

现象三:阳线阴线两条曲线同时存在,图例区域出现两个条目,而且即便某段时间全是阳线,阴线条目也始终显示。 原因:这不是 bug,是拆分双曲线方案本身的特性。图例只是把曲线列表里的对象逐一列出,和你画了多少根 K 线无关。 解决:如果主图不需要图例,直接pane.Legend.IsVisible = false;。如果确实需要图例(比如叠加了均线),保留两个条目反而是合理的,用户能直观看到红绿两种颜色的含义。真正值得注意的是:不要把曲线名字写成“up”“down”这类变量名,图例显示出来用户看不懂,用“阳线”“阴线”或者干脆用价格周期“日K”“周K”作为图例文字,产品观感会好很多。

6. 在 K 线上叠加买卖点与量能副图:最后这一步才像产品

6.1 买卖点标注:TextObj 的坐标是数据坐标,缩放不会错位

K 线图开发到了能看、能动、能查信息这一层,基本上算是完成了 70%。剩下的 30% 是把策略信号和数据指标叠加上去,最典型的就是在 K 线上标记买入点和卖出点。ZedGraph 里实现标注要使用GraphObjList,它是独立于曲线列表的绘图对象集合,最核心的优点是它保存的是数据坐标,用户缩放拖动 K 线时,标注自动跟随对应位置移动。

// 在某根 K 线的收盘价上方标注一个买入信号 "B" double markX = new XDate(buySignal.Date); double markY = buySignal.Price; TextObj buyMark = new TextObj("B", markX, markY); buyMark.FontSpec.FontColor = Color.Red; buyMark.FontSpec.Size = 16; buyMark.FontSpec.Bold = true; // 锚点设置:文字中心对准数据点,避免标注偏移 buyMark.Location.AlignH = AlignH.Center; buyMark.Location.AlignV = AlignV.Bottom; buyMark.ZOrder = ZOrder.A_BehindChart; pane.GraphObjList.Add(buyMark); zedStock.AxisChange(); zedStock.Invalidate();

这段代码里,new XDate(buySignal.Date)是把 DateTime 转成 ZedGraph 内部使用的 X 值,这一步必须存在,否则标注的 X 坐标会错。AlignV.Bottom表示文字的底边对齐到数据点位置,这样“B”字出现在价格上方,不会压住 K 线实体。ZOrder.A_BehindChart的作用是把标注放在数据层之后,保证 K 线本身不被文字遮挡。

标注这块有一个很常见的翻车点:数据量一多,信号点在屏幕外,但标注对象依然在GraphObjList里,占用绘制资源。如果策略信号上千个,K 线图会明显卡顿。业内做法是按当前可视区域过滤信号,只绘制 X 轴范围内可见的那些标注。实现上就是遍历信号列表,把日期值跟pane.XAxis.Scale.Min和Max比较,不在范围内的跳过,这个过滤逻辑放在绘制函数里,每次AxisChange()后执行一次。

6.2 量能副图:用 MasterPane 做上下分栏

行情软件的主图下面是成交量副图,两根图联动缩放,这是 K 线产品形态的基本盘。ZedGraphControl 本身封装了 MasterPane,可以承载多个 GraphPane。把价格 K 线放上面,成交量柱状图放下方,副图默认不显示 X 轴标签,等 MasterPane 整体布局完成后由下方的副图统一显示日期,这样就不会有两行日期刻度重复出现。

// 创建成交量副图 GraphPane volumePane = new GraphPane(); volumePane.Title.Text = ""; volumePane.YAxis.Title.Text = "成交量"; volumePane.XAxis.Title.Text = ""; volumePane.XAxis.Type = AxisType.Date; volumePane.XAxis.Scale.DateAsOrdinal = true; volumePane.XAxis.Scale.Format = "yy-MM-dd"; // 主图 X 轴标签隐藏,由副图统一显示日期 pricePane.XAxis.IsVisible = false; // 成交量柱状数据:X 用日期,Y 用手数 PointPairList volList = new PointPairList(); foreach (KLineItem k in klines) { volList.Add(new XDate(k.Date), k.Volume); } volumePane.AddBar("量", volList, Color.FromArgb(120, 120, 120)); // 挂到 MasterPane 并纵向排列 MasterPane master = zedStock.MasterPane; master.PaneList.Clear(); master.PaneList.Add(pricePane); master.PaneList.Add(volumePane); using (Graphics g = zedStock.CreateGraphics()) { master.SetLayout(g, PaneLayout.SingleColumn); } // 手动控制高度比例,价格图 70%,量能图 30% master.PaneList[0].Size = new SizeF(master.Width, (float)(master.Height * 0.7)); master.PaneList[1].Size = new SizeF(master.Width, (float)(master.Height * 0.3)); zedStock.AxisChange(); zedStock.Invalidate();

MasterPane 的用法有一定门槛,但一旦跑通,扩展性非常好:以后想加 MACD、KDJ 副图,只需要再 new 一个 GraphPane 塞进PaneList。这里有个习惯我保留至今:无论改曲线、改坐标轴还是改布局,最后一定调用AxisChange()再接一次Invalidate()。前者重算坐标边界,后者触发重绘,两个不一起调用,就会出现数据变了但画面不刷新的“灵异现象”,排查半天其实是漏了其中一行。

等 K 线、交互、副图都跑通之后,再回头看这个项目标题里面的“测试可以运行”五个字,会发现真正的验证标准不是代码能跑,而是换一个数据源、换一个周期、拉大窗口之后依然稳定。我每次交付这类 K 线功能前,都会用一个十年日 K 数据、一个分钟级数据、一个只有三十根 K 线的短数据分别跑一遍,三种极端情况都不出错,才敢说这版代码能用。希望这些方案能帮你少走弯路,把精力留给真正有业务价值的部分。

本文还有配套的精品资源,点击获取

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

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

立即咨询