ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C# WinForms 实战:打造实时股票行情查看与价格提醒工具

C# WinForms 实战:打造实时股票行情查看与价格提醒工具

简介:这份资源是一套基于C#开发的个人股票实时查看工具源码,面向希望学习桌面应用开发、网络数据获取与XML解析的C#初学者和进阶开发者。项目通过WebService获取股票行情,并支持根据买入成本价动态计算盈亏,是理解金融数据与编程结合的实用案例。压缩包共39个文件,约175KB,以13个cs源代码文件为核心,辅以resx与resources资源文件、config配置、wsdl与disco服务描述文件,以及sln解决方案和csproj工程文件,结构完整,便于直接编译运行与二次开发。目前已有624人学习下载。通过研读源码,读者可以掌握C#中网络请求、XML数据解析、用户界面交互与盈亏计算逻辑的实现方式,了解如何将实时行情数据转化为直观的监控界面,适合作为课程设计、毕业设计或自学练手项目,帮助提升桌面软件与金融工具开发能力。

1. 用 C# 写一个自己用的股票及时查看工具:为什么我最后没选网页版

盯盘这件事,很多人第一反应是打开浏览器收藏夹里那几个行情页,或者装一个手机 App。但真到需要「及时」两个字的时候,网页版的问题就暴露了:刷新靠手点,切标签页就断,多只股票要来回翻,想加个自己算的指标还得等平台支持。我最早也是这么凑合的,直到某天同时盯五只票,来回切页面切到眼花,才下决心用 C# 自己写一个桌面端的小工具。

这个工具要解决的核心问题很朴素:把关心的几只股票代码固定下来,程序按固定间隔去拉最新价,界面上实时刷新,涨跌用颜色区分,最好还能在价格穿过某个阈值时弹个提醒。它适合两类人:一类是有一定 C# 基础、想练手又想做点有用东西的开发者;另一类是纯粹想要一个轻量、可控、不依赖第三方客户端盯盘方式的从业者。整篇我会按「先跑通最小版本,再补实时刷新和提醒,最后讲坑」的顺序讲,源码结构也会给出来,你照着改就能用。

需要先说明一点:行情数据从哪来,是这个工具能不能落地的第一道坎。常见做法是接公开的行情接口,或者用券商提供的行情推送。不同来源的字段格式、刷新频率、是否需要鉴权都不一样。我下面用的是一种通用的 HTTP 拉取思路,具体地址和字段你替换成自己能拿到的即可,重点是整套拉取、解析、刷新、提醒的骨架。

2. 先把最小可运行版本跑起来:项目结构与拉取解析

2.1 技术选型:为什么用 WinForms 而不是 WPF

桌面端 C# 做界面,主流就 WinForms 和 WPF 两条路。这个工具我选了 WinForms,理由很实际:界面简单,就一个表格加几个按钮,WinForms 的 DataGridView 拖上去就能用,数据绑定和刷新逻辑几行代码搞定;WPF 的 XAML 数据绑定和 MVVM 更优雅,但对一个自己用的小工具来说,前期搭架子的成本偏高。如果你后续想做成多窗口、复杂样式、动画提醒,那 WPF 更合适,但第一版没必要。

数据存储用内存里的一个 List 就够,不需要数据库。配置(股票代码列表、刷新间隔、提醒阈值)用 JSON 文件存本地,程序启动时读,改完写回。JSON 序列化用 System.Text.Json,.NET 自带,不用引第三方包。

网络请求用 HttpClient。这里有个血泪经验:HttpClient 不要每次请求都 new 一个,频繁创建会耗尽 socket,正确做法是全局单例或者用 IHttpClientFactory。小工具里我直接用一个静态的 HttpClient 实例。

2.2 项目骨架与核心数据结构

先建一个 WinForms 项目,目标框架选 .NET 6 或 .NET 8 都行,我用的 .NET 8。核心就三个类:股票数据模型、行情拉取服务、主窗体。

// StockQuote.cs —— 单只股票的行情快照 public class StockQuote { public string Code { get; set; } = ""; // 股票代码,如 "600000" public string Name { get; set; } = ""; // 股票名称 public decimal Price { get; set; } // 最新价 public decimal PrevClose { get; set; } // 昨收价,用来算涨跌幅 public decimal ChangePercent => // 涨跌幅,计算属性 PrevClose == 0 ? 0 : (Price - PrevClose) / PrevClose * 100; public DateTime UpdatedAt { get; set; } // 本次数据的时间戳 }

