WPF做量化客户端这事儿,圈子里讨论的人不少,但真正能完整跑起来的参考项目并不多。多数人要么转到Web前端去了,要么直接用终端工具凑合。我用WPF重写了一个内部量化交易工作台的客户端,从上报数据、策略参数配置到日志追踪全走了一遍。今天先把第一篇整理出来:环境选择、配置文件体系、日志记录,以及整套基础框架是怎么搭出来的。这一篇偏“地基工程”,适合准备用WPF做桌面客户端、又不想在基础设施上反复返工的人。
1. 环境准备与方案选型:为什么是WPF,为什么这样搭
1.1 技术栈选择的底层逻辑
在量化客户端的技术选型上,有一道绕不开的选择题:到底是WinForms、WPF,还是直接上Electron、Avalonia这类跨平台方案?我这次坚定地选了WPF,因为几个很现实的原因。
第一,WPF在Windows桌面上的表现力是WinForms没法比的。量化客户端最常见的界面形态就是实时行情、自选股列表、策略状态面板、K线图、成交记录这些高频刷新的控件。WPF的渲染管线可以很好地处理数据绑定下的UI刷新,不会像WinForms那样在数据高频变动时出现明显的卡顿感。加上数据模板(DataTemplate)、样式(Style)、触发器(Trigger)这些机制,写出来的界面和交互逻辑能分离得比较干净。
第二,.NET生态下的库可以直接复用。量化客户端离不开网络请求、JSON解析、WebSocket连接、数据库访问这些基础能力,而WPF依附的.NET生态把这些能力全包了。钉钉、企业微信机器人告警、HTTP API对接、本地SQLite存储,这些在.NET上都有成熟方案,不需要像前端方案那样借助Node服务中转。
第三,MVVM模式在WPF里是原生支持的,这一点对量化客户端的开发效率至关重要。策略状态、账户权益、持仓数量这类数据,天然适合做成可观察对象(ObservableObject),界面只管绑定,逻辑层专注处理数据。代码量明显比传统的事件驱动写法少,也更好维护。
1.2 开发环境的具体配置清单
说完选型思路,直接上我实测过的环境配置清单。Visual Studio我用的是2022社区版,装的时候勾选“.NET 桌面开发”工作负载即可,这个工作负载会带WPF的模板和调试工具。如果你是VS Code用户,我也试过用dotnet CLI配合C# Dev Kit插件,基本能跑,但XAML的可视化设计器体验不如VS完整,新人不建议用VS Code做WPF。
.NET SDK版本我选的是.NET 8 LTS版本,因为量化客户端属于工具类应用,稳定性优先,LTS版本维护周期长,后续升级压力小。
打开Visual Studio Installer,确认以下组件已安装:
- .NET 桌面开发(含WPF、WinForms模板)
- .NET 8.0 Runtime
- 适用于最新v142生成工具的C++生成工具(这个在后续调试WPF渲染问题时会用到,也可以先装)
用命令行验证环境是否就绪:
dotnet --version dotnet --list-sdks如果正常输出SDK版本号,说明环境没问题。接下来我会建一个WPF解决方案,项目名称用QuantClient或者你自己命名的都行,目标框架直接选net8.0-windows。
有一点需要提前踩一下:WPF项目的目标框架必须是net8.0-windows,不能是net8.0,否则XAML编译器会报错。安装完SDK后需要检查项目的csproj文件,确保TargetFramework写的是net8.0-windows,这是很多新手第一次碰WPF就会遇到的坑。
1.3 解决方案的分层思路
环境备好之后,先别急着写代码。量化客户端的基础框架搭建,核心在于合理的分层。我把解决方案拆成了四个项目:
- QuantClient.App:启动入口,包含Main方法、App.xaml、主窗口Shell
- QuantClient.Core:核心模型,策略参数实体、配置模型、日志模型
- QuantClient.Infrastructure:基础设施层,配置加载、日志落地、外部API对接
- QuantClient.Service:业务服务层,行情服务、策略服务、账户服务
这个分层借鉴了DDD(领域驱动设计)的分层思想,但没做得那么重。最核心的一条原则是:App层是唯一依赖所有项目的层,Core层不依赖任何外部项目,Infrastructure层只做具体实现,Service层编排业务流程。
这样做的好处非常明显。后续加多策略、加多种数据源对接时,每加一块能力只需要在Service或者Infrastructure层新增实现,不会牵一发动全身。虽然第一次搭这类多项目解决方案的时候会多花一点配置时间,但一旦跑起来,后面写代码会非常顺畅。
2. 配置文件体系:把量化场景需要的参数都管起来
2.1 配置文件里放什么
量化客户端的配置和普通办公软件不一样,它要管的东西更敏感、更动态。我在搭建时把配置内容分成了三大类,各自有各自的存储逻辑。
第一类是连接参数。行情服务器地址、交易服务器地址、WebSocket重连间隔、API密钥等。这些参数的特点是“环境相关”,测试环境和生产环境的地址完全不同,不适合写死在代码里。
第二类是策略参数。交易品种、回测起始日期、滑点设置、止盈止损比例、仓位管理方式等。这类参数的特点是“业务相关”,需要经常调整,甚至需要在界面上提供修改入口,改动后要能热更新到运行中的策略实例。
第三类是客户端自身的运行参数。启动时是否自动连接服务器、日志级别、UI主题色、窗口位置大小等。这类参数是“用户偏好”。
2.2 为什么选JSON而不是XML或INI
配置文件的序列化格式我在JSON、XML和INI之间对比过。最终选了JSON,理由有三个。
XML的结构化能力强,但标签冗余太重,随便一个配置点就要写七八行标签,阅读负担太重。INI简单,但它只能表达键值对和简单的段落,面对多级嵌套的策略参数树就显得不够用。
JSON刚好卡在中间:支持嵌套结构,可读性不错,且.NET生态里System.Text.Json的解析性能极佳。我建立了appsettings.json作为主配置文件,放在程序根目录下:
{ "Logging": { "LogDir": "logs/", "MinimumLevel": "Information" }, "Market": { "QuoteServerWs": "ws://127.0.0.1:7900/quote", "HttpApiBase": "http://127.0.0.1:7900/api", "ReconnectIntervalSeconds": 5 }, "Strategy": { "Symbol": "rb2510", "InitCapital": 100000, "Slippage": 1, "StopLossPercent": 0.02, "TakeProfitPercent": 0.05, "DataFeedBufferSize": 2048 }, "AppSettings": { "AutoConnect": true, "Theme": "Dark", "WindowPlacement": { "Left": 120, "Top": 80, "Width": 1280, "Height": 720, "IsMaximized": false } } }JSON文件本身是容易被人为改乱的,所以我额外加了配置校验机制。App启动时会加载这个文件,然后用校验类逐段检查必填项,比如QuoteServerWs不能为空、InitCapital必须大于0。配置项有问题时直接抛出格式化异常并显示给用户,而不是等到行情连不上的时候才排查。
2.3 用Options模式管理配置
配置文件写好了,接下来就是如何在代码里优雅地读取。直接用ConfigurationManager把配置散落到各个界面控件里的写法我在早期项目里踩过,改一个参数就要满项目搜索字符串,维护成本极高。
这一版我直接用.NET自带的Microsoft.Extensions.Configuration和Options模式。先安装NuGet包:
Install-Package Microsoft.Extensions.Configuration Install-Package Microsoft.Extensions.Configuration.Json Install-Package Microsoft.Extensions.Options然后在Program.cs或者App.xaml.cs的启动流程里构建配置根:
var configuration = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .Build();接着定义强类型配置模型,比如MarketConfig:
public class MarketConfig { public string QuoteServerWs { get; set; } public string HttpApiBase { get; set; } public int ReconnectIntervalSeconds { get; set; } }绑定的时候用服务注册方式写入依赖注入容器:
services.Configure<MarketConfig>(configuration.GetSection("Market")); services.Configure<StrategyConfig>(configuration.GetSection("Strategy"));后续业务代码里需要通过构造函数注入IOptions 接口就能拿到配置实例,完全不需要关心配置文件从哪来、怎么变更。我试过在策略参数的界面上改配置后调用IConfigurationRoot的Reload方法,在没有重启客户端的情况下,新的策略参数能直接生效。这对量化交易场景来说是刚需,因为有时候策略跑在夜盘,不能随便重启。
2.4 配置文件的相对路径问题
配置文件放在程序根目录,但程序运行时的工作目录不一定是程序集所在目录。我第N次遇到“明明配置没问题但程序启动就提示找不到appsettings.json”的问题,最后定位到的原因就是SetBasePath没设置。
AppContext.BaseDirectory是程序集所在目录,也就是bin/Debug/net8.0-windows下的路径。而Environment.CurrentDirectory是当前工作目录,在Visual Studio里调试时它是项目文件所在目录。两种目录不一致时,AddJsonFile会按照当前工作目录找文件,结果找不到。
解决办法很简单,SetBasePath显式指定:
.SetBasePath(AppContext.BaseDirectory)之后再把appsettings.json的“复制到输出目录”属性设置为“如果较新则复制”。这段经历让我感受到配置文件路径这件小事,值得在框架搭建阶段一次性处理干净,而不是留到部署时再抓狂。
3. 日志记录要做对:量化客户端的日志是复盘的基础
3.1 日志选型:Serilog为什么值得用
量化客户端不能没有日志。你的策略昨天夜里为什么开仓了?为什么某个信号没触发?行情源断连了多久?这些问题如果不靠日志去复盘,就只能猜。既然日志是复盘的基础,那日志系统本身就应该极其可靠,并且能记录结构化信息。
我在Serilog和NLog之间对比过。NLog的历史更久,配置也很灵活,但Serilog在结构化日志方面的特点更突出——它天生支持把日志内容输出为JSON格式,可以直接推给日志管理平台做检索分析。另外Serilog的接收器(Sink)特别多,控制台、文件、数据库、HTTP接口都能对接。
对于量化客户端这个场景,我需要的核心能力有三个:
- 支持异步写入,日志IO不能阻塞行情处理的业务线程
- 支持按大小和日期滚动文件,磁盘空间可控
- 支持结构化数据,比如记录一笔策略信号时可以带上品种、价格、信号类型等字段,方便事后查询
这三条Serilog都是开箱即用。所以决定用Serilog作为基础设施层的日志组件。
3.2 日志配置的实操方案
NuGet安装:
Install-Package Serilog Install-Package Serilog.Sinks.Console Install-Package Serilog.Sinks.File Install-Package Serilog.Extensions.Hosting我的日志配置代码如下:
Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}") .WriteTo.File( path: "logs/quantclient-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 30, fileSizeLimitBytes: 100 * 1024 * 1024, outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff} {Level:u3}] {Message:lj}{NewLine}{Exception}") .CreateLogger();这里有几个细节值得展开说说。rollingInterval设为Day意味着每天生成一个独立日志文件,文件名带日期后缀,比如quantclient-20250618.log。retainedFileCountLimit设为30意味着最多保留30天的日志,超过自动清理,避免日志文件夹无限膨胀。fileSizeLimitBytes把单文件上限设为100MB,按天滚动的情况下单个文件一般到不了这个上限,但加了它更稳,防止某天行情异常时日志量暴涨把磁盘塞满。
我用的是严密的日志级别控制。在调试阶段把MinimumLevel切到Debug,可以看到策略内部的每一个信号计算中间值;正式运行切到Information,减少IO压力,只保留关键事件和错误。
3.3 结构化日志的量化场景应用
Serilog最强大的地方在于消息模板,也就是结构化日志。举个例子,我记录一条策略买入信号,普通写法是:
_logger.LogInformation($"买入信号: {symbol} 价格={price} 量={volume}");这种写法的问题在于日志系统看到的是已经格式化好的字符串,后续想按价格区间、按品种检索就只能全文搜索,效率低。
改成Serilog的结构化写法:
_logger.LogInformation("买入信号: {Symbol} 价格={Price} 量={Volume}", symbol, price, volume);这样Serilog会把Symbol、Price、Volume作为结构化字段保存下来。写进文件时虽然看起来还是文本,但如果流转到日志检索平台就可以直接按字段过滤。即使只是本地文件,在排查问题时用文本搜索工具去搜“Symbol=rb2510”也比搜“买入信号”精准得多。
在量化客户端里,我会固定这样一组事件日志规范:连接事件、断线事件、重连事件、策略信号事件、订单事件、持仓事件、系统异常事件。每类事件都有固定的事件名称和字段结构,这样日志文件本身就是一份可以被程序自动解析的复盘数据。
3.4 日志多线程写安全与性能
日志这块踩过一个比较典型的坑。量化客户端里行情线程、UI线程、交易线程同时运行,多个线程同时写日志时,普通文件写操作容易出现交织和异常。Serilog内部做了线程同步和队列管理,日志写入是异步的,业务线程只管把日志日志投递到队列里,后台就有一个专属的日志线程去刷写文件,所以我在这里没额外做并发控制。
但有一个性能问题需要自己控制:日志内容里不要记录大量无意义的对象。比如把整个持仓对象的JSON序列化结果塞进日志,一旦持仓量大,日志体积会瞬间膨胀,而且序列化本身消耗CPU。实操上我会记录对象的关键标识字段,比如合约代码、数量、开仓均价、浮动盈亏,而不是把整个对象ToString出来。
还有一个容易被忽略的细节是控制台编码。中文Windows环境下,控制台日志输出GBK编码会导致中文乱码,而文件日志则是UTF-8正常。解决方案是在Serilog的控制台Sink里设置编码,或者直接在App启动时设置Console.OutputEncoding为UTF-8。我实际测试下来,在csproj里加:
<PropertyGroup> <ConsoleEncoding>utf-8</ConsoleEncoding> </PropertyGroup>或者代码里调用Console.OutputEncoding = Encoding.UTF8都可以解决。
3.5 敏感信息保护
日志文件里有一类内容必须过滤:API密钥、账号密码、私钥。有些第三方SDK的异常信息会把请求参数原样带出来,如果这些参数里包含了密钥,问题就复杂了。我在框架层做了一层日志过滤器,对日志消息里出现的“key=xxx”“password=xxx”这类模式做脱敏替换。
Serilog有自定义的IDestructuringPolicy可以用来脱敏。比如定义SensitiveDataMaskPolicy,在Destructure的时候把匹配到敏感字段名的属性值替换为“***”。这是我自己在过程里补的,正经的日志框架文档不会写这些,但对量化交易客户端这种涉及凭据的桌面应用来说,这一层的保护值得花半小时加上去。
4. 基础框架搭建:MVVM、依赖注入与模块管理
4.1 分层项目结构搭建实录
下面进入工程量最大的部分:基础框架本身的搭建。我创建解决方案并添加项目用的是dotnet CLI,你也可以用Visual Studio的图形界面操作,效果一样。
dotnet new sln -n QuantClient dotnet new wpf -n QuantClient.App -f net8.0-windows dotnet new classlib -n QuantClient.Core -f net8.0 dotnet new classlib -n QuantClient.Infrastructure -f net8.0 dotnet new classlib -n QuantClient.Service -f net8.0 dotnet sln add QuantClient.App QuantClient.Core QuantClient.Infrastructure QuantClient.Service这里有个细节需要留意:Core目录下是基础设施无关的模型定义。使用WPF或控制台类库没关系,关键是把配置模型和策略实体定义在Core,不要让UI项目直接持有配置文件读取逻辑。
项目引用关系配置如下:
- QuantClient.App 引用 Core、Infrastructure、Service
- QuantClient.Service 引用 Core、Infrastructure
- QuantClient.Infrastructure 引用 Core
这样做的好处是,后续更换数据源、更换日志策略时,App层和Service层的代码几乎不用动。量化客户端的迭代频率高,我基本上每周都会有策略模型的变化,但这个分层结构让我很少需要动全局代码。
4.2 依赖注入:用Host构建应用边界
WPF默认模板是没有依赖注入的,它直接new一个MainWindow然后Show出来。这种写法在小项目里没问题,但项目一复杂,MainWindow里要调用行情服务、日志服务、配置服务,依赖关系就乱成一团麻。我选择用.NET泛型主机(Generic Host)来统一管理应用生命周期。
安装:
Install-Package Microsoft.Extensions.Hosting然后在App.xaml.cs中重构启动逻辑:
public partial class App : Application { private IHost _host; protected override async void OnStartup(StartupEventArgs e) { base.OnStartup(e); _host = Host.CreateDefaultBuilder() .ConfigureServices((context, services) => { services.Configure<MarketConfig>(context.Configuration.GetSection("Market")); services.Configure<StrategyConfig>(context.Configuration.GetSection("Strategy")); services.AddSingleton<IMarketDataService, MarketDataService>(); services.AddSingleton<ITradeService, TradeService>(); services.AddSingleton<IPolicyRunner, PolicyRunner>(); services.AddSingleton<MainWindow>(); }) .UseSerilog() // 自动把Serilog挂在Host生命周期上 .Build(); await _host.StartAsync(); var mainWindow = _host.Services.GetRequiredService<MainWindow>(); mainWindow.Show(); } protected override async void OnExit(ExitEventArgs e) { await _host.StopAsync(); _host.Dispose(); Log.CloseAndFlush(); base.OnExit(e); } }把MainWindow注册成单例后,它的构造函数参数会自动由容器注入。比如这样写:
public partial class MainWindow : Window { private readonly IMarketDataService _marketService; private readonly ITradeService _tradeService; public MainWindow(IMarketDataService marketService, ITradeService tradeService) { InitializeComponent(); _marketService = marketService; _tradeService = tradeService; } }这个模式的好处在于,MainWindow不关心这些服务怎么构造,它只知道自己需要什么。后面Mock一个行情服务做回测也可以直接替换注册项。这个对量化客户端特别有价值,因为实盘环境下的行情服务和回测环境下的历史行情服务接口相同,但实现完全不同,切换起来只需要改一行注册代码。
4.3 MVVM框架:CommunityToolkit.Mvvm到底怎么落地
WPF的数据绑定是它的核心优势,但要发挥这个优势,必须遵循MVVM模式。MVVM框架有很多选择:Prism功能强大但学习成本高;Caliburn.Micro稳定但更新频率低;我这次选了CommunityToolkit.Mvvm,理由是它轻量、官方维护、和现代.NET兼容性好。
安装:
Install-Package CommunityToolkit.Mvvm量化客户端里最常见的ViewModel场景是行情数据刷新的显示。定义一个行情ViewModel:
public partial class QuoteViewModel : ObservableObject { [ObservableProperty] private string symbol; [ObservableProperty] private double lastPrice; [ObservableProperty] private double changePercent; }这就是MVVM Toolkit的源生成器特性。声明[ObservableProperty]之后,源生成器会自动生成INotifyPropertyChanged接口的实现。不需要手写一堆SetProperty调用,代码量大幅度下降。
绑定到界面只需要在XAML里写:
<TextBlock Text="{Binding Symbol}" /> <TextBlock Text="{Binding LastPrice, StringFormat=F2}" /> <TextBlock Text="{Binding ChangePercent, StringFormat=+0.00%;-0.00%;0.00%}" />需要注意一个细节:MVVM Toolkit源生成器要求ViewModel是partial类。我见过有人把这个漏掉,结果编译报错找不到生成的属性。哪怕只是个小细节,卡住半天也是常见的事。
Command处理按钮交互也一样方便:
[RelayCommand] private void SendOrder() { // 调用交易服务下单 }XAML里用<Button Command="{Binding SendOrderCommand}" />就能绑定这个方法。相比传统的事件处理器,Command模式天然支持CanExecute状态控制——比如未连接行情服务器时,下单按钮置灰不可点。这个需求在量化客户端里太常见了。
4.4 主界面框架:别在最底层把路堵死
主窗口的框架结构我建议用简单的左菜单右内容布局,不要一开始就引入复杂的Tab控制。主窗口XAML骨架如下:
<Window x:Class="QuantClient.App.MainWindow" Title="量化交易工作台" Height="720" Width="1280"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="200" /> <ColumnDefinition Width="*" /> </Grid.ColumnDefinitions> <!-- 左侧导航菜单 --> <ListBox x:Name="NavList" Grid.Column="0" SelectionChanged="NavList_SelectionChanged"> <ListBoxItem Content="行情监控" Tag="QuoteView" /> <ListBoxItem Content="策略管理" Tag="StrategyView" /> <ListBoxItem Content="订单记录" Tag="OrderView" /> <ListBoxItem Content="运行日志" Tag="LogView" /> </ListBox> <!-- 右侧内容区域,运行时动态切换 --> <ContentControl x:Name="MainContent" Grid.Column="1" /> </Grid> </Window>切换页面时用代码动态创建UserControl实例并赋值给MainContent.Content。这里要用导航解决方案而不是写死所有页面,因为量化客户端的视图之间需要互相通信。比如策略管理页面修改了一个参数,行情监控页面要立刻刷新这个策略相关的行情订阅。
具体的导航思路是:维护一个viewName到ViewModel实例的字典,在启动时预先创建好所有单例ViewModel并注册到容器。切换页面时,先检查对应ViewModel的初始化状态,再注入到ContentControl。这样保证页面切换不会重复创建昂贵的资源,比如行情订阅通道。这个方案比正规的导航框架轻量许多,但对量化客户端来说完全够用了。
4.5 启动流程与全局异常捕获
框架搭好的最后一步,是给App加上全局异常捕获。WPF里常见的异常会导致程序直接崩溃。在UI线程和后台任务线程上分别处理。
App.xaml.cs里增加DispatcherUnhandledException事件:
public partial class App : Application { public App() { DispatcherUnhandledException += (sender, e) => { Log.Error(e.Exception, "UI线程未处理异常"); MessageBox.Show($"发生未处理异常,请查看日志。\n{e.Exception.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error); e.Handled = true; }; AppDomain.CurrentDomain.UnhandledException += (sender, e) => { if (e.ExceptionObject is Exception ex) { Log.Fatal(ex, "致命异常,进程终止"); } }; TaskScheduler.UnobservedTaskException += (sender, e) => { Log.Error(e.Exception, "Task未观察异常"); e.SetObserved(); }; } }这里要注意DispatcherUnhandledException中把e.Handled设为true,会让程序继续运行而非崩溃。但并非所有异常都应该吃掉,我在生产版本里设的是“弹窗提示继续运行”,同时保证日志完整记录。有些异常比如核心服务不可用直接置为Handled反而会让程序带病运行,所以我加了异常类型判断,只有非致命异常才吞掉。
整个贝斯框架搭建完成后,我在本机实测启动耗时约1.2秒,日志文件写入正常,配置热更新生效,左侧菜单切换到行情页面也流畅。到这里,第一期的基础框架算是站稳了。
5. 实操复盘:这套框架的边界与后续空间
搭过这一遍框架,我有几个切身的体会。先说框架的建议边界。这套架构适合中小规模的量化工具类客户端,比如个人研究用、小团队内部用。如果是大型券商级别的终端,需要更严格的插件化架构、多客户端的同步机制、复杂的权限体系,这套框架就需要继续演进,底层的分工思维还是通用的,但复杂度完全不是一个量级。
另外,我在实际搭建之前先画了一张架构草图。基础设施层、服务层、界面层之间的依赖关系一目了然后再动手写代码,效率比直接上来撸代码高很多。这个习惯我一直保持了。
一个小技巧值得分享给同样的开发者:新建解决方案时可以把每个项目都打开Nullable和ImplicitUsings,这样代码会更干净:
<Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings>这套基础框架搭完之后,下一步的扩展点很明确。我会继续完善行情模块:对接WebSocket实时行情、处理重连与心跳、行情数据的内存缓存。服务层已经有了雏形,后续加策略引擎、订单管理、回测模块都只往Service和Infrastructure层填实现即可。WPF做量化客户端的灵活度确实比想象中大,地基打稳了,往上盖楼速度就会快很多。