WPF 自动化交易应用程序






4.95/5 (30投票s)
WPF 自动化交易应用程序
引言
在此,我介绍一个独立桌面应用程序的设计,该应用程序包含独立运行的服务和一个响应式用户界面,旨在运行交易策略。中介者模式(Mediator pattern)的特定使用是此构建的基石,可以回收用于构建其他类型的并发系统。
特点
- 接收和处理实时报价
- 根据交易策略生成买入和卖出信号
- 向交易场所发起买入和卖出订单
- 监听并接收交易执行确认
- 通过响应式用户界面监控交易活动
- 归档报价、交易和日志以供交易后分析
背景
该项目最初的想法是针对比特币/欧元汇率运行趋势跟随交易策略。它旨在利用 Kraken.com 交易场所,该场所当时提供最先进的订单类型。由于运行此程序从未记录过任何利润,我添加了一个模拟的交易所客户端,以便人们可以在无需在 Kraken 开设账户的情况下运行它。还提供了原始的 Kraken API 客户端,如果您已有账户,可以将安全密钥添加到应用程序的配置文件中并尝试进行一些真实交易。
应用的交易计划是由 George Kleinman 开发的趋势跟随策略,被称为自然数法 (Natural Numbers Method (N#M))。它使用价格的加权移动平均 (WMA) 来识别趋势,并依赖于经验观察,即市场在以零结尾的价格下表现不同;Kleinman 称之为自然数。当价格穿越移动平均线(Setup)时,我们预期一个新的趋势正在形成。如果市场随后在 Setup 条形图的最高价(最低价)上方(下方)开盘,则 Setup 得到确认。确认后,我们在下一个自然数处下单。此过程在两个方向上都有效。
设计
该项目的基础是一个 WPF 应用程序,它利用中介者模式来构建松散耦合的独立运行组件,这些组件彼此不了解,但可以实时通信。它们通过向中介者发送消息进行通信,中介者会调用所有已注册接收这些消息的方法。在这方面,许多内容借鉴了 MVVM Disciples (http://wpfdisciples.wordpress.com/)。
应用程序的主屏幕本身被分解成独立的面板,这些面板通过中介者与其他服务通信。我们将把这组面板称为一个单一的 UI 组件。
应用程序的其他组件在交易系统中各自扮演特定角色。QuoteService
从交易所获取交易数据,定期打包并发送消息通知中介者数据已到达。StrategyService
,已注册接收此特定消息,会处理数据并决定何时开仓或平仓,以及何时设置或调整止损订单。当发生此类事件时,StrategyService
会将适当的消息发送给中介者,BrokerageService
已在此注册了其兴趣。BrokerageService
向交易所下订单,监听直到订单被取消或关闭,并在订单状态发生变化时通知中介者。StrategyService
注册接收来自 BrokerageService
的消息,以跟踪实际交易的合约数量。在此过程中,UI 组件监听来自所有交易服务的消息,并更新表格、图表和日志控制台供用户跟踪交易活动。
中介者和 ObservableObject
与 Mediator
关联的组件被封装在一个 ObservableObject
类中,该类包含 Mediator
的一个 static
实例。要注册接收给定消息的方法会用一个属性进行修饰,该属性指定要订阅的消息以及方法期望的参数类型。当一个组件需要通知其他组件一个事件时,它会调用 Mediator
的 NotifyColleagues
方法,并附带消息和附加的数据对象。收到消息后,Mediator
会调用所有已注册接收该消息的方法。
例如
StrategyService
为 NewPriceData
消息注册一个方法,该方法将新数据通过核心算法运行,并最终改变交易头寸。
[MediatorMessageSink(MediatorMessages.NewPriceData, ParameterType = typeof(PriceData))]
public void NewPriceDataReceived(PriceData priceData)
{
TreatNewPriceData(priceData);
}
UIComponent
也为 NewPriceData
消息注册一个方法,以便在新价格点到达时更新图表。
[MediatorMessageSink(MediatorMessages.NewPriceData, ParameterType = typeof(PriceData))]
public void UpdateGraphData(PriceData chartData)
{
App.Current.Dispatcher.Invoke(DispatcherPriority.Normal, new Action(
() =>
{
candleStickCollection.Add(chartData.CandleStick);
wmaCollection.Add(chartData.WmaPoint);
}));
}
该消息源自 QuoteService
,当它处理完一批新的交易数据时。
Mediator.NotifyColleagues<PriceData>(MediatorMessages.NewPriceData,
new PriceData(candleStick, WMAPoints.Last(), true));
可组合部分和 MEF
与交易场所和后端数据存储通信的系统部分已被抽象到相应的接口后面。这允许更大的可测试性,并最终扩展系统以使用其他第三方服务。依赖注入使用托管可扩展性框架 (MEF)。
交易所客户端
IExchangeClient
接口代表了交易场所的入口点。它定义了一组需要由系统中的其他组件调用的函数,但隐藏了实际的细节。到目前为止,有两个客户端实现:KrakenClient
,它是 Kraken
API 的真实客户端,以及 KrakenClientMock
,它模拟了交易活动。
托管可扩展性框架 (MEF) 的使用允许将不同的客户端作为插件来处理。任何实现 IExchangeClient
并带有适当属性的类都将被识别并显示在可能的客户端列表中。
映射和匹配工作在 MEFLoader
类中完成。ControlViewModel
使用 MEFLoader
来填充交易所列表。选择交易所后,会创建一个相应的交易所客户端实例,并将其传递给与交易场所通信的部分(QuoteService
和 BrokerageService
)。
数据存储库
为了进行交易后分析,应用程序最初被设置为将对象存储在数据库中。它已扩展,增加了模拟存储库的选项,这些模拟存储库实际上不执行任何数据库操作。可以通过在配置文件中指定 mock
或 database
在 RepositoryConfigurationType
设置中配置任一方法的用法。
<appSettings>
<add key="KrakenBaseAddress" value="https://api.kraken.com" />
<add key="KrakenApiVersion" value="0" />
<add key="KrakenKey" value="xxx" />
<add key="KrakenSecret" value="xxx"/>
<add key="KrakenClientMaxRetries" value="5" />
<add key="PositionEpsilon" value="0.0000025"/>
<add key="RepositoryConfigurationType" value="mock"/>
</appSettings>
需要访问数据存储库的服务会间接通过 MEFLoader
获取它们,MEFLoader
会查看配置文件以确定选择哪个实现。
QuoteService
QuoteService
是一个实时组件,它从交易场所获取交易数据,将其打包成蜡烛图(开盘价、最高价、最低价、收盘价),计算 WMA,并将这些数据中继给 Mediator
。所说的交易场所是 IExchangeClient
的一个实例。它可以是真实的 KrakenClient
,也可以是模拟在线交易所行为的 KrakenClientMock
。
自然数法依赖于加权移动平均 (WMA),该平均值使用过去的数据计算。对于 N 周期的 WMA,需要知道最后的 N 个点。QuoteService
的 CatchUpWithConnector
方法会指示交易所客户端获取足够远的回溯交易数据来计算 WMA。这允许策略立即启动,而不必等待 N 个周期。然而,Kraken
不允许查询过早的交易数据。启动应用程序后,必须等待 N 个周期才能计算出第一个 WMA 点并开始交易。如果 N 很高且周期很长,这是一个问题。我编写了一个独立的交易应用程序的机器人,该机器人会存档 Kraken
的交易数据,还有一个 WebService
可以查询此存档。KrakenClient
从此 WebService
获取数据,而 KrakenClientMock
则从模拟交易的本地缓存中获取数据。
QuoteService
还为 StartQuoteBot
消息注册了一个方法,该消息由 ControlViewModel
在 CatchUpWithConnector
完成后触发。注册的方法在一个单独的线程中启动 QuoteService
的核心功能 (Run()
)。Run
方法实例化一个计时器和计时器到期时要执行的回调函数。当计时器到期时,回调函数将在单独的线程中执行。回调函数从交易场所加载最近的价格,计算加权移动平均,通知其他组件新数据已到达,并重置计时器。
最后,Stop
方法注册接收 StopQuoteBot
消息,停止计时器并将 StopStrategyService
消息发送给 Mediator
。
StrategyService
StrategyService
包含交易策略的核心算法。它监听来自 QuoteService
的传入数据,通过算法运行这些数据,并最终向 BrokerageService
发出采取行动的信号。通过监听来自 BrokerageService
的消息,它还可以跟踪与交易所的交易头寸。当收到停止信号时,它会指示 BrokerageService
关闭任何未平仓头寸。
避免消息确认循环
该交易方法一次只允许一个头寸,无论是多头还是空头。在达到此限制之前,交易算法会响应市场状况发出订单信号。因此,跟踪与交易所的实际头寸非常重要。但是,交易订单由 BrokerageService
异步执行,它与 StrategyService
完全独立。变量 OngoingContracts
用于跟踪头寸,并且只能由注册接收 Mediator 消息 UpdateOngoingContracts
的函数进行修改。每当订单与交易所成交时,BrokerageService
都会发送此消息。
[MediatorMessageSink(MediatorMessages.UpdateOngoingContracts, ParameterType = typeof(decimal))]
public void UpdateOngoingContracts(decimal ongoingContractsIncrement)
{
lock (OngoingContractsLock)
{
OngoingContracts += ongoingContractsIncrement;
//Sometimes an order is considered to be executed
//even when a very small fraction of it is not filled.
//We allow for a small error Epsilon (~ 0.001 EUR at the time of writing)
if (Math.Abs(OngoingContracts) < PositionEpsilon)
{
OngoingContracts = 0;
}
}
}
从 StrategyService
发送订单到收到订单执行确认(以 UpdateOngoingContracts
消息的形式)之间存在时间间隔。如果仅在收到执行确认后才调整头寸计数器,系统会在订单执行期间持续发出订单,可能导致下达过多订单。一种解决方案是保留两个计数器:一个用于已发送订单,一个用于已执行订单。在当前系统中使用的解决方案是通过保证一次只下达一个开仓订单来在 BrokerageService
中直接强制执行限制。下达开仓订单时,如果已有一个订单,则需要先取消第一个订单再发送新订单。
BrokerageService
BrokerageService
监控交易系统的头寸,并通过 IExchangeClient
下订单。它能够跟踪订单的状态直到被执行或取消,届时它会通知 Mediator
。它响应 OpenPosition
、ClosePosition
和 ShiftStopLoss
三种指令。
职位
在我们的系统中,一个头寸是开仓订单、平仓订单和止损订单的组合。两个开仓订单不能共存,并且在相应的开仓订单平仓之前不能下达平仓订单。这解决了常见的消息确认循环陷阱。止损订单是一个市价单,它镜像开仓订单,可以随时用于退出头寸。
在 BrokerageService
类中定义的 private Position
类,公开了 OpeningOrder
、ClosingOrder
和 EmergencyOrder
三个属性。在这些属性的 set 方法中,我们调用 UpdateOngoingContracts
方法。
Order openingOrder;
public Order OpeningOrder
{
get
{
return openingOrder;
}
set
{
openingOrder = value;
if(value!=null)
{
_brokerageService.Mediator.NotifyColleagues<Order>
(MediatorMessages.UpdateOrder, openingOrder);
}
UpdateOngoingContracts(openingOrder);
}
}
UpdateOngoingContracts
向中介者发送一条消息,表明未平仓合约数量已发生变化。
private void UpdateOngoingContracts(Order order)
{
if (order != null && order.VolumeExecuted.HasValue)
{
int sign = 0;
switch (order.Type)
{
case "buy":
//positive
sign = 1;
break;
case "sell":
//negative
sign = -1;
break;
}
decimal ongoingContractsIncrement = sign * order.VolumeExecuted.Value;
_brokerageService.Mediator.NotifyColleagues<decimal>
(MediatorMessages.UpdateOngoingContracts, ongoingContractsIncrement);
}
}
OpenPosition
BrokerageService
为 Mediator 消息 OpenPosition
注册 OpenPositionReceived
方法。此方法会启动一个新线程,并在打开新订单之前尝试取消当前开仓订单。
[MediatorMessageSink(MediatorMessages.OpenPosition, ParameterType = typeof(OpenPositionData))]
public void OpenPositionReceived(OpenPositionData openPositionData)
{
Task task = new Task(() =>
{
Log(LogEntryImportance.Info, "OpenPosition message received", true);
if (openPositionData != null)
{
//Cancel opening order
bool cancelOpeningOrderRes = CancelOpeningOrder();
if (!cancelOpeningOrderRes)
{
Log(LogEntryImportance.Error, "Unable to cancel current opening order.
Cannot open new Position", true);
}
else
{
Log(LogEntryImportance.Info, "Opening Position...", true);
OpenPosition(openPositionData);
}
}
else
{
Log(LogEntryImportance.Info, "OpenPositionData is null.
Cannot open new Position...", true);
}
});
task.Start();
}
OpenPosition()
使用 OrderFactory
创建一个包含 StrategyService
发送的数据的订单。然后,它将该订单设置为头寸的 OpeningOrder
。因此,如果在一个订单正在下达时收到另一个消息,BrokerageService
会知道已经有一个 OpeningOrder
在进行中。然后,它通过调用客户端的 PlaceOrder
方法来实际下达订单。无论实现(真实或模拟),PlaceOrder
在订单被关闭、取消或发生异常时都会返回。方法 PlaceOrder
返回后,Position
的 OpeningOrder
会被更新。如果开仓订单成功下达,则该方法继续下达平仓或止损订单。同样,这将继续异步运行,直到订单达到稳定状态。在下达开仓或平仓订单之前和之后,BrokerageService
的 Position
中的相应字段始终会更新。
private void OpenPosition(OpenPositionData openPositionData)
{
try
{
BrokerPosition = new Position(this);
BrokerPosition.Direction = openPositionData.Direction;
//Create opening order
Order openingOrder = _orderFactory.CreateOpeningOrder
(openPositionData.Direction, KrakenOrderType.stop_loss,
openPositionData.EnteringPrice, openPositionData.Volume,
openPositionData.CandleStickId, openPositionData.ConfirmationId,
validateOnly: openPositionData.ValidateOnly);
UpdateOpeningOrder(openingOrder);
//Place opening order and wait until closed or canceled
Log(LogEntryImportance.Info, "Placing opening order...", true);
PlaceOrderResult openingOrderResult = _client.PlaceOrder(openingOrder, true);
openingOrder = openingOrderResult.Order;
UpdateOpeningOrder(openingOrder);
bool ok = false;
... Handle opening-order result ...
if (!ok) return;
//if nothing went wrong, place exiting order
Order closingOrder = _orderFactory.CreateStopLossOrder
(openingOrder, openPositionData.ExitingPrice, openPositionData.ValidateOnly);
UpdateClosingOrder(closingOrder);
//Place closing order and wait until closed or canceled
Log(LogEntryImportance.Info, "Placing closing order...", true);
PlaceOrderResult closingOrderResult = _client.PlaceOrder(closingOrder, true);
closingOrder = closingOrderResult.Order;
UpdateClosingOrder(closingOrder);
... Handle closing-order result...
}
catch (Exception ex)
{
Log(LogEntryImportance.Error, string.Format("An exception occurred in OpenPosition at line {0}. {1} {2}", ex.LineNumber(), ex.Message, ((ex.InnerException != null) ? ex.InnerException.Message : "")), true);
}
}
ClosePosition
BrokerageService
为 Mediator
消息 ClosePosition
注册 ClosePositionReceived
方法。此方法异步运行,最终取消开仓或平仓订单并下达止损订单。
[MediatorMessageSink(MediatorMessages.ClosePosition, ParameterType = typeof(string))]
public void ClosePositionReceived(string message)
{
Task task = new Task(() =>
{
Log(LogEntryImportance.Info, "Closing Position...", true);
//Cancel opening order
bool cancelOpeningOrderRes = CancelOpeningOrder();
//cancel closing order
bool cancelClosingOrder = CancelClosingOrder();
//execute emergency exit order
if (BrokerPosition != null && BrokerPosition.EmergencyExitOrder != null)
{
Order emergencyExitOrder = BrokerPosition.EmergencyExitOrder;
PlaceOrderResult emergencyExitOrderResult = _client.PlaceOrder(emergencyExitOrder, true);
emergencyExitOrder = emergencyExitOrderResult.Order;
//update PositionView and PanelView
UpdateEmergencyOrder(emergencyExitOrder);
... Handle emergency order result ...
}
else
{
Log(LogEntryImportance.Info, "No emergency order to execute.", true);
}
Log(LogEntryImportance.Info, "Position closed.", true);
BrokerPosition = null;
});
task.Start();
}
ShiftPositionLimits
ShiftPositionLimits
方法会取消当前的平仓订单并下达一个新的订单,该订单会更高或更低。它由注册接收 Mediator
消息 ShiftPositionLimits
的方法异步调用。
private void ShiftPositionLimits(ShiftPositionLimitsData shiftPositionLimitsData)
{
Log(LogEntryImportance.Info, "In ShiftPositionLimits", true);
try
{
//Cancel current stoploss order
bool cancelClosingOrderRes = CancelClosingOrder();
if (cancelClosingOrderRes)
{
//create new stop loss order
Order newStopLossOrder = _orderFactory.CreateStopLossOrder
(BrokerPosition.OpeningOrder, shiftPositionLimitsData.NewLimitPrice,
shiftPositionLimitsData.ValidateOnly);
UpdateClosingOrder(newStopLossOrder);
//place order and wait
Log(LogEntryImportance.Info, "Placing new closing order...", true);
PlaceOrderResult placeOrderResult = _client.PlaceOrder(newStopLossOrder, true);
newStopLossOrder = placeOrderResult.Order;
UpdateClosingOrder(newStopLossOrder);
... Handle place-order result ...
}
else
{
Log(LogEntryImportance.Error,
"Unable to cancel current closing order. Cannot shift limits", true);
}
}
catch (Exception ex)
{
Log(LogEntryImportance.Error, string.Format
("An exception occurred in ShiftPositionLimits at line {0}. {1} {2}",
ex.LineNumber(), ex.Message,
((ex.InnerException != null) ? ex.InnerException.Message : "")), true);
}
}
UI 组件
主窗口由独立的视图组成,每个视图都有自己的 ViewModel
。给定一组视图,构成 MainWindow
仅仅是将它们放入 XAML 中的网格中。通过此设计,可以在不修改整体框架的情况下处理界面的一个部分。它还允许更高的可测试性和与 Blend 的集成。
ViewModels
包含 View
的底层功能,它们被封装在 ObservableObject
类中,将其链接到 Mediator
。用户操作通过发送特定消息来传输给交易服务,交易服务会注册接收这些消息的方法。反之,来自交易服务的消息可能会被转换为 View
的视觉更新。
MVVM
正如 MVVM 模式所预期的,View
的底层功能在 ViewModel
中处理,ViewModel
使用 WPF 数据绑定机制与之通信。View
与其 ViewModel
之间的对应关系在 app.xaml 文件中声明。
<img src="file:///C:\Users\ARRIV_~1\AppData\Local\Temp\msohtmlclip1\01\clip_image001.emz" />
<Application x:Class="TradingApp.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:TradingApp.ViewModel"
xmlns:v="clr-namespace:TradingApp.View">
<Application.Resources>
<DataTemplate DataType="{x:Type vm:ControlViewViewModel}">
<v:ControlView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:GraphViewViewModel}">
<v:GraphView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:LogViewViewModel}">
<v:LogView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:PanelViewViewModel}">
<v:PanelView />
</DataTemplate>
<DataTemplate DataType="{x:Type vm:PositionViewViewModel}">
<v:PositionView />
</DataTemplate>
</Application.Resources>
</Application>
给定一组 View
,构成 MainWindow
仅仅是将它们放入 XAML 中的网格中。下面的提取显示了如何将 ControlViewModel
和 PositionViewModel
的实例传递给 ContentPresenters
并直接放入 Grid
中,以形成 MainWindow
的左侧列。
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="55*"/>
<RowDefinition Height="45*"/>
</Grid.RowDefinitions>
<ContentPresenter Content="{Binding ControlVM}" Margin="3,0,3,0" />
<GridSplitter .../>
<ContentPresenter Content="{Binding PositionVM}" Margin="3,0,3,0" Grid.Row="1" />
</Grid>
ViewModels 作为 ObservableObjects
ViewModels
继承自 ViewModelBase
,而 ViewModelBase
又继承自 ObservableObject
,这使得它们能够向 Mediator
注册方法,并间接与应用程序的其他组件通信。
动态属性通过 WPF 绑定链接到 ViewModel
。
<TextBox Grid.Row="3" Grid.Column="1" Height="23" TextWrapping="Wrap"
Text="{Binding WmaPeriod}" IsReadOnly="{Binding Busy}" Width="100"/>
在底层 ViewModel
属性的 setter 函数中,会向 Mediator
发送一条消息,通知值已更改,并附带新值。
public int WmaPeriod
{
get
{
return wmaPeriod;
}
set
{
OldPeriod = wmaPeriod;
wmaPeriod = value;
Mediator.NotifyColleagues<int>(MediatorMessages.WmaPeriodChanged, wmaPeriod);
base.RaisePropertyChanged(() => this.WmaPeriod);
}
}
对该值更改感兴趣的服务可以为相应消息注册一个方法。
[MediatorMessageSink(MediatorMessages.WmaPeriodChanged, ParameterType = typeof(int))]
public void SetWmaPeriod(int wmaPeriod)
{
WmaPeriod = wmaPeriod;
}
这项工作很繁琐,因为需要为每个可编辑属性创建一个消息,并从使用该值的任何类中将方法注册到 Mediator
。这是此设计的一个缺点。
使用应用程序
数据存储库
该应用程序可以配置为使用不同的数据访问层存储库集。如前一节所述,有 模拟 存储库和 数据库 存储库。使用数据库存储库需要实际创建数据库(提供 SQL 脚本)并更新连接字符串。但是,在第一次尝试运行应用程序时,我建议使用 模拟 配置,该配置不需要任何先前的操作。
交易所客户端
如果您已有 Kraken
账户,您可以获取一对公钥和私钥,并将其指定在配置文件中。但是,在熟悉应用程序时,使用模拟客户端可能是个好主意。
<appSettings>
<add key="KrakenBaseAddress" value="https://api.kraken.com" />
<add key="KrakenApiVersion" value="0" />
<add key="KrakenKey" value="xxx" />
<add key="KrakenSecret" value="xxx"/>
<add key="KrakenClientMaxRetries" value="5" />
<add key="PositionEpsilon" value="0.0000025"/>
<add key="RepositoryConfigurationType" value="mock"/>
</appSettings>
模拟客户端以简化的方式模拟交易活动。新交易的到来 被建模为非齐次泊松过程。交易量 被建模为独立的随机变量,遵循对数正态分布,其均值和标准差根据星期几和一天中的小时数从历史数据中估算。价格过程 被建模为几何布朗运动,其中均值和标准差是任意设定的。事实上,几何布朗运动(通常用于模拟金融资产)非常不适合比特币的价格,因为比特币的价格非常不稳定且波动性很大。
尝试拟合其他模型会很有趣。我可以根据要求提供交易数据(Kraken 一年多的交易数据)。
变量
- 间隔:算法的频率。在每个时间间隔结束时,将从交易所获取价格数据,聚合为蜡烛图,并传递给策略服务进行进一步处理。
- WMA 周期:计算加权移动平均时包含的蜡烛图数量。例如,间隔 = 5 分钟,WMA 周期 = 180;WMA 将使用追溯到 180 * 5 = 900 分钟 = 15 小时的数据进行计算。每点之间间隔 5 分钟。QuoteBot 计时器设置为 5 分钟。
- NN 间隔:将应用于价格以查找下一个“自然数”的模数。例如,NN 间隔 = 10;在上升趋势中,Setup 确认在 392,下一个自然数是 400。我们将以 10 的增量移动限制。
- 头寸大小:每个新头寸的大小,以欧元表示。例如,如果头寸大小 = 20,并且我们想开立一个多头头寸,我们将下达价值 20 欧元比特币的订单。
- 启用订单:如果选中,交易策略将指示经纪服务向交易所(模拟或真实)下订单。如果未选中,则不会发生任何事情。
调试
该应用程序使用 log4net 和滚动文件附加程序来记录事件和错误。由于应用程序利用多线程来运行并发交易服务,因此跟踪事件序列可能会非常复杂。在 tradingapp.log 文件中,每个日志行前面都标有线程 ID,这对于调试非常有帮助。
结论
大多数系统化交易平台由独立组件组成,负责报价处理、运行时算法处理、经纪活动和运行时性能监控。在本文中,我们描述了一个轻量级单体 WPF 应用程序的设计,该应用程序处理了这些功能并允许运行自动化交易策略。较大的组织可能会使用面向服务的架构来物理分离这些组件中的每一个,并减少系统中出现故障点的数量。