ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Delphi打造股票分析系统:主站+客户端全栈实践

用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: TList<TQuoteChange>); 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 后循环 AddOHLC,TCandleSeries 每加一根数据都触发一次重绘,所耗时间成倍叠加。解决:在批量装载数据前后加 BeginUpdate/EndUpdate,同时限制图表最大显示的 K 线根数,比如加载完数据后设置 BottomAxis 的可见范围只显示最近 250 根,其余数据等用户缩放时再按需计算范围。我现在的习惯是数据装载和可见范围设置一起做,不再单独依赖组件的自动缩放。

5.2 数据库字符集不匹配导致股票名称乱码

现象:客户端从主站拉取的股票名称显示成“???”,或者一段中文变成半个汉字。原因:主站数据库用的是 UTF8,客户端 FireDAC 连接串里没指定字符集,或者指定了 GBK,导致服务端返回的 UTF8 字节被按 GBK 解码。解决:统一在连接串里显式指定字符集,客户端和主站都用 UTF8;Delphi 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 连接没有正常走 OnDisconnect,IdTCPServer 的连接列表里残留了一堆死连接,定时器每次遍历列表推送时往死连接写入数据还会抛异常。解决: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 上把一个完整的股票分析系统从零搭起来,踩坑的部分尤其值得多看两眼。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进