news 2026/10/12 4:12:03

Delphi股票客户端与主站通信协议设计:从TCP拆包到K线绘制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Delphi股票客户端与主站通信协议设计:从TCP拆包到K线绘制

简介:这是用Delphi完整开发的一套股票行情分析系统,面向金融软件开发者及Delphi中高级程序员,印证Delphi同样能驾驭大型桌面应用。系统涵盖股票行情分析、实时财务数据同步、信息地雷监测等功能,主程序几乎不依赖第三方控件,登录名随意填写即可直接体验,方便深入拆解内部实现。压缩包共87个文件,体积仅2.32MB:主体为60个cel报表模板与14个dat数据库文件,分别承载全套财务、基金、期货、债券等模板数据;另有5个dll封装核心逻辑、3个ini保存配置、2个exe主程序入口,结构紧凑清晰。附带基于TeeChart的看盘软件源码,可借鉴K线图渲染、行情图表等图形处理与交互设计。目前已有984人学习下载,对想用Delphi构建完整业务系统、或参考金融数据组织与界面开发的程序员都有较高参考价值。

1. 股票分析系统与Delphi客户端:为什么老组合还能打

做行情终端的工程师都见过这种组合:主站那边把行情数据整理好,客户端这边几十个窗口快速切换也不卡。放在今天,这类“股票分析系统”里,客户端全用Delphi开发的比例依然不低。不是因为团队守旧,而是Delphi做桌面客户端确实省钱——原生编译、上手快、部署就是一个exe目录,客户机器上不用装任何运行时。它解决的问题很具体:主站负责数据整合和下发,客户端负责把K线、自选股和技术指标算好画出来,两端只用一套二进制协议对话。这篇文章写给两类人:一类是接手存量Delphi行情客户端的团队,想知道边界在哪;另一类是准备从零搭一套桌面分析终端、希望少走弯路的开发者。我会把主站与客户端的职责划分、协议设计、最小可跑代码和常见翻车点一次讲清。

2. 主站与Delphi客户端的分工:为什么这套组合能跑行情

客户端是Delphi,不代表主站也必须是Delphi。两个子系统之间只有一个约定:协议。我见过的主站有C++写的,有Java写的,也有直接用Delphi的TIdTCPServer撑起来的服务端。Delphi写服务端没有想象中难,但它的优势本来就不在服务端,把精力放在客户端会更划算。实战中主站只需要做好三件事:接行情、存K线、按会话推给客户端。技术栈不同完全不影响协作,前提是协议文档写得够清楚。

2.1 主站管什么,客户端不碰什么

主站负责从上游接入快照和逐笔成交,把分钟级、日线级的K线在服务端合成好,存入缓存,再推给在线客户端。登录鉴权、自选股列表、预警规则这些用户级数据也尽量放主站,客户端只保留会话凭证。这样划分之后,主站可以单独换行情源,客户端代码几乎不用动。反过来,如果让客户端自己去拼K线,十个终端就会拼出十种结果:有的漏算停牌,有的没处理复权,最后主站和客户端对不上账。

我一般会把K线合成放在主站,客户端只负责展示和本地指标计算。这不是技术上最优,而是协作上最省事。主站统一算复权因子、统一处理停牌K线、统一决定是否推送已收盘的最后一根K线,客户端拿到什么画什么。如果某个客户端需要自定义周期,比如3分钟K线,可以在协议里加一个周期枚举,由主站合成后下发,而不是让每个客户端各算一套。

2.2 客户端选Delphi的三个现实理由

第一是交付效率。行情界面本质上是列表加图表再加几个输入框,VCL拖拽就能出原型,窗体布局调整非常快,业务人员早上提的需求下午就能看到界面。第二是部署成本。Delphi编译出来是原生的exe,拷到目标机器就能跑,不需要安装庞大的运行时,这点在客户环境里特别友好。老机器、精简版系统、权限受限的账户,Delphi程序基本不挑环境。第三是运行开销。VCL的原生控件内存占用小,几十个窗口同时打开也能保持切换流畅;Electron这类方案动辄占用几百兆内存,在需要长挂机、多图联动的行情终端场景里,很容易把低配机器拖垮。

