☰
C# 实战:股票盯盘小工具开发,从数据拉取到 XML 落盘
2026/10/16 18:45:29 网站建设 项目流程

简介:这是一份基于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 或 SQLite?XML 在 C# 里的原生支持非常好,System.Xml.Linq里的XDocument和XElement用起来很顺手,而且 XML 的可读性强,用户直接打开文件就能改自选股列表,不需要额外的工具。

配置文件的结构我一般这样设计:

<?xml version="1.0" encoding="utf-8"?> <StockWatchConfig> <!-- 刷新间隔,单位秒 --> <RefreshInterval>5</RefreshInterval> <!-- 自选股列表 --> <WatchList> <Stock Code="600519" Name="示例股票A" /> <Stock Code="000001" Name="示例股票B" /> <Stock Code="300750" 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 List<StockItem> 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 Task<QuoteData?> 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.Deserialize<QuoteData>(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.Cast<ListViewItem>() .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.Cast<ListViewItem>().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> <UpPercent>5.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 读写逻辑,都会先手动改坏配置文件跑一遍,确认异常处理到位了再继续写别的。希望帮到你。

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

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

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

立即咨询