这个模型里 ChangePercent 用计算属性而不是存字段,好处是 Price 或 PrevClose 一变,涨跌幅自动跟着变,不用手动同步。PrevClose 为 0 时返回 0,避免除零。UpdatedAt 用来在界面上显示数据新鲜度,后面排查「数据不刷新」时全靠它。

// QuoteService.cs —— 行情拉取与解析 public class QuoteService { // 全局单例,避免 socket 耗尽 private static readonly HttpClient _http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) // 超时设短,避免界面卡死 }; public async Task<StockQuote> FetchAsync(string code) { // 这里的 url 换成你自己能拿到的行情接口地址 string url = $"https://example-quote-api.com/quote?code={code}"; string json = await _http.GetStringAsync(url); // 用 JsonDocument 解析,字段名按实际接口替换 using var doc = JsonDocument.Parse(json); var root = doc.RootElement; return new StockQuote { Code = code, Name = root.GetProperty("name").GetString() ?? code, Price = root.GetProperty("price").GetDecimal(), PrevClose = root.GetProperty("prevClose").GetDecimal(), UpdatedAt = DateTime.Now }; } }

逻辑说明:FetchAsync 是异步的,返回 Task,这样界面线程不会被网络请求阻塞。Timeout 设 5 秒是个经验值,太长会让界面在接口卡住时显得假死,太短又容易在弱网下误判失败。解析用 JsonDocument 而不是强类型反序列化,是因为不同接口字段差异大,用 GetProperty 按需取更灵活,字段缺失时抛异常也能被上层捕获。

参数说明:code 是股票代码字符串,注意有些接口要带市场前缀(如 sh600000),有些不要,这个必须对着接口文档确认,搞错了会返回空数据而不是报错,属于典型的「静默失败」。

2.3 主窗体:定时刷新与界面绑定

主窗体上放一个 DataGridView、一个「开始/停止」按钮、一个 NumericUpDown 控制刷新间隔(秒)。刷新用 System.Windows.Forms.Timer,注意这个 Timer 的回调跑在 UI 线程上,所以里面不能做同步阻塞的网络请求,得用 async void 配合 await。

// MainForm.cs 关键片段 private readonly QuoteService _service = new QuoteService(); private readonly List<string> _codes = new() { "600000", "000001", "300750" }; private readonly BindingList<StockQuote> _quotes = new(); private System.Windows.Forms.Timer _timer = new(); public MainForm() { InitializeComponent(); grid.DataSource = _quotes; // BindingList 变更会自动刷新界面 _timer.Interval = 3000; // 默认 3 秒 _timer.Tick += async (s, e) => await RefreshAllAsync(); } private async Task RefreshAllAsync() { foreach (var code in _codes) { try { var q = await _service.FetchAsync(code); // 找到已有记录就更新,没有就新增 var exist = _quotes.FirstOrDefault(x => x.Code == code); if (exist == null) _quotes.Add(q); else { exist.Price = q.Price; exist.PrevClose = q.PrevClose; exist.UpdatedAt = q.UpdatedAt; } } catch (Exception ex) { // 单只失败不影响其他,记录到日志区 Log($"{code} 拉取失败: {ex.Message}"); } } }

逻辑说明:用 BindingList 而不是普通 List,是因为它实现了 IBindingList,元素属性变化时 DataGridView 会自动重绘,省掉手动调 Refresh。刷新时先查再更新,避免每次 Add 导致列表越来越长。每只股票单独 try-catch,一只失败不拖累其他,这是多标的拉取必须做的隔离。

参数说明:Timer.Interval 单位是毫秒,3000 就是 3 秒。这个值不要设太小,公开接口一般有频率限制,1 秒一次很容易被限流甚至封 IP;也不要太大,超过 10 秒就谈不上「及时」了。我一般设 3 到 5 秒。

3. 让数据真正「及时」:刷新策略、涨跌染色与阈值提醒

3.1 刷新策略:串行还是并发,间隔怎么定

最小版本里我是串行拉取,五只股票每只 200 毫秒,一轮下来一秒左右,还能接受。但如果你盯二十只,串行就会让一轮刷新超过间隔,Timer 会堆积回调,界面越来越卡。这时候要改成并发拉取。