还有一点经常被忽略:如果手里有老Delphi项目,平移改造比重写划算。老项目里的自选股存储、公式计算、批量下单模块,经过多年生产环境踩坑已经稳定,换技术栈等于把这些模块重新验证一遍。这个因素在选型时的分量,往往比“哪门语言更好”更重。

2.3 数据链路选型:TCP长连接优先,HTTP和WebSocket各有边界

方案实时性客户端复杂度适用场景
TCP长连接低延迟,服务端可主动推送中,需处理拆包、心跳、重连行情快照、逐笔成交、K线实时推送
HTTP轮询高延迟,受轮询间隔限制低,天然无状态历史K线补拉、自选股下载、预警列表
WebSocket低延迟,有帧协议中,Delphi侧库少、维护成本高浏览器端优先,桌面客户端一般不选

桌面行情客户端优先走TCP长连接,低频查询走HTTP短连接。一条连接上同时承载行情推送、心跳、服务端主动断开通知,这比每笔快照都发一次HTTP请求要省太多资源。WebSocket对浏览器端有意义,放到Delphi这边反而要引入额外的字节处理库,收益很小。主站设计时最好把接口分成两类:长连接接口管实时数据,短连接接口管历史查询,这个习惯会在后期调试时省掉大量时间。

3. 主站端协议设计:给Delphi客户端的发送-解析契约

协议是主站和客户端之间唯一契约。Delphi客户端能不能正确解析,取决于主站报文里每个字节怎么排、端序怎么定义。这里踩的坑比业务逻辑还多。先把格式定死,再写两端的收发代码,顺序不能反过来。

3.1 消息格式:先定包头再定包体

行情系统的消息类型不多:登录、请求K线、推送K线、快照、心跳。每种消息用一个16位ID放在包头。我常用的包头格式是固定6字节:

字段字节数说明
MsgID2消息类型标识
BodyLen2包体字节数,不含包头
CRC162从包头第一字节到包体末尾的校验值

客户端收到数据后,先读4字节判断消息ID和包体长度,再读BodyLen个字节的包体,最后读2字节校验。这4字节是整份协议里最不能动的地方。MsgID段位的约定也要写死:登录用0x0001,请求K线用0x1001,服务端推送K线用0x8001,心跳用0xF001。这样客户端一收到0x8开头就知道是主动推送,不用等请求。

3.2 用TMemoryStream写消息,绕开record对齐

很多新手第一反应是用packed record描述消息结构,然后直接写流。结构体字段少的场景可以用,但消息里一旦出现变长数组或者字符串,record就变得非常别扭,而且packed record在不同Delphi版本编译选项下存在被编译器填充字节的可能。我一般直接用TMemoryStream手动拼字节,简单、可控、跨语言不解释。

procedure EncodeRequest(const AMsgID: Word; const APayload: TBytes; AStream: TMemoryStream); var L: Word; CRC: Word; begin // 包头按网络序写入:高字节在前,低字节在后 L := Length(APayload); AStream.WriteByte(Byte(AMsgID shr 8)); // 消息ID高字节 AStream.WriteByte(Byte(AMsgID and $FF)); // 消息ID低字节 AStream.WriteByte(Byte(L shr 8)); // 包体长度高字节 AStream.WriteByte(Byte(L and $FF)); // 包体长度低字节 if L > 0 then AStream.WriteBuffer(APayload[0], L); // 从包头开始到包体末尾计算CRC,不含CRC本身 CRC := CalcCRC16(AStream.Memory, AStream.Size); AStream.WriteByte(Byte(CRC shr 8)); AStream.WriteByte(Byte(CRC and $FF)); end;

为什么不用TIdBytes直接写?因为Indy的接口在不同版本之间有差异,用TMemoryStream作为传输缓冲最稳。为什么手动拆高字节?Delphi的Word在x86内存里是小端,而网络协议统一约定大端,必须手动调换。调用端代码很直观:

// 请求最近200根日K,股票代码“600000” var Ms: TMemoryStream; Payload: TBytes; begin Ms := TMemoryStream.Create; try // 股票代码定长10字节,UTF-8编码,不足补空格 SetLength(Payload, 10 + 1 + 2); ... EncodeRequest($1001, Payload, Ms); // 把Ms里的字节发给主站 finally Ms.Free; end; end;

这里有几个参数需要约死:股票代码定长10字节,不足补空格;周期字段用1字节,0表示日K、1表示周K、2表示月K;数量字段用2字节,最大能表示65535根,足够一次拉全历史。

注意:跨语言协作时,协议文档里必须写清“所有多字节整数都按大端传输”,并给一个16进制报文示例。空口说明最后一定会变成双方各说各话。

3.3 K线下发的最小包结构与定点数

K线数据是行情系统里最核心的包。价格建议用定点整数而不是Double:行情价格最多保留四位小数,把价格放大10000倍存成Int32,传输体积固定,解析逻辑也简单。浮点数的序列化虽然也固定8字节,但老系统、跨语言解析时容易在舍入上产生不一致。一个K线项可以定义成下面这样:

字段类型字节数说明
时间戳UInt648Unix毫秒
OpenInt324开盘价x10000
HighInt324最高价x10000
LowInt324最低价x10000
CloseInt324收盘价x10000
VolumeUInt324成交量,股数

单根K线固定28字节,包体长度直接用K线数量乘以28计算,客户端不需要逐字段猜边界。写包体时主站按顺序循环写入即可,任何一端都不要给这个结构追加可选字段。如果需要加字段,就加一个新消息ID,而不是在旧结构后面续字段,否则老客户端会把新数据解析成错位垃圾。

3.4 心跳、超时和重连参数别省

行情连接是长连接,必须有心跳。常见做法是客户端每10秒发一个心跳包,主站连续3次没收到就断开连接。参数表可以直接抄:

参数建议值说明
心跳间隔10秒行情空闲时也要保持连接温度
读超时5秒一次TCP读取等待上限
建连超时3秒超过即放弃本次连接
重连退避1秒、2秒、4秒,封顶30秒间隔递增,防止重启风暴
每用户最大连接数2到4防止多窗口各自建连耗尽主站资源

连接成功不等于会话有效。主站在登录响应里必须带上会话Token,后续所有业务请求都携带这个Token。客户端收到错误码时要按错误码降级,比如0x1001表示密码错误,直接停掉重连,不要每10秒去撞一次鉴权接口。

4. Delphi客户端最小实现:从连接主站到画出第一根K线

第3章解决了“主站怎么发”,这一章解决“Delphi怎么收、怎么画”。最小闭环只需要三块:连接、拆包、绘制。这三块对应三个类,不要揉进一个事件里。

4.1 用TIdTCPClient建立连接

Indy的TIdTCPClient是Delphi里最常见的TCP客户端组件。连接动作一定要放到后台线程,直接在主界面按钮事件里Connect会让界面卡住几秒,客户会以为程序死了。

procedure TMainForm.ConnectToServer; begin FClient := TIdTCPClient.Create(nil); FClient.Host := EditHost.Text; // 主站IP地址 FClient.Port := StrToInt(EditPort.Text); // 主站端口 FClient.ConnectTimeout := 3000; // 建连超时3秒 FClient.ReadTimeout := 5000; // 单次读超时5秒 // 放入后台线程,避免阻塞UI线程 TThread.CreateAnonymousThread( procedure begin try FClient.Connect; // 连接成功后启动接收线程 FReceiver := TRecvThread.Create(FClient); FReceiver.OnPacket := HandlePacket; FReceiver.Start; except on E: Exception do Log('连接失败: ' + E.Message); end; end).Start; end;

这段代码有几个易错点:ConnectTimeout只管TCP握手,不管后续读写;ReadTimeout要设得比心跳间隔小,才能及时发现断线;FClient不能在UI线程释放,否则接收线程会访问到野指针。Indy的组件在跨线程使用时,如果只有一个线程读写IOHandler,问题不大;一旦UI线程也去读同一个连接,就会出现线程竞争,表现为偶发异常。

