首页 / 资讯中心 / 文章详情

C# 实战:股票盯盘小工具开发,从数据拉取到 XML 落盘

C# 实战:股票盯盘小工具开发,从数据拉取到 XML 落盘 ★ FEATURED ARTICLE
简介这是一份基于C#开发的个人股票实时监控工具源码面向希望学习桌面应用开发、网络数据获取与金融工具实现的C#开发者及股票投资爱好者。项目围绕实时行情查看与盈亏计算展开用户输入买入成本价后即可动态跟踪持仓表现是理解编程与金融数据结合的实用起点。压缩包共39个文件约175KB以13个cs源代码文件为核心辅以resx与resources资源文件、config配置、wsdl与disco服务描述、sln解决方案及csproj工程文件另含编译产物与图标等结构完整可直接研究。目前已有624人学习下载。源码清晰展示了如何通过WebService发起网络请求、解析XML行情数据、组织WinForm界面并实时刷新盈亏结果读者可据此掌握C#网络通信、XML解析与用户交互设计的具体写法也能借鉴其模块划分与配置管理思路快速搭建属于自己的行情监控小工具。1. 用 C# 写一个股票盯盘小工具从数据拉取到 XML 落盘的完整思路手里有持仓的人大概都经历过这种场景开盘时间想瞄一眼自选股又不想开笨重的行情软件浏览器标签页切来切去还容易分心。这个用 C# 写的股票及时查看工具解决的就是这个具体问题——一个轻量级的桌面小窗口定时拉取行情、刷新显示、把自选股配置和快照数据用 XML 存下来关掉再开还在。它适合有 C# 基础、想拿一个完整小项目练手的开发者也适合想自己改改就能用的散户程序员。整个项目围绕 C# 和 XML 两个核心点展开C# 负责网络请求、界面刷新和定时调度XML 负责配置持久化和数据落盘。源码结构不复杂但麻雀虽小该有的轮子一个不少拿来拆解学习或者二次开发都挺合适。2. 数据从哪来行情接口选型与 XML 配置结构设计2.1 行情数据源的几种常见做法做股票查看工具第一个要解决的问题就是数据从哪来。常见做法有这么几类一是用公开的行情接口比如一些财经网站提供的 HTTP 接口返回 JSON 或文本格式的数据优点是接入简单、不需要额外依赖缺点是稳定性和字段完整性参差不齐二是用券商或数据服务商提供的 SDK功能全但通常需要开户或付费三是自己维护一个本地数据文件做模拟适合纯练手不追求实时性的场景。这个项目我一般会选第一种方案用 HTTP 请求拉取行情数据。原因很直接依赖少、跨平台、代码量可控。C# 里用HttpClient就能搞定不需要引入第三方网络库。请求回来的数据格式常见的是 JSON用System.Text.Json反序列化就行。如果你拿到的接口返回的是文本行按分隔符切分也能用。提示行情接口的可用性会随时间变化建议把接口地址做成可配置项方便后续替换。2.2 XML 配置文件的结构设计配置和数据的持久化用 XML这是这个项目的一个明确技术选型。为什么不用 JSON 或 SQLiteXML 在 C# 里的原生支持非常好System.Xml.Linq里的XDocument和XElement用起来很顺手而且 XML 的可读性强用户直接打开文件就能改自选股列表不需要额外的工具。配置文件的结构我一般这样设计?xml version1.0 encodingutf-8? StockWatchConfig !-- 刷新间隔单位秒 -- RefreshInterval5/RefreshInterval !-- 自选股列表 -- WatchList Stock Code600519 Name示例股票A / Stock Code000001 Name示例股票B / Stock Code300750 Name示例股票C / /WatchList !-- 数据快照存储路径 -- SnapshotPath./data/snapshot.xml/SnapshotPath /StockWatchConfig这个结构里RefreshInterval控制定时器间隔WatchList下的每个Stock节点代表一只自选股Code是股票代码Name是显示名称。SnapshotPath指定快照文件的存放位置。用 XML 的好处是层级清晰加字段也方便比如后面想加“成本价”“持仓数量”直接在Stock节点上加属性就行。2.3 用 XDocument 读写配置的完整代码读取配置的代码用XDocument.Load一行就能加载然后通过 LINQ to XML 查询节点using System.Xml.Linq; public class WatchConfig { public int RefreshInterval { get; set; } 5; public ListStockItem WatchList { get; set; } new(); public string SnapshotPath { get; set; } ./data/snapshot.xml; // 从 XML 文件加载配置 public static WatchConfig Load(string path) { var config new WatchConfig(); if (!File.Exists(path)) return config; // 文件不存在时返回默认配置 var doc XDocument.Load(path); var root doc.Root; if (root null) return config; // 读取刷新间隔做异常保护 var intervalEl root.Element(RefreshInterval); if (intervalEl ! null int.TryParse(intervalEl.Value, out int interval)) config.RefreshInterval Math.Max(1, interval); // 最小 1 秒防止刷太快 // 读取自选股列表 var watchListEl root.Element(WatchList); if (watchListEl ! null) { foreach (var stockEl in watchListEl.Elements(Stock)) { var code stockEl.Attribute(Code)?.Value; var name stockEl.Attribute(Name)?.Value; if (!string.IsNullOrEmpty(code)) config.WatchList.Add(new StockItem { Code code, Name name ?? code }); } } // 读取快照路径 var snapshotEl root.Element(SnapshotPath); if (snapshotEl ! null) config.SnapshotPath snapshotEl.Value; return config; } // 保存配置到 XML 文件 public void Save(string path) { var doc new XDocument( new XElement(StockWatchConfig, new XElement(RefreshInterval, RefreshInterval), new XElement(WatchList, WatchList.Select(s new XElement(Stock, new XAttribute(Code, s.Code), new XAttribute(Name, s.Name)))), new XElement(SnapshotPath, SnapshotPath) ) ); doc.Save(path); } } public class StockItem { public string Code { get; set; } ; public string Name { get; set; } ; }这段代码有几个关键点值得说清楚。Load方法里对每个字段都做了空值检查和格式校验int.TryParse失败时不会抛异常而是保留默认值Math.Max(1, interval)保证刷新间隔不会小于 1 秒避免请求过密。Save方法用XDocument的构造函数直接拼出完整 XML 树doc.Save会自动处理编码和格式化。参数方面path建议用绝对路径或相对于程序运行目录的路径避免工作目录变化导致找不到文件。2.4 行情拉取与数据模型行情拉取的逻辑封装成一个服务类核心是HttpClient发请求、解析响应、返回结构化数据using System.Net.Http; using System.Text.Json; public class QuoteService { private static readonly HttpClient _http new HttpClient { Timeout TimeSpan.FromSeconds(8) // 超时设短一点避免界面卡住 }; // 拉取单只股票的实时行情 public async TaskQuoteData? FetchQuoteAsync(string stockCode) { try { // 接口地址按实际可用的替换这里用占位 string url $https://example-api.com/quote?code{stockCode}; string json await _http.GetStringAsync(url); // 假设返回格式为 {code:600519,price:1800.00,change:1.5} var data JsonSerializer.DeserializeQuoteData(json); return data; } catch (Exception ex) { // 网络异常时返回 null由上层决定怎么展示 System.Diagnostics.Debug.WriteLine($拉取 {stockCode} 失败: {ex.Message}); return null; } } } public class QuoteData { public string Code { get; set; } ; public decimal Price { get; set; } public decimal Change { get; set; } public DateTime FetchTime { get; set; } DateTime.Now; }HttpClient用static readonly声明是常见做法避免频繁创建导致 socket 耗尽。Timeout设成 8 秒比默认的 100 秒短很多因为盯盘场景下用户等不了那么久。FetchQuoteAsync返回可空的QuoteData网络失败时返回null调用方根据null决定是显示“--”还是保留上一次的数据。QuoteData里加了FetchTime字段方便在界面上显示“最后更新时间”。3. 界面刷新与定时调度WinForms 下的线程安全实践3.1 定时器的选择与刷新逻辑C# 桌面端做定时刷新System.Windows.Forms.Timer和System.Timers.Timer都能用但两者行为差别不小。Forms.Timer的Tick事件在 UI 线程上执行更新控件不需要额外做Invoke写起来省事缺点是精度一般间隔太短比如小于 50ms会不准。Timers.Timer在后台线程触发精度高但更新 UI 必须跨线程调度容易踩坑。这个项目我一般用Forms.Timer因为盯盘刷新间隔通常在 3 到 10 秒精度要求不高省去跨线程的麻烦更划算。初始化代码private System.Windows.Forms.Timer _refreshTimer; private QuoteService _quoteService new QuoteService(); private WatchConfig _config; private void InitTimer() { _refreshTimer new System.Windows.Forms.Timer(); // 间隔从配置读取单位毫秒 _refreshTimer.Interval _config.RefreshInterval * 1000; _refreshTimer.Tick async (s, e) await RefreshAllQuotesAsync(); _refreshTimer.Start(); } private async Task RefreshAllQuotesAsync() { // 先停掉定时器防止上一次没跑完又触发 _refreshTimer.Stop(); try { foreach (var stock in _config.WatchList) { var quote await _quoteService.FetchQuoteAsync(stock.Code); if (quote ! null) { UpdateStockRow(stock.Code, quote); } } // 刷新完成后更新快照 SaveSnapshot(); } finally { _refreshTimer.Start(); // 无论成功失败都恢复定时器 } }这里有个容易翻车的地方Tick事件里用了async如果不在开头Stop、结尾Start上一次请求还没返回下一次又触发了请求会堆积。try-finally保证即使中间抛异常定时器也能恢复。UpdateStockRow负责把数据写到对应的界面行因为Forms.Timer在 UI 线程触发这里不需要Invoke。3.2 用 ListView 展示行情数据界面展示用ListView比较合适列头固定行数据动态更新private ListView _stockListView; private void InitListView() { _stockListView new ListView { Dock DockStyle.Fill, View View.Details, FullRowSelect true, GridLines true }; _stockListView.Columns.Add(代码, 80); _stockListView.Columns.Add(名称, 100); _stockListView.Columns.Add(最新价, 80); _stockListView.Columns.Add(涨跌幅, 80); _stockListView.Columns.Add(更新时间, 120); // 根据配置初始化行 foreach (var stock in _config.WatchList) { var item new ListViewItem(stock.Code); item.SubItems.Add(stock.Name); item.SubItems.Add(--); item.SubItems.Add(--); item.SubItems.Add(--); item.Tag stock.Code; // 用 Tag 存代码方便反查 _stockListView.Items.Add(item); } } private void UpdateStockRow(string code, QuoteData quote) { // 根据 Tag 找到对应行 var item _stockListView.Items.CastListViewItem() .FirstOrDefault(i (string)i.Tag code); if (item null) return; item.SubItems[2].Text quote.Price.ToString(F2); item.SubItems[3].Text ${quote.Change:F2}%; item.SubItems[4].Text quote.FetchTime.ToString(HH:mm:ss); // 涨跌颜色区分 item.SubItems[3].ForeColor quote.Change 0 ? Color.Red : Color.Green; }ListViewItem.Tag存股票代码是个实用技巧更新时不用遍历列去匹配。涨跌颜色用红绿区分符合国内用户习惯。SubItems的索引从 0 开始0 是代码列1 是名称列2 开始才是行情数据。3.3 快照数据的 XML 落盘每次刷新完成后把当前行情写一份快照到 XML这样程序异常退出后重启还能看到上次的数据private void SaveSnapshot() { var snapshotDoc new XDocument( new XElement(Snapshot, new XAttribute(SaveTime, DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss)), _stockListView.Items.CastListViewItem().Select(item new XElement(Stock, new XAttribute(Code, item.SubItems[0].Text), new XAttribute(Name, item.SubItems[1].Text), new XElement(Price, item.SubItems[2].Text), new XElement(Change, item.SubItems[3].Text), new XElement(UpdateTime, item.SubItems[4].Text) )) ) ); // 确保目录存在 var dir Path.GetDirectoryName(_config.SnapshotPath); if (!string.IsNullOrEmpty(dir) !Directory.Exists(dir)) Directory.CreateDirectory(dir); snapshotDoc.Save(_config.SnapshotPath); }快照文件每次覆盖写入不追加避免文件无限膨胀。SaveTime属性记录快照生成时间方便判断数据新鲜度。目录不存在时自动创建省得用户手动建文件夹。4. 避坑与排查XML 读写和网络请求里最容易翻车的几个点4.1 坑一XML 文件被占用导致保存失败现象程序运行中保存配置时报IOException提示文件正被另一进程使用。原因用XDocument.Load读取文件后如果没及时释放文件流或者同时开了多个程序实例文件句柄会冲突。另外某些编辑器打开 XML 文件时也会锁定。解决读取时用using var stream File.OpenRead(path)再XDocument.Load(stream)确保流及时关闭。保存时先写临时文件再替换避免写入过程中被中断导致文件损坏public void SaveAtomic(string path) { string tmpPath path .tmp; // 先写临时文件 this.Save(tmpPath); // 原子替换 if (File.Exists(path)) File.Replace(tmpPath, path, null); else File.Move(tmpPath, path); }4.2 坑二HttpClient 超时设置不生效现象设置了Timeout TimeSpan.FromSeconds(8)但请求卡了半分钟才返回。原因HttpClient.Timeout只覆盖从发送请求到接收响应的总时间如果 DNS 解析阶段就卡住了这个超时不一定能及时中断。另外如果用了HttpClientHandler并设置了Proxy代理层的超时是独立的。解决配合CancellationTokenSource做双重保险using var cts new CancellationTokenSource(TimeSpan.FromSeconds(8)); try { string json await _http.GetStringAsync(url, cts.Token); } catch (OperationCanceledException) { // 超时或手动取消 return null; }4.3 坑三定时器 Tick 里 async 导致请求堆积现象网络变慢时程序内存占用持续上升日志里同一只股票短时间内被请求了多次。原因Tick事件触发后async方法还没执行完下一次Tick又进来了多个请求并发执行界面更新也互相覆盖。解决用一个bool _isRefreshing标志位做互斥或者在Tick开头Stop、finally里Start。我一般两个都用标志位防重入Stop/Start防堆积private bool _isRefreshing false; private async Task RefreshAllQuotesAsync() { if (_isRefreshing) return; // 防重入 _isRefreshing true; _refreshTimer.Stop(); try { // ... 刷新逻辑 } finally { _isRefreshing false; _refreshTimer.Start(); } }4.4 坑四XML 特殊字符导致解析失败现象股票名称里带了或等字符保存后再读取时报XmlException。原因XML 对、、等字符有转义要求直接拼字符串会生成非法 XML。解决用XElement和XAttribute构造节点时框架会自动做转义不要手动拼 XML 字符串。如果确实需要手动处理用SecurityElement.Escape或System.Xml.XmlConvert做转义。另外读取时如果遇到非法字符可以在XDocument.Load前先做一次清洗。4.5 坑五快照文件路径在不同运行环境下不一致现象开发时快照能正常保存发布后双击 exe 运行却找不到文件。原因./data/snapshot.xml这种相对路径是相对于“当前工作目录”的而工作目录在双击 exe 和从 IDE 启动时可能不同。解决用AppDomain.CurrentDomain.BaseDirectory拼绝对路径string basePath AppDomain.CurrentDomain.BaseDirectory; string snapshotPath Path.Combine(basePath, data, snapshot.xml);或者在配置里直接写绝对路径程序启动时检查并提示用户。5. 进阶技巧用 XML 做历史数据回溯与简单预警5.1 把快照变成时间序列前面每次刷新覆盖写快照只保留最新一份。如果想做简单的历史回溯可以改成按日期分文件追加或者用一个History节点存最近 N 条记录private void AppendHistory(QuoteData quote, int maxRecords 100) { string historyPath Path.Combine( AppDomain.CurrentDomain.BaseDirectory, data, history.xml); XDocument doc; if (File.Exists(historyPath)) doc XDocument.Load(historyPath); else doc new XDocument(new XElement(History)); var root doc.Root!; root.Add(new XElement(Record, new XAttribute(Code, quote.Code), new XAttribute(Time, quote.FetchTime.ToString(yyyy-MM-dd HH:mm:ss)), new XElement(Price, quote.Price), new XElement(Change, quote.Change))); // 只保留最近 maxRecords 条 var records root.Elements(Record).ToList(); if (records.Count maxRecords) records.Take(records.Count - maxRecords).Remove(); doc.Save(historyPath); }maxRecords控制历史记录上限防止文件无限增长。records.Take(...).Remove()是 LINQ to XML 里删节点的常用写法注意要先ToList()再删否则遍历时修改集合会抛异常。5.2 基于阈值的简单预警有了历史数据就可以做最基础的预警当涨跌幅超过设定阈值时在界面上高亮或者弹一个提示。配置里加一个AlertThreshold节点AlertThreshold UpPercent5.0/UpPercent DownPercent-3.0/DownPercent /AlertThreshold读取后在UpdateStockRow里判断private decimal _upThreshold 5.0m; private decimal _downThreshold -3.0m; private void CheckAlert(QuoteData quote) { if (quote.Change _upThreshold) { // 涨幅超阈值标红加粗 NotifyAlert(${quote.Code} 涨幅 {quote.Change:F2}% 超过阈值); } else if (quote.Change _downThreshold) { NotifyAlert(${quote.Code} 跌幅 {quote.Change:F2}% 超过阈值); } } private void NotifyAlert(string message) { // 简单做法状态栏显示 系统托盘气泡 _statusLabel.Text message; _notifyIcon.ShowBalloonTip(3000, 行情预警, message, ToolTipIcon.Warning); }NotifyIcon是 WinForms 自带的托盘组件不需要额外依赖。ShowBalloonTip的第二个参数是标题第三个是内容第四个是图标类型。阈值从配置读取用户改 XML 就能调整不用重新编译。5.3 验证 XML 读写是否正确的几个检查点写完 XML 相关代码后我一般会走一遍这几个检查第一手动把配置文件里的RefreshInterval改成非数字看程序是否用默认值而不是崩溃第二在股票名称里故意加和保存后重新打开看是否正常第三把快照文件删掉再启动看程序是否自动创建第四用文本编辑器打开生成的 XML确认缩进和编码正常没有乱码。这几个检查点花不了几分钟但能挡掉大部分低级问题。从那以后我每次写完 XML 读写逻辑都会先手动改坏配置文件跑一遍确认异常处理到位了再继续写别的。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站