用Delphi打造股票分析系统:主站+客户端全栈实践 简介基于Delphi完整开发的股票分析系统源码打破“Delphi难撑大系统”的刻板印象。系统覆盖股票行情分析、实时财务数据、信息地雷预警等核心功能界面图形化展示程度高且几乎未使用第三方控件适合Delphi开发者研习大型桌面应用架构也可供金融领域程序员了解实时行情与财务数据展示方案。资源共87个文件压缩包仅2.32MB其中60个模板文件覆盖基金净值、债券收益率、财务报表等各类金融数据14个数据文件与5个动态链接库支撑行情及财务逻辑另有少量配置与可执行程序整体按功能模块划分清晰便于检索。目前已有984人学习下载。解压即可获得可运行的客户端主程序、动态链接库及配套数据模板涵盖招股上市、分红配股、国际期货行情等场景无论是借鉴其Delphi控件布局还是复用数据模板对搭建同类金融终端都有实际参考价值。1. 主站和客户端全用 Delphi这套股票分析系统其实没那么“复古”打开行情软件切自选股、翻日 K、盯分时、看 MACD 金叉——这套操作背后主站负责采集行情、算指标、往下分发客户端负责画图、选股、提醒。而主站和客户端全部用 Delphi 开发听起来像是十几年前的技术选型实际上在当前环境下依然是一条性价比很高的路子Delphi 编译出来的原生程序启动快、不挑部署环境一份代码能把行情展示、指标计算、自选股管理全部吃掉不需要在前端和后端之间维护两套语言。这个方案的适用人群很明确想自己掌控完整技术栈、不想被 Web 前端和跨平台框架拖累性能的团队需要高频刷新、大量指标计算、客户端又要求低内存占用的场景更是 Delphi 的舒适区。接下来我按“架构拆解 → 客户端实现 → 主站实现 → 踩坑记录”这条线把一套可落地的方案讲清楚。2. 先把架构说清主站干什么、客户端干什么、数据怎么走2.1 主站负责采集与分发客户端负责展示与交互我习惯把系统拆成三层数据源适配层、主站计算分发层、客户端展示层。数据源适配层对接行情接口把不同来源的行情数据和历史K线统一成内部结构主站运行在服务器上做定时采集、指标计算、数据落库同时维护一批客户端连接把增量行情推下去客户端只做两件事一是从主站拉历史数据用于画图和计算指标二是接收实时推送刷新当前价格和分时走势。为什么要这样切如果把采集和计算全部塞进客户端每个客户端都要直连行情源连接数一旦上去就会被数据源限制而且指标计算的逻辑散落在各客户端里改一个 MACD 参数就要发一版客户端。把计算留在主站客户端拿到的就是加工后的数据但画笔、K 线缩放、均线叠加这些交互操作一定要留在客户端否则服务的压力会非常难看。Delphi 在这个架构里的位置很自然主站用 Delphi 写 Windows 服务跑在服务器上TServiceApplication 框架能把采集、推送、定时任务都托管起来客户端用 VCL 写桌面程序TChart 系列组件直接画 K 线两端的代码还能共享一套数据结构定义和数据访问层代码省掉了跨语言通信时的结构体对齐问题。2.2 通讯协议怎么选行情推送更适合长连接而不是 HTTP 轮询股票行情对时效性要求高常规做法是客户端与主站建立 TCP 长连接主站有增量数据就主动推给客户端。如果走 HTTP 轮询每秒拉一次已经是极限遇到盘中高频变动时延迟肉眼可见而且服务端要反复处理 HTTP 头浪费带宽。我用的是 Delphi 自带的 Indy 组件主站放 IdTCPServer客户端放 IdTCPClient。协议层定义一套简单的文本行协议比如客户端登录后发送自选股列表主站据此订阅对应的股票代码有行情变化时主站把最新价、成交量、涨跌幅拼成一行文本推给客户端。文本协议容易调试出问题可以直接在服务端日志里看到收发内容解析开销对现代机器也完全不是瓶颈。早期我尝试过二进制协议硬编码结构体排错时非常痛苦后来全部改成文本行协议这个决定帮我省了大量排查时间。2.3 行情表结构设计日线与分钟线分开落库数据库我选 SQLite 还是 MySQL 取决于数据量单机自用、历史数据不超过几十万条SQLite 完全够用客户端直接读本地文件都行多客户端共享、需要主站统一管理历史数据就上 MySQL。无论哪种表结构都遵循同一套原则行情数据按频率分表不要全塞进一张大表。日线数据表记录每只股票每个交易日的 OHLCV以股票代码加交易日期做联合主键这是所有指标计算的基础。分钟线数据量比日线大一个数量级单独建表按股票代码加交易时间做联合主键只保留最近一段时间超出保留期的数据定期清理。除权因子单独放一张表记录每只股票的送股、配股、分红信息给复权计算用后面避坑章节会专门提到它。CREATE TABLE daily_data ( stock_code VARCHAR(12) NOT NULL, trade_date DATE NOT NULL, open NUMERIC(10,2) NOT NULL, high NUMERIC(10,2) NOT NULL, low NUMERIC(10,2) NOT NULL, close NUMERIC(10,2) NOT NULL, volume BIGINT NOT NULL DEFAULT 0, amount NUMERIC(16,2) NOT NULL DEFAULT 0, PRIMARY KEY (stock_code, trade_date) ); CREATE TABLE minute_data ( stock_code VARCHAR(12) NOT NULL, trade_time DATETIME NOT NULL, open NUMERIC(10,2) NOT NULL, high NUMERIC(10,2) NOT NULL, low NUMERIC(10,2) NOT NULL, close NUMERIC(10,2) NOT NULL, volume BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (stock_code, trade_time) );这两张表建好后日线表负责长周期指标和回测分钟表负责当日分时和短周期监控。我在项目里把两张表的字段保持完全对齐这样切表查询时不需要写两套映射逻辑。要注意的是 NUMERIC(10,2) 对价格精度够用但计算指标时读出来转成 Double 后再算避免数据库端做浮点运算导致精度不一致这个问题后面会展开说。3. 客户端落地用 TCandleSeries 画 K 线指标计算全在本地3.1 K 线图的最小实现TCandleSeries 与涨红跌绿Delphi 做 K 线图首选自带的 TeeChart 组件里的 TCandleSeries。这个系列专门画 K 线一根 K 线由开高低收四个价格加上日期组成你只需要把数据逐根喂进去组件自己处理蜡烛体的绘制和上下影线。国内习惯涨红跌绿在 TCandleSeries 上直接设 UpColor 和 DownColor 两个属性就行不需要自己写绘制逻辑。加载历史数据的代码大概是这样的procedure TKLineChart.LoadDailyData(const AData: TStockDailyList); var i: Integer; begin if not Assigned(FSeries) then begin FSeries : TCandleSeries.Create(Self); FSeries.ParentChart : Chart1; FSeries.UpColor : clRed; // 国内习惯涨为红 FSeries.DownColor : clGreen; // 跌为绿 FSeries.Title : 日K; end; FSeries.BeginUpdate; try FSeries.Clear; for i : 0 to AData.Count - 1 do FSeries.AddOHLC( AData[i].Open, AData[i].High, AData[i].Low, AData[i].Close, AData[i].TradeDate); finally FSeries.EndUpdate; end; Chart1.Axes.Bottom.SetMinMax( AData.First.TradeDate, AData.Last.TradeDate); end;关键点有三个一是 BeginUpdate 和 EndUpdate 一定要成对出现一次性喂几千根 K 线时如果不加这个保护组件每加一根数据就会重绘一次界面会卡到没法看二是 AddOHLC 的日期参数传 TDateTime让时间轴自动按交易日分布跳过周末空缺三是画完后手动设置横轴范围否则组件默认的自动范围会把首尾多余空间也画进去K 线图看起来会偏左或偏右。3.2 MA/MACD 指标计算滑动窗口与 EMA 的 Delphi 实现K 线画完后叠加均线和 MACD 是最常用的需求。均线是滑动窗口的平均值MACD 依赖 EMA 指数移动平均。我把指标计算封装成独立单元输入一个 Double 数组输出一个 Double 数组这样画图时只需要把结果塞进 TLineSeries 里不需要关心指标内部实现。EMA 的计算代码很简单核心就是那个平滑系数 2 / (N 1)function CalcEMA(const Values: TDoubleArray; Period: Integer): TDoubleArray; var i: Integer; K: Double; begin SetLength(Result, Length(Values)); if Length(Values) 0 then Exit; K : 2 / (Period 1); Result[0] : Values[0]; for i : 1 to High(Values) do Result[i] : Values[i] * K Result[i - 1] * (1 - K); end;这段代码看着简单但有两个值得注意的地方。第一数组长度要和输入完全一致result[i] 对应第 i 根 K 线的均线值前面 Period - 1 个位置因为数据不足按 0 处理还是按空值处理要提前想清楚我一般返回 0画图时用一个带空值判断的 TLineSeries把为 0 的点设成空避免图表前端出现一条突兀的零轴直线。第二K 值精度直接影响指标结果建议用 Double 而不是 Single否则计算几百根 K 线的 EMA 后累计误差会让指标值和小数点后第二位开始漂移。MACD 的计算在 EMA 基础上三个公式就够DIF EMA(CLOSE, 12) - EMA(CLOSE, 26)DEA EMA(DIF, 9)MACD 柱 (DIF - DEA) * 2。DIF 和 DEA 用 TLineSeries 叠加在副图上MACD 柱用 TBarSeries 画成红绿柱。这里注意国内软件 MACD 柱通常放大了两倍显示乘 2 是一种经验值直接在计算层做不要放到画图层。3.3 行情刷新不卡 UI后台线程取数Synchronize 回主线程实时行情刷新是最容易把 UI 卡死的地方。TTimer 在滚动触发时虽然方便但它跑在主线程里如果回调里做网络请求或者数据库查询界面就直接僵住。我一开始在这个问题上翻了车后来改成后台线程拉数主线程只做界面更新。我一般这样组织刷新线程type TQuoteRefreshThread class(TThread) private FQuotes: TStockQuoteList; protected procedure Execute; override; procedure SyncUI; end; procedure TQuoteRefreshThread.Execute; begin while not Terminated do begin FQuotes : FQuoteProvider.FetchLatestQuotes; Synchronize(SyncUI); Sleep(1000); end; end; procedure TQuoteRefreshThread.SyncUI; begin // 主线程中刷新价格、分时图和自选列表 FMainForm.UpdateQuotes(FQuotes); end;这里几个参数是要反复调的。Sleep 间隔我取 1000 毫秒盘中行情一般够用如果想要更及时可以缩到 500但再低就要考虑数据源本身的推送频率频繁空轮询没什么意义。Synchronize 是阻塞式的如果主线程当前在处理一个耗时的重绘工作线程会等在那里所以 SyncUI 里只做最简单的 UI 赋值千万不要在里面做数据库写入或复杂计算。如果确实有大数据量要处理优先用 Queue 方法异步排队别用 Synchronize 硬等。这里还有一个容易被忽略的点FetchLatestQuotes 内部要连接主站取数如果网络异常会抛异常线程里必须包一层 try/except 并记录日志否则线程会静默死掉表现出来就是界面还在行情数字却不再更新。4. 主站实现Windows 服务做定时采集TCP 长连接做行情分发4.1 用 TServiceApplication 搭一个自启动的行情服务主站我推荐做成 Windows 服务好处是开机自启动、不依赖用户登录、崩溃后可以由服务管理器拉起。Delphi 新建一个 Service Application 项目框架就自动生成 TService 子类把核心逻辑挂在服务启动事件里就行了。type TQuoteService class(TService) private FQuoteCollector: TQuoteCollector; FQuoteServer: TIdTCPServer; FRefreshTimer: TTimer; protected procedure ServiceStart(Sender: TService; var Started: Boolean); override; procedure ServiceStop(Sender: TService; var Stopped: Boolean); override; end; procedure TQuoteService.ServiceStart(Sender: TService; var Started: Boolean); begin FQuoteCollector : TQuoteCollector.Create; FQuoteCollector.Start; FQuoteServer : TIdTCPServer.Create(nil); FQuoteServer.DefaultPort : 7788; FQuoteServer.Active : True; FRefreshTimer : TTimer.Create(nil); FRefreshTimer.Interval : 5000; FRefreshTimer.OnTimer : FQuoteCollector.OnRefreshTimer; Started : True; end; procedure TQuoteService.ServiceStop(Sender: TService; var Stopped: Boolean); begin FRefreshTimer.Enabled : False; FQuoteServer.Active : False; FQuoteCollector.Stop; FreeAndNil(FRefreshTimer); FreeAndNil(FQuoteServer); FreeAndNil(FQuoteCollector); Stopped : True; end;这里我把采集器、行情分发服务器、定时器拆成了三个独立对象服务只负责启动和停止它们。定时器用 5000 毫秒作为默认采集间隔日线数据收盘后补一次就够分钟数据盘中也 5 秒拉一次完全可以接受。如果采集源本身有推送接口把 OnTimer 换成事件订阅就行不影响服务主体结构。安装服务时我习惯手工写一个 install.bat用系统自带的 sc 命令创建服务比 IDE 里的向导更可控。要注意服务的运行账号如果后续要访问网络共享目录或映射盘需要给服务配置对应的账号权限普通 LocalSystem 账号访问不了带密码的网络路径。4.2 行情分发IdTCPServer 的 OnExecute 与连接清理IdTCPServer 是多线程模型每个客户端连接在独立的线程上下文里执行 OnExecute 事件。我的分发思路是OnConnect 时把 TIdContext 对象加入一个线程安全的连接列表OnDisconnect 时移除有行情数据时遍历列表逐个写入。代码骨架如下procedure TQuoteService.OnConnect(AContext: TIdContext); begin FConnectionList.Add(AContext); end; procedure TQuoteService.OnDisconnect(AContext: TIdContext); begin FConnectionList.Remove(AContext); end; procedure TQuoteService.OnExecute(AContext: TIdContext); var Cmd: string; begin Cmd : AContext.Connection.IOHandler.ReadLn; if Cmd SUB:SZ000001 then AContext.Connection.IOHandler.WriteLn(OK); end;OnExecute 里有两个大家容易踩的细节。第一个是 ReadLn 默认超时为 0如果客户端建立了连接但一直不发数据这个线程会一直阻塞在那里连接数累积起来服务端内存会不断涨。我会给 IOHandler 设置一个 30 秒的读超时超时后直接断开该连接。第二个是写数据要加换行符WriteLn 会自动附加行结束符客户端用 ReadLn 读取时才能正确区分一条消息的边界。行情推送我另外开一个方法定时器触发时遍历连接列表组装好消息一次性写出。注意不要在 OnExecute 线程里做耗时操作比如查数据库、计算指标这些都应该在采集器线程里提前算好分发只做内存数据的序列化。4.3 定时刷新与增量下发别再全表推送新手最常见的错误是每次刷新都把所有股票的全量数据推给所有客户端。股票数量一多这种全量推送几秒就把带宽打满客户端还会因为数据量太大卡住。正确的做法是增量下发主站维护一份当前最新行情的内存字典定时器刷新时只把价格发生变化的股票数据组成小包推出去。procedure TQuoteService.PushQuoteChanges(AChanges: TListTQuoteChange); var Item: TQuoteChange; I: Integer; begin if AChanges.Count 0 then Exit; for I : 0 to FConnectionList.Count - 1 do begin try for Item in AChanges do begin FConnectionList[I].Connection.IOHandler.WriteLn( Format(Q:%s:%.2f:%d, [Item.Code, Item.Price, Item.Volume])); end; except // 连接可能已断开交给 OnDisconnect 清理 end; end; end;这里我按股票代码、最新价、成交量三个字段组包客户端收到后直接更新本地内存不用重新拉全量。增量下发的频率由定时器控制我通常 1 秒推一次这样网络占用很平稳客户端也不会有卡顿感。另外注意 Format 里的打印格式要固定价格保留两位小数客户端解析时就不要做容错处理协议简单清晰比面面俱到更重要。5. 从主站到客户端最常踩的 5 个坑5.1 K 线图全量重绘导致客户端卡顿现象第一次加载某只股票 5 年日 K 数据约 1200 根时窗口要白屏两三秒拖动滚动条像在放幻灯片。原因是一次性 Clear 后循环 AddOHLCTCandleSeries 每加一根数据都触发一次重绘所耗时间成倍叠加。解决在批量装载数据前后加 BeginUpdate/EndUpdate同时限制图表最大显示的 K 线根数比如加载完数据后设置 BottomAxis 的可见范围只显示最近 250 根其余数据等用户缩放时再按需计算范围。我现在的习惯是数据装载和可见范围设置一起做不再单独依赖组件的自动缩放。5.2 数据库字符集不匹配导致股票名称乱码现象客户端从主站拉取的股票名称显示成“???”或者一段中文变成半个汉字。原因主站数据库用的是 UTF8客户端 FireDAC 连接串里没指定字符集或者指定了 GBK导致服务端返回的 UTF8 字节被按 GBK 解码。解决统一在连接串里显式指定字符集客户端和主站都用 UTF8Delphi 2009 之后字符串默认是 Unicode只要数据库读写两端字符集一致名称显示就不会出问题。我还额外做了一个约定所有通讯协议里的文本字段一律使用 UTF8 编码Delphi 侧用 TEncoding.UTF8.GetBytes/GetString 做显式转码不依赖隐式转换。5.3 浮点漂移指标计算结果对不上现象同一只股票、同一段历史数据客户端算出的 MACD 值和主站算出的值在小数点后第二位开始不一致回测结果也跟着跑偏。原因两个端点各自用 Double 算了一遍 EMA表面看算法一样但数据精度和读取顺序有细微差别累积下来就产生了肉眼可见的偏差。解决把指标计算收敛到单侧完成。我统一在主站计算指标客户端只接收结果如果客户端必须本地算就要求主站把原始价格以字符串而不是浮点数下传客户端解析成 Double 后再算避免中间精度损失。另一个细节是数据库里价格字段用 NUMERIC(10,2)读出来转成 Double 前先 Round 一下确保所有端点拿到的输入完全一致。5.4 客户端断开不清理服务端内存一路涨现象主站运行几天后内存占用从 200MB 涨到 1GB客户端数量并没有增加CPU 占用却不正常。原因客户端异常退出时 TCP 连接没有正常走 OnDisconnectIdTCPServer 的连接列表里残留了一堆死连接定时器每次遍历列表推送时往死连接写入数据还会抛异常。解决OnExecute 里设置读超时超过 30 秒没有收到任何数据的连接直接主动断开推送循环里包 try/except写失败时调用连接列表的删除接口清理。我还会给每条连接记录最后活跃时间每轮定时器清理一次超过阈值的连接做成双保险。5.5 历史数据除权不处理指标在除权日“跳水”现象某股票 10 送 10 后日 K 线图上出现一个巨大的向下跳空缺口MA5 和 MACD 都在除权日出现不合理的拐点回测策略在这里频繁误触发信号。原因数据库里存的是不复权原始价除权当天的开盘价直接跳空均线和 MACD 都是基于价格序列计算的价格断崖必然导致指标失真。解决在表结构里加除权因子表记录送股比例和分红金额读取日线数据时按前复权方式计算复权价。我的做法是客户端启动时先从主站拉一次除权因子表加载 K 线数据时在内存里完成复权计算数据库始终保留原始价这样既能还原真实走势又不污染原始数据。6. 再进一步把回测功能塞进客户端指标参数不用硬编码K 线和指标画出来后下一步最自然的需求就是回测按某个均线策略跑一遍历史数据看看收益曲线怎么样。这个功能完全可以放在客户端本地做因为历史数据和指标结果都已在内存里回测只是一次数组遍历。我用一个简单的策略文件来表示回测规则MA5 上穿 MA20 买入下穿卖出。回测代码只需遍历 K 线数组记录每次交易的买入价、卖出价最后统计总收益率、胜率和最大回撤。这里要特别提醒回测脚本里的参数一定要从外部配置文件读取不要硬编码在代码里。可以定义一个 TStrategyConfig 类用 JSON 序列化保存参数TStrategyConfig class public FastPeriod: Integer; // 快线周期 SlowPeriod: Integer; // 慢线周期 end;我见过太多人把周期直接写成常量改一个参数就重新编译一次客户端盘后复盘时修改参数的成本特别高。现在我的习惯是每次回测前动态加载配置文件改完参数立刻重跑一天能试几十组参数组合胜率和最大回撤的对比结果直接列表展示。这样回测才真正成为选参数的辅助工具而不是一个跑完就扔的演示功能。回测的验证方法也分享一下先用一段你非常熟悉的股票历史走势做冒烟测试把策略参数设在极端值比如快线 2、慢线 250确认回测结果符合你对走势的直觉判断然后再跑一组常规参数把收益率和行情软件里自带的指标对比一下确认没有数值偏差。我现在的习惯是每次改完指标计算代码先跑一遍回测再跑一遍实时行情两边数字对上了才敢继续动下一个功能。希望这套方案能帮你在 Delphi 上把一个完整的股票分析系统从零搭起来踩坑的部分尤其值得多看两眼。本文还有配套的精品资源点击获取