4.2 接收线程与拆包:TCP是流,不是消息队列

TCP不保证一次recv就是一个完整报文。两根K线可能粘在一个包到达,也可能一根K线拆成两半。拆包的思路是:先读4字节包头,算出BodyLen,再读BodyLen长度的包体,最后读2字节CRC。剩余不足4字节的数据留在缓冲区,等下一次读。

procedure TRecvThread.Execute; var Head: TIdBytes; Body: TIdBytes; CRC: TIdBytes; MsgID, BodyLen: Word; begin while not Terminated do begin try // 第一步:读4字节固定包头 SetLength(Head, 4); FClient.IOHandler.ReadBytes(Head, 4, False); // 按网络序解析消息ID和包体长度 MsgID := (Head[0] shl 8) or Head[1]; BodyLen := (Head[2] shl 8) or Head[3]; if BodyLen = 0 then Continue; // 第二步:按包体长度读完整包体 SetLength(Body, BodyLen); FClient.IOHandler.ReadBytes(Body, BodyLen, False); // 第三步:读2字节CRC,移交业务层校验 SetLength(CRC, 2); FClient.IOHandler.ReadBytes(CRC, 2, False); // 通过Queue切回主线程,避免阻塞接收循环 TThread.Queue(nil, procedure begin FOnPacket(Self, MsgID, Body, CRC); end); except on E: Exception do begin // 读超时或连接断开都进到这里 FOnDisconnected(Self); Break; end; end; end; end;

这套写法有几个关键设计:读包头用ReadBytes阻塞读满4字节,天然解决了半包问题;解析端序与第3章编码端序一致,都用大端;FOnPacket回调里不要做复杂计算,只把消息放入队列或直接触发UI刷新,否则接收线程会被拖慢。业务层的CRC校验不要省。真实主站发错包、网络中间设备篡改,都会在这里被拦住。

4.3 用离屏画布绘制K线,告别闪烁

K线绘制最忌讳直接在控件的OnPaint里逐根画线,那样每次重绘都会闪。正确做法是先画到TBitmap上,再一次性Draw到目标画布。下面的代码演示最小绘制流程:

procedure TMainForm.DrawKLine(ACanvas: TCanvas; AData: TKLineArray); var Buf: TBitmap; I, X, YHigh, YOpen, YClose, YLow: Integer; MinV, MaxV: Double; begin Buf := TBitmap.Create; try Buf.Width := ChartBox.Width; Buf.Height := ChartBox.Height; Buf.Canvas.Brush.Color := clWhite; Buf.Canvas.FillRect(Rect(0, 0, Buf.Width, Buf.Height)); // 先遍历一遍,算出可视范围内的最小和最大价 MinV := Min(AData.High); MaxV := Max(AData.Low); if MaxV - MinV = 0 then Exit; // 每根K线占8像素,只画可视范围 for I := 0 to Min(High(AData), Buf.Width div 8 - 1) do begin X := I * 8; YHigh := PriceToY(AData[i].High, MinV, MaxV, Buf.Height); YLow := PriceToY(AData[i].Low, MinV, MaxV, Buf.Height); YOpen := PriceToY(AData[i].Open, MinV, MaxV, Buf.Height); YClose := PriceToY(AData[i].Close, MinV, MaxV, Buf.Height); // 影线 Buf.Canvas.MoveTo(X, YHigh); Buf.Canvas.LineTo(X, YLow); // 实体 Buf.Canvas.Rectangle(X - 3, YOpen, X + 3, YClose); end; // 一次性上屏 ChartBox.Canvas.Draw(0, 0, Buf); finally Buf.Free; end; end; function PriceToY(APrice: Double; AMin, AMax: Double; AHeight: Integer): Integer; begin Result := AHeight - Round((APrice - AMin) / (AMax - AMin) * AHeight); end;