private async Task RefreshAllAsync() { // 并发拉取所有标的,Task.WhenAll 等全部完成 var tasks = _codes.Select(async code => { try { return await _service.FetchAsync(code); } catch { return null; } // 失败返回 null,后面过滤 }); var results = await Task.WhenAll(tasks); foreach (var q in results.Where(x => x != null)) { var exist = _quotes.FirstOrDefault(x => x.Code == q!.Code); if (exist == null) _quotes.Add(q!); else { exist.Price = q.Price; exist.PrevClose = q.PrevClose; exist.UpdatedAt = q.UpdatedAt; } } }

逻辑说明:Task.WhenAll 并发发起所有请求,总耗时约等于最慢的那一只,而不是所有之和。失败返回 null 而不是抛异常,是为了让 WhenAll 不被单个异常打断。注意并发数别无限大,二十只以内没问题,上百只建议用 SemaphoreSlim 限流,否则对接口压力太大也容易触发风控。

参数说明:并发拉取时,HttpClient 的连接数限制(默认每服务器 2 个)可能成为瓶颈,可以在程序启动时调 ServicePointManager.DefaultConnectionLimit,或者用 SocketsHttpHandler 配置 MaxConnectionsPerServer。这个属于进阶调优,标的少的时候不用管。

3.2 涨跌染色:DataGridView 的 CellFormatting

行情工具不染色等于白做,红涨绿跌一眼扫过去才有意义。WinForms 里用 CellFormatting 事件按涨跌幅设置前景色。

private void Grid_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { if (grid.Columns[e.ColumnIndex].Name != "ChangePercent") return; if (e.Value == null) return; decimal pct = Convert.ToDecimal(e.Value); // 国内习惯:涨红跌绿,平盘黑 if (pct > 0) e.CellStyle.ForeColor = Color.Red; else if (pct < 0) e.CellStyle.ForeColor = Color.Green; else e.CellStyle.ForeColor = Color.Black; }

逻辑说明:CellFormatting 在单元格绘制时触发,只处理涨跌幅那一列,其他列直接 return,避免无谓开销。颜色用 Color.Red 和 Color.Green,如果你习惯欧美配色(涨绿跌红),把两个分支对调即可。注意 e.Value 可能是格式化后的字符串,如果列绑定的就是 decimal,这里能直接转。

参数说明:判断列用 Columns[e.ColumnIndex].Name,前提是你在设计器里给列起了名字,或者用 DataPropertyName 绑定。如果列名对不上,染色会静默失效,这是新手最容易踩的坑之一。

3.3 阈值提醒:价格穿越时弹窗还是托盘通知

提醒逻辑的关键是「穿越」而不是「大于」。如果只是判断 Price > 阈值,那价格一直在阈值上方时,每次刷新都会弹,烦到你想砸键盘。正确做法是记录上一次的状态,只在状态翻转时触发。

// 每只股票维护一个是否已提醒的标记 private readonly Dictionary<string, bool> _alerted = new(); private void CheckAlert(StockQuote q, decimal threshold) { bool above = q.Price >= threshold; bool wasAbove = _alerted.TryGetValue(q.Code, out var v) && v; if (above && !wasAbove) // 从下方穿到上方,触发一次 { notifyIcon.ShowBalloonTip(3000, "价格提醒", $"{q.Name} 已涨到 {q.Price}", ToolTipIcon.Info); } _alerted[q.Code] = above; // 更新状态 }

逻辑说明:_alerted 字典记录每只股票上一次是否在阈值上方。只有「这次在上方、上次不在」才提醒,穿越一次弹一次。用托盘气泡通知(NotifyIcon)而不是 MessageBox,是因为 MessageBox 会阻塞界面,行情刷新就停了,这是很多人第一版会犯的错。

参数说明:ShowBalloonTip 第一个参数是显示毫秒数,3000 即 3 秒。阈值 threshold 建议做成可配置,每只股票一个,存在 JSON 配置里。提醒只做单向(向上穿越)还是双向(上下都提醒),看你自己需求,双向就把判断反过来再加一个分支。

4. 避坑与排查:那些让我熬夜的静默失败

4.1 界面卡死但没报错

现象:点了开始之后,界面过几秒就点不动了,任务管理器看 CPU 不高,也没异常弹窗。

原因:多半是在 UI 线程里做了同步网络请求,比如用了 _http.GetStringAsync(url).Result 或者 .Wait()。这会造成死锁——UI 线程等网络结果,而网络回调又要回到 UI 线程,互相等。

解决:全程用 async/await,从事件处理到服务方法一路 async 到底,绝不混用 .Result 和 .Wait()。Timer 的 Tick 事件写成 async (s, e) => await RefreshAllAsync()。

4.2 数据一直不刷新,但也没报错

现象:程序跑着,界面上的价格就是不动,UpdatedAt 也不变。

原因:接口返回的字段名和你解析时用的名字对不上,GetProperty 抛了 KeyNotFoundException,但被外层 catch 吞了,只记了日志你没看。或者接口对代码格式有要求(要带前缀),你传的格式不对,返回了空数据。

解决:先把 catch 里的异常完整打出来,别只记 Message,把 StackTrace 也记上。然后手动用浏览器或 curl 请求一次接口,对比返回的 JSON 字段名和你的解析代码。代码格式问题就对着接口文档加前缀。

4.3 刷新频率一高就被限流

现象:刚开始正常,跑几分钟后全部拉取失败,过一会儿又恢复。

原因:请求太频繁触发了接口的频率限制,返回 429 或直接拒绝连接。

解决:把刷新间隔调大,或者做退避重试——失败后等 2 秒、4 秒、8 秒再试,而不是死循环硬刚。多标的并发时也要控制并发数,别一次性全发出去。

4.4 涨跌染色时有时无

现象:有时候颜色对,有时候全是黑的。

原因:CellFormatting 里判断列名的方式不对,或者 e.Value 的类型不是你以为的 decimal,转换失败被吞。

解决:在事件开头加一句日志,把 e.ColumnIndex、列名、e.Value 的类型打出来,跑一次就知道问题在哪。列名建议用 DataPropertyName 绑定到模型属性,比手写字符串可靠。

4.5 提醒弹窗把界面卡住

现象:价格到阈值时弹了 MessageBox,点确定之前行情完全不刷新。

原因:MessageBox.Show 是模态阻塞的,它会开一个新的消息循环,把原来的刷新逻辑挡住。

解决:改用 NotifyIcon 的 ShowBalloonTip,非阻塞。如果一定要用窗口提醒,就开一个独立的非模态窗体,别用 MessageBox。

5. 进阶:把配置、日志和打包做扎实

走到这一步,工具已经能用了,但离「自己长期用」还差几块。第一块是配置外置。把股票代码列表、刷新间隔、每只股票的提醒阈值写进一个 config.json,程序启动读、退出写,改股票不用重新编译。

public class AppConfig { public List<string> Codes { get; set; } = new(); public int RefreshSeconds { get; set; } = 3; public Dictionary<string, decimal> AlertThresholds { get; set; } = new(); } // 读取 var cfg = JsonSerializer.Deserialize<AppConfig>(File.ReadAllText("config.json")) ?? new AppConfig(); // 写入 File.WriteAllText("config.json", JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true }));

WriteIndented 让 JSON 带缩进,方便你手动改。反序列化可能返回 null(文件为空或格式错),用 ?? 兜底成默认配置,避免启动就崩。

第二块是日志。别用 Console.WriteLine,WinForms 没有控制台。用一个简单的写文件方法,按天分文件,出问题时能回溯。

private static readonly object _logLock = new(); private static void Log(string msg) { lock (_logLock) // 多线程并发写要加锁 { string line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {msg}"; File.AppendAllText($"log_{DateTime.Now:yyyyMMdd}.txt", line + Environment.NewLine); } }

lock 是必须的,因为并发拉取时多个线程可能同时写日志,不加锁会丢内容甚至抛 IO 异常。

第三块是打包。自己用的话,dotnet publish 成单文件就行,不用装运行时。命令是 dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true。这样产出一个 exe,拷到哪都能跑。注意 self-contained 会让文件变大(几十兆),但省去了装 .NET 运行时的麻烦,自己用值得。

最后说个我自己的习惯:这个工具我从来不接实盘交易,只做查看和提醒。原因很简单,自己写的程序稳定性没经过长期验证,网络一抖、接口一改就可能出问题,拿它做交易决策的输入可以,拿它直接下单就是给自己埋雷。行情查看工具的价值在于「让你更快看到」,不在于「替你做决定」。把刷新间隔、提醒阈值这些参数调到自己舒服的值,比堆功能重要得多。希望帮到你。

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

RELATED READING

延伸阅读

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