这段代码有两个参数值得调:每根K线宽度8像素是初值,用户可以放大缩小;Y轴范围一定要先遍历所有可视K线算一次,不能在循环里逐根调用Scale,否则坐标不一致,图形会显得上下跳。涨跌颜色建议在实体矩形里加一个判断:Close大于Open用红色,小于用绿色。注意红涨绿跌在部分海外市场习惯相反,最好做成配置项,别写死。

提示:十字光标跟随这类交互,不要整块重绘,只重绘光标经过的小矩形区域。整块重绘加双缓冲也能用,但高频移动时CPU占用会明显上升。

5. 避坑:Delphi股票客户端常见的5个翻车现场

这一章全是血泪经验。下面5个问题我在实际项目里都遇到过,按“现象、原因、解决”写,可以直接当成排查手册用。

5.1 通信与解析层的翻车现场

第一个坑是拆包错位。现象是K线图时不时多一根或少一根,自选股列表偶发出现半截名字。原因是TCP流没有消息边界,接收端如果直接按一次读到的字节数解析,遇到粘包或半包就会移位。解决方法是严格按第4.2节的流程:先读4字节包头,根据BodyLen读完整包体,剩余数据留在缓冲区。这个坑最容易在联调初期出现,主站一秒钟推几十个包,一旦错位,后续所有包都废。

第二个坑是字符串乱码。现象是股票名称显示成“锟斤拷”,英文代码正常,中文全是乱码。原因是老Delphi工程默认用AnsiString,主站下发的UTF-8字节被直接当作Ansi字符赋值。解决方法是协议里统一为UTF-8,Delphi侧用TEncoding.UTF8.GetString显式转换,不要依赖隐式转换。如果历史协议用的是GBK,那么编码必须在协议文档里写明,客户端按GBK解码。最怕的是两边都觉得自己没错,最后发现一个发UTF-8一个解GBK。

第三个坑是断线不自愈。现象是主站重启或者网络闪断后,客户端一直转圈,只有重启程序才能恢复。原因是客户端没有心跳,也没有自动重连。解决方法是客户端每10秒发一个心跳包,接收线程捕获到异常后,按1秒、2秒、4秒指数退避重连,封顶30秒。重连后要重新登录并恢复自选股列表,不要只恢复TCP连接,否则主站认为会话无效,还会再断一次。

5.2 界面与数据层的翻车现场

第四个坑是UI卡成幻灯片。现象是行情刷新时拖动窗口卡顿,CPU占用居高不下。原因是有开发者把网络接收线程的数据直接在事件里同步刷新界面,或者用Synchronize高频调用UI。解决方法是合并刷新:用一个25Hz到50Hz定时器,每次从内存缓冲区取出最新的行情,一次性刷新可见区域;不要每来一笔就刷一次。接收线程收到包后只更新内存对象,不碰任何VCL控件。

第五个坑是32位进程内存越涨越高。现象是客户端挂机一整天后内存占用持续上涨,最后弹出地址错误对话框。原因是K线对象、自选股网格数据越积越多,又存在未能释放的对象引用,32位Delphi程序默认用户空间只有2GB,很容易撑爆。解决方法是打开大地址支持,把程序标记为LAA可执行;行情列表改用虚拟列表,只持有当前可见范围的对象;每天收盘后做一次缓存清理,释放超过N天不再访问的K线数据。我发现很多团队不做缓存上限,这是内存问题反复出现的根源。

6. 进阶验证:用报文回放器把主站和客户端焊在一起

真实验证行情链路最痛苦的地方在于:行情只在交易时段有,改一次协议要等开盘才能确认效果。更麻烦的是,盘中出了故障不能随便重启主站,否则会影响在线用户。我的习惯是做一个报文回放器,把主站发过的原始报文存下来,在任意时间点原样回放给客户端。这相当于给客户端造了一个“假的真主站”,而且比真主站听话。

回放器的核心原则是:保存原始字节,回放原始字节,不要在回放器里重新编码。如果回放器自己先解析一次再重新组装,那它验证的只是自己的编码逻辑,客户端解析真实主站报文时该错的还是错。只有把真实主站捕获到的字节原样喂给客户端,才能暴露字段顺序、字节序、CRC算法这些最底层的问题。

type TPlaybackItem = record DelayMs: Integer; // 距上一条报文的时间间隔 Data: TBytes; // 原样保存的报文字节 end; procedure TPlaybackThread.Execute; var I: Integer; begin for I := 0 to FItems.Count - 1 do begin if Terminated then Exit; Sleep(FItems[I].DelayMs); // 按真实时间间隔回放 FClient.IOHandler.Write(FItems[I].Data); // 原样写入原始字节 end; end;

回放器要支持三件事:单条回放、按间隔回放、循环回放。循环回放用来做稳定性测试,让客户端连续处理几万条K线消息,观察内存和CPU是否异常。验证清单如下:

验证项通过标准
登录往返客户端能收到登录响应并进入就绪状态
K线解析绘制结果与主站历史数据一致,不多不少
粘包半包人为把两条报文拼一起发送,解析不错位
断网恢复断开后停止推送,客户端进入重连流程并恢复

用回放器验证时还有一个附加价值:它能把问题边界切得特别清楚。客户端解析出错了,先用回放器确认是原始报文本身的字段错,还是Delphi解析代码错。这样就不用在真环境里跟主站开发互相推诿。我自己一直保留这个习惯,每次协议改版先把几分钟的报文盯一遍,再上真环境。这个习惯帮我拦下过好几次上线就白屏的尴尬。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 4:11:06

SecureCRT 7.1.1安装配置与自动化运维实战指南

简介:SecureCRT 7.1.1.264 的 32 位安装包,是一款面向系统管理员、网络工程师与开发人员的专业终端仿真工具。它通过 SSH1/SSH2、Telnet、Rlogin 及 Serial 等协议安全连接远程主机,支持多标签会话、终端定制、SFTP 文件传输与脚本自动化&…

作者头像 李华
网站建设 2026/10/12 4:10:42

AI Infra全景解析:从GPU调度到推理服务的六大核心模块

这两年AI技术的发展速度确实让人应接不暇。我自己的感觉是,模型算法本身当然重要,但真正让一个想法从论文变成稳定在线服务的,往往是背后那套看不见的支撑体系。身边不少朋友都在问同一个问题:AI Infra到底在解决什么问题&#xf…

作者头像 李华
网站建设 2026/10/12 4:07:28

.NET WebSocket实战:从握手原理到心跳保活与避坑指南

简介:这是一份面向.NET/WinForms开发者的WebSocket通信示例包,覆盖客户端、服务端与网页测试端。资源共174个文件,约984KB,以C#源码、工程配置、可执行文件、HTML测试页及说明文档为主,包含WinformServer、WinformClie…

作者头像 李华
网站建设 2026/10/12 4:07:07

C/C++数组内存分配全解析:连续存储、栈堆差异与越界定位

前几天有个同学拿着一段代码来找我,说程序跑着跑着某个变量的值莫名变成了 0,怎么都想不通。我扫了一眼代码,发现他在一个数组里写数据时用了错误的索引。这种问题我见过太多次了——数组越界导致了相邻变量的内存被改写,而程序真…

作者头像 李华
网站建设 2026/10/12 4:05:03

WPF加载OBJ模型实战:解析、重组与渲染避坑指南

简介:这份资源面向WPF开发者与3D图形学初学者,提供在Windows Presentation Foundation中加载并渲染OBJ格式3D模型的完整示例工程。OBJ作为通用的Wavefront模型格式,常用于跨软件交换三维数据,而WPF基于Direct3D的3D图形系统可通过…

作者头像 李华
网站建设 2026/10/12 4:04:51

Spring Boot+Vue景区管理系统:从运行到改造的完整指南

每逢毕设季,总有同学拿着“旅游景区管理系统”这种经典选题来问我一件事:源码拿到了,代码也解压了,但双击启动类报错一片红,或者前端页面死活出不来。说实话,这类基于Java Spring Boot Vue的全栈项目&…

作者头像 李华