
手动交易的风险管理
内容
概述
大家好!在本文中,我们将继续讨论风险管理方法。在之前的文章 同时交易多种金融工具时的风险平衡中,我们谈到了关于风险的基本概念。现在,我们将从头开始实现一个风险管理基类,以确保交易安全。同时,我们还将探讨在交易系统中如何限制影响交易策略有效性的风险。
风险管理类是我学习编程基础后不久,在2019年编写的第一个类。那时,我从自己的经验中了解到,交易者的心理状态极大地影响着交易的有效性,尤其是做出交易决策的“一致性”和“公正性”方面。赌博心态、情绪化交易以及为了尽快弥补损失而加大风险,都可能更快地损失掉账户里的所有资金,即使你使用的是在测试中表现出色的有效交易策略。
本文的目的是展示使用风险管理类进行风险控制用以提高交易的有效性和可靠性。为了证明这一点,我们将从头开始创建一个用于手动交易的简单基础风险管理基类,并使用一个非常简单的分形突破策略对其进行测试。
功能定义
当针对手动交易编写算法时,我们只针对时间维度(日、周、月)实行风险控制。一旦实际损失金额达到或超过用户设置的限制,EA 必须自动关闭所有未平仓的仓位,并通知用户无法继续交易。需要指出的是,这些信息将纯粹是“建议性质的”,它将随着正在运行的 EA 显示在图表左下角的评论行中。这是因为我们正在为手动交易专门创建一个风险管理类,所以,当“绝对必要”时,用户可以随时从图表中移除这个EA并继续交易。然而,我的确不建议这样做,因为如果当前市场对您不利,最好第二天返回交易并试图找出您的手动交易到底出现了什么问题,避免更大额的损失。如果您将此类集成到您的算法交易中,当发送的订单达到限额时,您需要实施该限制,并且最好将此类直接集成到 EA 结构中。之后我们将更详细地讨论这一点。
输入参数和类结构
我们决定只通过时间段和达成的日收益率标准来实施风险控制。为了实现这一点, 我们介绍了这种类型的几个变量 double ,并且使用了内存类修改量input ,以便用户可以手动输入每个时间段的风险阈值,即投资金额的百分比,以及锁定利润的目标日收益率百分比。为了表明对目标日收益的控制,我们引入了一个额外的类型为 bool 的变量,以便交易者能够根据需要启用/禁用此功能。如果交易者希望单独考虑每次入场,并且确信所选的工具之间不存在相关性,那么他们可以使用这个变量。这种类型的开关变量也被称为 "flag"。让我们声明以下全局代码。为了方便,我们曾经使用group关键字将其“封装”在一个已命名的模块中。
input group "RiskManagerBaseClass" input double inp_riskperday = 1; // risk per day as a percentage of deposit input double inp_riskperweek = 3; // risk per week input double inp_riskpermonth = 9; // risk per month input double inp_plandayprofit = 3; // target daily profit input bool dayProfitControl = true; // whether to close positions after reaching daily profit
在定义变量时,我们会根据以下逻辑为它们赋予默认值。我们从日风险开始,因为这个类别最适合日内交易,但也可以用于中期阶段的交易和投资。. 显然,如果你进行中期交易或作为投资者,那么控制每日风险对你来说就没有意义,你可以为日风险和周风险设置相同的值。此外,如果你只做长期投资,你可以将所有限制值设置为月度回撤值。接下来,我们将展示日内交易默认参数的逻辑。
我们决定余额的1%作为日风险交易的舒适水平,这意味着我们每天愿意承担的最大损失是账户余额的1%。We decided that we would be comfortable trading with a daily risk of 1% of the deposit. 一旦超过日风险限额,我们会关闭当日交易,直到第二天再开启。接下来,我们定义周风险限额如下。通常一周有5个交易日,这意味着如果我们连续3天亏损,我们就会停止交易直到下周初。这是因为很可能我们本周没有理解市场,或者市场发生了某种变化。如果我们继续交易,我们可能会在这段时间内累积非常大的损失,以至于即使关闭下周的交易也无法弥补。当进行日内交易时,设置月度限额的逻辑与此类似。我们接受这样的条件:如果一个月内我们有三周是不盈利的,那么第四周最好不要交易,因为这将需要很长时间并且通过牺牲未来时间段的收益用以“改善”收益曲线。此外,我们也不希望因为某个月份的巨大损失而“吓跑”投资者。
在设定目标日盈利范围时,我们会基于日风险,并考虑您交易系统的特点来设置。这里需要考虑的因素包括:首先,您是否使用相关性较高的金融工具交易,您的交易系统发出入场信号的频率如何,您是否在每笔交易中固定止损和止盈的比例,或者您的投资规模有多大。我要特别强调的是,我强烈不建议在没有设置止损和风险管理的情况下进行交易。在这种情况下,投资额亏损只是时间问题。因此,我们要么为每笔交易单独设置止损,要么使用风险管理工具来按时间段限制风险。在我们当前关于默认参数的例子中,我设定的日盈利条件是日风险的1到3倍。此外,强制设置风险盈利能力时,最好将这些参数与每笔交易通过止损和止盈比例(同样是1到3,即止盈大于止损)一起使用。
我们的限额结构描述如下:
Figure 1. 限制结构
接下来,我们使用class 关键字来定义我们自定义的数据类型. 输入参数需要存储在我们自定义的 RiskManagerBase类中. 由于我们的输入参数是以百分比来衡量的,而限额是以投资金额来追踪的,因此我们需要在我们的自定义类中添加几个对应类型的double 字段,并使用 protected访问修改量。
protected: double riskperday, // risk per day as a percentage of deposit riskperweek, // risk per week as a percentage of deposit riskpermonth, // risk per month as a percentage of deposit plandayprofit // target daily profit as a percentage of deposit ; double RiskPerDay, // risk per day in currency RiskPerWeek, // risk per week in currency RiskPerMonth, // risk per month in currency StartBalance, // account balance at the EA start time, in currency StartEquity, // account equity at the limit update time, in currency PlanDayEquity, // target account equity value per day, in currency PlanDayProfit // target daily profit, in currency ; double CurrentEquity, // current equity value CurrentBallance; // current balance
为了根据输入参数方便地按周期计算投资金额的风险限额,我们将在类中声明RefreshLimits()方法,并使用 protected访问修改量。现在,我们在类外部描述这个方法,如下所述。我们为之后预留了返回类型为 bool 的可能性,以便在需要时扩展我们的方法,使其能够检查所获得数据的正确性。当前,我们通过以下形式描述这个方法:
//+------------------------------------------------------------------+ //| RefreshLimits | //+------------------------------------------------------------------+ bool RiskManagerBase::RefreshLimits(void) { CurrentEquity = NormalizeDouble(AccountInfoDouble(ACCOUNT_EQUITY),2); // request current equity value CurrentBallance = NormalizeDouble(AccountInfoDouble(ACCOUNT_BALANCE),2); // request current balance StartBalance = NormalizeDouble(AccountInfoDouble(ACCOUNT_BALANCE),2); // set start balance StartEquity = NormalizeDouble(AccountInfoDouble(ACCOUNT_EQUITY),2); // request current equity value PlanDayProfit = NormalizeDouble(StartEquity * plandayprofit/100,2); // target daily profit, in currency PlanDayEquity = NormalizeDouble(StartEquity + PlanDayProfit/100,2); // target equity, in currency RiskPerDay = NormalizeDouble(StartEquity * riskperday/100,2); // risk per day in currency RiskPerWeek = NormalizeDouble(StartEquity * riskperweek/100,2); // risk per week in currency RiskPerMonth = NormalizeDouble(StartEquity * riskpermonth/100,2); // risk per month in currency return(true); }
一个方便的做法是,当我们每次改变时间周期需要重新计算限额值时,在代码里调用这个方法,正如初次更改字段值时调用这个方法一样。为了初始化字段的起始值,我们可以在类构造函数中编写以下代码:
//+------------------------------------------------------------------+ //| RiskManagerBase | //+------------------------------------------------------------------+ RiskManagerBase::RiskManagerBase() { riskperday = inp_riskperday; // set the value for the internal variable riskperweek = inp_riskperweek; // set the value for the internal variable riskpermonth = inp_riskpermonth; // set the value for the internal variable plandayprofit = inp_plandayprofit; // set the value for the internal variable RefreshLimits(); // update limits }
在确定了输入参数的逻辑和类的初始数据状态之后,我们接下来要设置投资账户限额。.
处理风险限制周期
为了处理风险限制周期,我们需要一个具有 protected 访问类型的附加变量。首先,让我们以bool类型变量的形式为每个周期定义自己的标识,这些标识将存储达到设定风险限额的数据,同时还有一个主要标识,该标识将仅在所有限额同时可用时,才通知用户可以继续交易。这一点是必要的,用来以避免出现以下情况:当月度限额已经超出时,但因为存在日限额所以仍然允许用户交易。这样一来,在下一个时间段之前达到任何时间限额都将被限制交易。This will limit trading when any time limit is reached before the next time period. 我们还需要相同类型的变量来控制日利润和开启新交易日。此外,我们将添加类型为double的字段,以存储每个交易周期(日、周和月)的实际盈亏信息。另外,我们还将为交易操作中的隔夜利息和佣金赋予单独的值。
bool RiskTradePermission; // general variable - whether opening of new trades is allowed bool RiskDayPermission; // flag prohibiting trading if daily limit is reached bool RiskWeekPermission; // flag to prohibit trading if daily limit is reached bool RiskMonthPermission; // flag to prohibit trading if monthly limit is reached bool DayProfitArrive; // variable to control if daily target profit is achieved bool NewTradeDay; // variable for a new trading day //--- actual limits double DayorderLoss; // accumulated daily loss double DayorderProfit; // accumulated daily profit double WeekorderLoss; // accumulated weekly loss double WeekorderProfit; // accumulated weekly profit double MonthorderLoss; // accumulated monthly loss double MonthorderProfit; // accumulated monthly profit double MonthOrderSwap; // monthly swap double MonthOrderCommis; // monthly commission
我们特意不将佣金和隔夜利息的费用计入相对应时间段的损失中,以便将来能够区分由决策工具产生的损失与不同经纪商对佣金和隔夜利息要求相关的损失。现在我们已经定义了类中的相应字段,接下来让我们继续控制限额的使用。
控制限制的使用
为了控制限额的实际使用,我们需要处理相关的事件,包括开启每个新周期直到完成交易操作。为了正确跟踪实际使用的限额,我们将在类的 protected区域中定义一个内部方法 ForOnTrade()。.
首先,我们需要在方法中定义变量来记录当前时间,以及当天、当周和当月的开始时间。为此,我们将使用struct结构体类型中MqlDateTime格式的一个特殊预定义的数据类型。我们将立即使用如下形式的当前终端时间来初始化这些变量。
MqlDateTime local, start_day, start_week, start_month; // create structure to filter dates TimeLocal(local); // fill in initially TimeLocal(start_day); // fill in initially TimeLocal(start_week); // fill in initially TimeLocal(start_month); // fill in initially
请注意,为了初始化当前时间,我们使用了预定义函数TimeLocal()而不是TimeCurrent()。这是因为TimeLocal()使用的是本地时间,而TimeCurrent()则获取的是从经纪商那里接收到的最后一个报价的时间戳。由于不同经纪商之间的时区差异,使用TimeCurrent()可能会导致限制条件的计算不准确。因此,我们选择使用TimeLocal()来确保时间计算的准确性。接下来,我们需要重置每个时间段的开始时间,以便获取它们的起始日期值。我们将通过访问结构体中的公共字段来完成这一操作,具体如下:
//--- reset to have the report from the beginning of the period start_day.sec = 0; // from the day beginning start_day.min = 0; // from the day beginning start_day.hour = 0; // from the day beginning start_week.sec = 0; // from the week beginning start_week.min = 0; // from the week beginning start_week.hour = 0; // from the week beginning start_month.sec = 0; // from the month beginning start_month.min = 0; // from the month beginning start_month.hour = 0; // from the month beginning
为了正确获取周和月的数据,我们需要定义查找周和月的开始逻辑。在自然月份情况下,一切都很简单,我们知道每个月都是从第一天开始的。处理周的情况则稍微复杂一些,因为没有特定的起始点,而且日期每次都会变化。在这里,我们可以使用 MqlDateTime 结构的特殊字段处理每周的日期范围。它允许您从当前日期开始,以0为起点,获取星期几的编号。知道了这个值,我们就可以很容易地按照如下方式找出当前周的开始日期。
//--- determining the beginning of the week int dif; // day of week difference variable if(start_week.day_of_week==0) // if this is the first day of the week { dif = 0; // then reset } else { dif = start_week.day_of_week-1; // if not the first, then calculate the difference start_week.day -= dif; // subtract the difference at the beginning of the week from the number of the day } //---month start_month.day = 1; // everything is simple with the month
既然我们已经有了相对于当前时刻的每个周期的精确开始日期,我们就可以继续获取账户上进行的交易的历史数据。首先,我们需要定义必要的变量来记录已完成的订单,并且重置用于收集每个选定周期交易金额结果的变量值。
//--- uint total = 0; // number of selected trades ulong ticket = 0; // order number long type; // order type double profit = 0, // order profit commis = 0, // order commission swap = 0; // order swap DayorderLoss = 0; // daily loss without commission DayorderProfit = 0; // daily profit WeekorderLoss = 0; // weekly loss without commission WeekorderProfit = 0; // weekly profit MonthorderLoss = 0; // monthly loss without commission MonthorderProfit = 0; // monthly profit MonthOrderCommis = 0; // monthly commission MonthOrderSwap = 0; // monthly swap
我们将通过预定义的终端函数HistorySelect()获取已关闭订单的历史数据。. 该函数的参数将使用我们之前为每个周期定义的日期。。因此,我们需要将我们的MqlDateTime变量类型转换为HistorySelect()函数参数所需的类型,即datetime。并且我们将使用预定义的终端函数StructToTime()。我们将以同样的方式请求交易数据,用所需周期的开始和结束日期作为必要值进行替换。
在每次调用HistorySelect()函数后,我们需要使用预定义的终端函数HistoryDealsTotal()获取选定订单的数量,并将此值全部存储在我们的局部变量中。获取已关闭交易的数量后,我们可以使用for循环操作符开启一个循环,通过预定义的终端函数HistoryDealGetTicket()定义每个订单的编号。这将使我们有权限访问每个订单的数据。我们将使用预定义的终端函数HistoryDealGetDouble()和HistoryDealGetInteger()访问每个订单的数据,并将之前获取的订单编号传递给它们。我们需要从ENUM_DEAL_PROPERTY_INTEGER和ENUM_DEAL_PROPERTY_DOUBLE枚举指定相应的交易属性标识符。我们还需要通过布尔选择操作符if添加一个过滤器,仅提取来自交易操作的数据,通过检查DEAL_TYPE_BUY和DEAL_TYPE_SELL值(来自ENUM_DEAL_TYPE枚举)来过滤掉其他账户操作,例如余额交易和奖金累加。因此,我们最终将获得以下用于选择数据的代码:
//--- now select data by --==DAY==-- HistorySelect(StructToTime(start_day),StructToTime(local)); // select required history //--- check total = HistoryDealsTotal(); // number number of selected deals ticket = 0; // order number profit = 0; // order profit commis = 0; // order commission swap = 0; // order swap //--- for all deals for(uint i=0; i<total; i++) // loop through all selected orders { //--- try to get deals ticket if((ticket=HistoryDealGetTicket(i))>0) // get the number of each in order { //--- get deals properties profit = HistoryDealGetDouble(ticket,DEAL_PROFIT); // get data on financial results commis = HistoryDealGetDouble(ticket,DEAL_COMMISSION); // get data on commission swap = HistoryDealGetDouble(ticket,DEAL_SWAP); // get swap data type = HistoryDealGetInteger(ticket,DEAL_TYPE); // get data on operation type if(type == DEAL_TYPE_BUY || type == DEAL_TYPE_SELL) // if the deal is form a trading operatoin { if(profit>0) // if financial result of current order is greater than 0, { DayorderProfit += profit; // add to profit } else { DayorderLoss += MathAbs(profit); // if loss, add up } } } } //--- now select data by --==WEEK==-- HistorySelect(StructToTime(start_week),StructToTime(local)); // select the required history //--- check total = HistoryDealsTotal(); // number number of selected deals ticket = 0; // order number profit = 0; // order profit commis = 0; // order commission swap = 0; // order swap //--- for all deals for(uint i=0; i<total; i++) // loop through all selected orders { //--- try to get deals ticket if((ticket=HistoryDealGetTicket(i))>0) // get the number of each in order { //--- get deals properties profit = HistoryDealGetDouble(ticket,DEAL_PROFIT); // get data on financial results commis = HistoryDealGetDouble(ticket,DEAL_COMMISSION); // get data on commission swap = HistoryDealGetDouble(ticket,DEAL_SWAP); // get swap data type = HistoryDealGetInteger(ticket,DEAL_TYPE); // get data on operation type if(type == DEAL_TYPE_BUY || type == DEAL_TYPE_SELL) // if the deal is form a trading operatoin { if(profit>0) // if financial result of current order is greater than 0, { WeekorderProfit += profit; // add to profit } else { WeekorderLoss += MathAbs(profit); // if loss, add up } } } } //--- now select data by --==MONTH==-- HistorySelect(StructToTime(start_month),StructToTime(local)); // select the required history //--- check total = HistoryDealsTotal(); // number number of selected deals ticket = 0; // order number profit = 0; // order profit commis = 0; // order commission swap = 0; // order swap //--- for all deals for(uint i=0; i<total; i++) // loop through all selected orders { //--- try to get deals ticket if((ticket=HistoryDealGetTicket(i))>0) // get the number of each in order { //--- get deals properties profit = HistoryDealGetDouble(ticket,DEAL_PROFIT); // get data on financial results commis = HistoryDealGetDouble(ticket,DEAL_COMMISSION); // get data on commission swap = HistoryDealGetDouble(ticket,DEAL_SWAP); // get swap data type = HistoryDealGetInteger(ticket,DEAL_TYPE); // get data on operation type MonthOrderSwap += swap; // sum up swaps MonthOrderCommis += commis; // sum up commissions if(type == DEAL_TYPE_BUY || type == DEAL_TYPE_SELL) // if the deal is form a trading operatoin { if(profit>0) // if financial result of current order is greater than 0, { MonthorderProfit += profit; // add to profit } else { MonthorderLoss += MathAbs(profit); // if loss, sum up } } } }
每当我们需要更新当前限额可用值时,都可以调用上述方法。在生成各种终端事件时,我们既可以更新实际限额的值,也可以调用此函数。由于此方法的目的是更新限额,因此可以在与当前订单变更相关的事件发生时执行此操作,例如 Trade 和TradeTransaction事件,以及每当带有NewTick的新报价出现时的事件。由于我们的方法非常节省资源,因此我们将在每个报价出现时更新实际限额。现在,让我们来实现处理与动态取消和交易结算相关事件所需的事件处理程序。
类事件处理程序
为了处理事件,我们在ContoEvents()类中定义了一个具有protected(限制)访问级别的内部方法。因此,我们定义了具有相同访问级别的其他辅助字段。为了能够立即跟踪新交易周期的开始时间(这是更改交易权限标识所必需的),我们需要存储上一个记录周期和当前周期的值。为达到此目的,我们可以使用定义为datetime数据类型的简单数组来存储相应周期的值。
//--- additional auxiliary arrays datetime Periods_old[3]; // 0-day,1-week,2-mn datetime Periods_new[3]; // 0-day,1-week,2-mn
在第一维度中,我们将存储日的值,在第二维度中存储周的值,在第三维度中存储月的值。. 如果需要进一步扩展控制周期,可以动态定义这些数组。但在这里,我们只处理三个时间段。现在,让我们在类构造函数中添加这些数组变量,其初始化过程如下:
Periods_new[0] = iTime(_Symbol, PERIOD_D1, 1); // initialize the current day with the previous period Periods_new[1] = iTime(_Symbol, PERIOD_W1, 1); // initialize the current week with the previous period Periods_new[2] = iTime(_Symbol, PERIOD_MN1, 1); // initialize the current month with the previous period
我们将使用预定义的终端函数iTime()来初始化每个对应的周期,通过传入参数,即来自当前周期前一个周期的ENUM_TIMEFRAMES中的对应周期。我们特意不初始化 Periods_old[] 数组。在这种情况下,在调用构造函数和我们的ContoEvents()方法后,我们确保触发了新交易周期开始的事件,并且所有开始交易的标识都被打开,之后如果没有剩余额度,再由代码关闭这些标识。否则,当类被重新初始化时,可能导致无法正常工作。上述方法包含简单的逻辑:如果当前周期与前一个周期不相等,则意味着一个新的对应周期已经开始,您可以通过更改标识中的值来重置限额并允许交易。此外,对于每个周期,我们都将调用之前描述的RefreshLimits()方法来重新计算输入限额。
//+------------------------------------------------------------------+ //| ContoEvents | //+------------------------------------------------------------------+ void RiskManagerBase::ContoEvents() { // check the start of a new trading day NewTradeDay = false; // variable for new trading day set to false Periods_old[0] = Periods_new[0]; // copy to old, new Periods_new[0] = iTime(_Symbol, PERIOD_D1, 0); // update new for day if(Periods_new[0]!=Periods_old[0]) // if do not match, it's a new day { Print(__FUNCTION__+" line"+IntegerToString(__LINE__)+", New trade day!"); // inform NewTradeDay = true; // variable to true DayProfitArrive = false; // reset flag of reaching target profit after a new day started RiskDayPermission = true; // allow opening new positions RefreshLimits(); // update limits DayorderLoss = 0; // reset daily financial result DayorderProfit = 0; // reset daily financial result } // check the start of a new trading week Periods_old[1] = Periods_new[1]; // copy data to old period Periods_new[1] = iTime(_Symbol, PERIOD_W1, 0); // fill new period for week if(Periods_new[1]!= Periods_old[1]) // if periods do not match, it's a new week { Print(__FUNCTION__+" line"+IntegerToString(__LINE__)+", New trade week!"); // inform RiskWeekPermission = true; // allow opening new positions RefreshLimits(); // update limits WeekorderLoss = 0; // reset weekly losses WeekorderProfit = 0; // reset weekly profits } // check the start of a new trading month Periods_old[2] = Periods_new[2]; // copy the period to the old one Periods_new[2] = iTime(_Symbol, PERIOD_MN1, 0); // update new period for month if(Periods_new[2]!= Periods_old[2]) // if do not match, it's a new month { Print(__FUNCTION__+" line"+IntegerToString(__LINE__)+", New trade Month!"); // inform RiskMonthPermission = true; // allow opening new positions RefreshLimits(); // update limits MonthorderLoss = 0; // reset the month's loss MonthorderProfit = 0; // reset the month's profit } // set the permission to open new positions true only if everything is true // set to true if(RiskDayPermission == true && // if there is a daily limit available RiskWeekPermission == true && // if there is a weekly limit available RiskMonthPermission == true // if there is a monthly limit available ) // { RiskTradePermission=true; // if all are allowed, trading is allowed } // set to false if at least one of them is false if(RiskDayPermission == false || // no daily limit available RiskWeekPermission == false || // or no weekly limit available RiskMonthPermission == false || // or no monthly limit available DayProfitArrive == true // or target profit is reached ) // then { RiskTradePermission=false; // prohibit trading } }
在该方法中,我们增加主变量 RiskTradePermission(一个标识位)来控制数据状态的功能,该标识位用于确定是否可以开设新的仓位。通过系统逻辑选择运算符,我们实现了仅当所有权限都为真时,才通过该变量启用交易权限;如果任何一个标识位不允许交易,则禁用交易权限。如果您将这个类集成到一个已经创建的 EA中,这个变量将非常有用;您可以通过 getter 方法简单地获取它,并将其插入到代码中,作为交易的条件之一。在我们的案例中,它仅作为一个标识位,用于通知用户已经没有可用的交易限额了。既然我们的类已经“学会”了如何在达到指定的损失时控制风险,接下来我们将实现控制达到目标利润的功能。
日目标利润控制机制
在我们之前的 articles中,已经定义了一个用于启动目标利润控制的标识,以及一个输入变量,用于确定其值相对于账户资金的额度。根据我们控制目标利润实现的类的逻辑,如果所有头寸的总利润已经达到目标值,则所有开仓都将被平仓。为了平掉账户上的所有头寸,我们将在我们的类中定义一个具有 public访问级别的内部方法AllOrdersClose()。 为了使这个方法能生效,我们需要接收关于开仓的数据,并自动发送平仓指令。
为了避免浪费时间自己编写代码实现这些功能,我们将使用终端现有的内部类。I我们将使用内部标准终端类CPositionInfo来处理开仓,并使用CTrade类来平仓。让我们也使用 protected(限制)访问级别定义这两个类的变量,并且不使用带有默认构造函数的指针,如下所示。
CTrade r_trade; // instance CPositionInfo r_position; // instance
在处理这些对象时,就我们当前所需的功能框架而言,我们无需对它们进行附加配置,因此不会在类的构造函数中编写相关代码。以下是使用已定义的类实现该方法的步骤::
//+------------------------------------------------------------------+ //| AllOrdersClose | //+------------------------------------------------------------------+ bool RiskManagerBase::AllOrdersClose() // closing market positions { ulong ticket = 0; // order ticket string symb; for(int i = PositionsTotal(); i>=0; i--) // loop through open positoins { if(r_position.SelectByIndex(i)) // if a position selected { ticket = r_position.Ticket(); // remember position ticket if(!r_trade.PositionClose(ticket)) // close by ticket { Print(__FUNCTION__+". Error close order. "+IntegerToString(ticket)); // if not, inform return(false); // return false } else { Print(__FUNCTION__+". Order close success. "+IntegerToString(ticket)); // if not, inform continue; // if everything is ok, continue } } } return(true); // return true }
我们将在达到目标利润或满足限制条件时都调用上述描述的方法。该方法还会返回一个bool值,以便在发送平仓指令时处理可能出现的错误。为了提供控制是否达到目标利润的功能,我们将在已描述的事件处理方法ContoEvents()紧接在之前的代码之后,补充以下代码:
//--- daily if(dayProfitControl) // check if functionality is enabled by the user { if(CurrentEquity >= (StartEquity+PlanDayProfit)) // if equity exceeds or equals start + target profit, { DayProfitArrive = true; // set flag that target profit is reached Print(__FUNCTION__+", PlanDayProfit has been arrived."); // inform about the event Print(__FUNCTION__+", CurrentEquity = "+DoubleToString(CurrentEquity)+ ", StartEquity = "+DoubleToString(StartEquity)+ ", PlanDayProfit = "+DoubleToString(PlanDayProfit)); AllOrdersClose(); // close all open orders StartEquity = CurrentEquity; // rewrite starting equity value //--- send a push notification ResetLastError(); // reset the last error if(!SendNotification("The planned profitability for the day has been achieved. Equity: "+DoubleToString(CurrentEquity)))// notification { Print(__FUNCTION__+IntegerToString(__LINE__)+", Error of sending notification: "+IntegerToString(GetLastError()));// if not, print } } }
该方法包括向用户发送推送通知,告知用户该事件已经发生。为此,我们使用预定义的终端函数 SendNotification。我们为了实现类的最基本功能,我们只需要再组合一个具有public(公共)访问权限的类方法,该方法将在风险管理器连接到我们EA时被调用。
在EA中定义一个启用监控的方法
为了将风险管理类一个实例的监控功能添加到EA中,我们将定义一个公共方法ContoMonitor()。. 在这个方法中,我们将收集所有之前定义的事件处理方法,并且还会补充一个功能,用于比较实际使用中的限制与用户输入的参数值。让我们以public(公共)访问级别声明这个方法,并在类外部描述如下:
//+------------------------------------------------------------------+ //| ContoMonitor | //+------------------------------------------------------------------+ void RiskManagerBase::ContoMonitor() // monitoring { ForOnTrade(); // update at each tick ContoEvents(); // event block //--- double currentProfit = AccountInfoDouble(ACCOUNT_PROFIT); if((MathAbs(DayorderLoss)+MathAbs(currentProfit) >= RiskPerDay && // if equity is less than or equal to the start balance minus the daily risk currentProfit<0 && // profit below zero RiskDayPermission==true) // day trading is allowed || // OR (RiskDayPermission==true && // day trading is allowed MathAbs(DayorderLoss) >= RiskPerDay) // loss exceed daily risk ) { Print(__FUNCTION__+", EquityControl, "+"ACCOUNT_PROFIT = " +DoubleToString(currentProfit));// notify Print(__FUNCTION__+", EquityControl, "+"RiskPerDay = " +DoubleToString(RiskPerDay)); // notify Print(__FUNCTION__+", EquityControl, "+"DayorderLoss = " +DoubleToString(DayorderLoss)); // notify RiskDayPermission=false; // prohibit opening new orders during the day AllOrdersClose(); // close all open positions } // check if there is a WEEK limit available for opening a new position if there are no open ones if( MathAbs(WeekorderLoss)>=RiskPerWeek && // if weekly loss is greater than or equal to the weekly risk RiskWeekPermission==true) // and we traded { RiskWeekPermission=false; // prohibit opening of new orders during the day AllOrdersClose(); // close all open positions Print(__FUNCTION__+", EquityControl, "+"WeekorderLoss = "+DoubleToString(WeekorderLoss)); // notify Print(__FUNCTION__+", EquityControl, "+"RiskPerWeek = "+DoubleToString(RiskPerWeek)); // notify } // check if there is a MONTH limit available for opening a new position if there are no open ones if( MathAbs(MonthorderLoss)>=RiskPerMonth && // if monthly loss is greater than or equal to the monthly risk RiskMonthPermission==true) // we traded { RiskMonthPermission=false; // prohibit opening of new orders during the day AllOrdersClose(); // close all open positions Print(__FUNCTION__+", EquityControl, "+"MonthorderLoss = "+DoubleToString(MonthorderLoss)); // notify Print(__FUNCTION__+", EquityControl, "+"RiskPerMonth = "+DoubleToString(RiskPerMonth)); // notify } }
我们的方法的操作逻辑非常简单:如果某个月或某周的实际损失限额超过了用户设定的限额,那么给定时间段的交易标志将被设置为禁止,相应地,交易也会被禁止。唯一不同的是在日限额方面,我们还需要控制是否存在未平仓头寸;为此,我们还将通过逻辑运算符OR增加对当前未平仓头寸利润的控制。当达到风险限额时,我们会调用我们的平仓方法,并打印出关于这一事件的日志。
在此阶段,为了完善该功能,我们只需要添加一个方法,以便用户能够控制当前限制。最简单且最便捷的方式是通过标准预定义的终端函数Comment()来显示必要的信息。 Comment(). 为了使用这个函数,我们需要向它传递一个包含要在图表上显示的信息的 string (字符串)类型参数。. 为了从我们的类中获取这些值,我们声明了一个具有public(公共)访问级别的Message()方法,该方法将返回包含用户所需所有变量的string (字符串)数据。
//+------------------------------------------------------------------+ //| Message | //+------------------------------------------------------------------+ string RiskManagerBase::Message(void) { string msg; // message msg += "\n"+" ----------Risk-Manager---------- "; // common //--- msg += "\n"+"RiskTradePer = "+(string)RiskTradePermission; // final trade permission msg += "\n"+"RiskDayPer = "+(string)RiskDayPermission; // daily risk available msg += "\n"+"RiskWeekPer = "+(string)RiskWeekPermission; // weekly risk available msg += "\n"+"RiskMonthPer = "+(string)RiskMonthPermission; // monthly risk available //---limits and inputs msg += "\n"+" -------------------------------- "; // msg += "\n"+"RiskPerDay = "+DoubleToString(RiskPerDay,2); // daily risk in usd msg += "\n"+"RiskPerWeek = "+DoubleToString(RiskPerWeek,2); // weekly risk in usd msg += "\n"+"RiskPerMonth = "+DoubleToString(RiskPerMonth,2); // monthly risk usd //--- current profits and losses for periods msg += "\n"+" -------------------------------- "; // msg += "\n"+"DayLoss = "+DoubleToString(DayorderLoss,2); // daily loss msg += "\n"+"DayProfit = "+DoubleToString(DayorderProfit,2); // daily profit msg += "\n"+"WeekLoss = "+DoubleToString(WeekorderLoss,2); // weekly loss msg += "\n"+"WeekProfit = "+DoubleToString(WeekorderProfit,2); // weekly profit msg += "\n"+"MonthLoss = "+DoubleToString(MonthorderLoss,2); // monthly loss msg += "\n"+"MonthProfit = "+DoubleToString(MonthorderProfit,2); // monthly profit msg += "\n"+"MonthCommis = "+DoubleToString(MonthOrderCommis,2); // monthly commissions msg += "\n"+"MonthSwap = "+DoubleToString(MonthOrderSwap,2); // monthly swaps //--- for current monitoring if(dayProfitControl) // if control daily profit { msg += "\n"+" ---------dayProfitControl-------- "; // msg += "\n"+"DayProfitArrive = "+(string)DayProfitArrive; // daily profit achieved msg += "\n"+"StartBallance = "+DoubleToString(StartBalance,2); // starting balance msg += "\n"+"PlanDayProfit = "+DoubleToString(PlanDayProfit,2); // target profit msg += "\n"+"PlanDayEquity = "+DoubleToString(PlanDayEquity,2); // target equity } return(msg); // return value }
该方法为用户创建的消息如下:
Figure 2. 数据输出格式
这个方法可以通过添加用于在终端处理图形的元素来进行修改或补充。但我们会这样使用它,因为它向用户提供了来自类的足够数据以便用户做出决策。如果愿意,您可以在将来完善这个格式,使其图形界面更加美观。现在,让我们讨论在使用个别交易策略时扩展这个类的可能性。
最终实现和扩展类的可能性
正如我们之前所述,这里描述的功能是所有交易策略中几乎最为基础且普遍适用的。它帮助我们控制风险,并防止一天内损失全部资金。在本文的这一部分,我们将探讨扩展这个类的更多可能性。
- 在短线止损交易时控制点差大小
- 控制开仓滑点
- 控制目标月收益
针对第一点,我们的交易系统通过短线止损交易实现附加的功能。您可以定义一个SpreadMonitor(int intSL)方法,设置一个参数,即某项交易理论上或计算出的止损值(以点为单位),以便将其与当前的点差水平进行比较。如果点差相对于用户确定的止损值比例剧增,该方法将禁止下单,从而避免因点差过大而导致在止损位被迫平仓的高风险。
为了控制开仓时的滑点,根据第二点,您可以声明一个SlippageCheck()方法。如果经纪商的开仓价格与指定价格相差甚远,导致交易风险超过了预期值,该方法将关闭每一笔单独的交易。这样一来,在止损被触发时,可以避免因单次高风险交易而破坏整体统计数据。此外,当使用固定的止损与止盈比例进行交易时,滑点会恶化这一比例,因此最好以小损失平仓,而不是日后承受更大的损失。
与控制日利润的逻辑类似,我们可以构建一个相应的方法来控制目标月利润。这个方法可以在交易长期策略时使用。我们之前描述的类已经具备了在手动日内交易中使用所需的所有功能,并且它可以被整合到交易EA的最终实现中,这个EA应该与手动交易同时加载在交易对象的图表上。
在项目的最终组装过程中,使用#include预处理指令来连接类是一个关键步骤。
#include <RiskManagerBase.mqh>
接下来,我们在全局级别声明风险管理器对象的指针。
RiskManagerBase *RMB;
当初始化EA时,在加载前需要手动为对象分配内存。
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { RMB = new RiskManagerBase(); //--- return(INIT_SUCCEEDED); }
当我们从图表中移除EA时,我们需要从对象中清除内存以避免内存泄漏。为此,请在EA的OnDeinit函数中编写以下内容。
//+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { //--- delete RMB; }
此外,如果有必要,在相同的事件中,您可以调用Comment(" ")方法,并向其发送一个空字符串,以便在从交易图表中移除EA时清除图表上的注释。
当接收到该交易品类的新行情数据时,我们会调用类中的主要监控方法。
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { RMB.ContoMonitor(); Comment(RMB.Message()); } //+------------------------------------------------------------------+
这样我们就完成了带有内置风险管理器的EA的组装,并且它现在已完全准备好投入使用(文件名为ManualRiskManager.mq5)。为了测试其使用的几种情况,我们将在当前代码中做一个小小的补充,以模拟手动交易的过程。
用法示例
为了可视化使用风险管理器和不使用风险管理器时的手动交易过程,我们需要附加的代码来模拟手动交易。由于本文不会涉及选择交易策略的主题,因此我们不会在代码中实现完整的交易功能。相反,我们将从日线图上直观地获取入场点,并将现成的数据添加到我们的EA中。我们将使用一种非常简单的策略来做出交易决策,并观察这一策略的最终盈亏结果,唯一的区别在于:是否使用了风险控制。
作为入场点的例子,我们将使用一个简单的基于分形水平突破的策略,针对USDJPY货币对,在两个月的时间段内交易。让我们来看看这个策略在有和没有风险控制的情况下的表现如何。从示意图的角度来看,手动入场的策略信号如下:.
Figure 3. 使用测试策略的入场点
为了模拟这个策略,让我们编写一个通用的单元测试框架,用于测试手动策略,这样每个用户都可以稍作修改后测试他们的入场点。. 在这个测试中,策略将执行预加载的现成信号,而不需要实现自己的入场逻辑。为此,我们首先需要声明一个附加的结构体struct,用于存储我们基于分形的入场点。
//+------------------------------------------------------------------+ //| TradeInputs | //+------------------------------------------------------------------+ struct TradeInputs { string symbol; // symbol ENUM_POSITION_TYPE direction; // direction double price; // price datetime tradedate; // date bool done; // trigger flag };
负责构建交易信号模型的主要类是TradeModel。该类的构造函数将接受一个包含信号输入参数的容器,并且它的主要Processing()方法将基于输入值监控每一个tick(交易时间单位),以判断入场点的时间是否已到。由于我们正在模拟日内交易,因此在一天结束时,我们将使用风险管理类中先前声明的AllOrdersClose()方法来平掉所有仓位。以下是我们的辅助类。
//+------------------------------------------------------------------+ //| TradeModel | //+------------------------------------------------------------------+ class TradeModel { protected: CTrade *cTrade; // to trade TradeInputs container[]; // container of entries int size; // container size public: TradeModel(const TradeInputs &inputs[]); ~TradeModel(void); void Processing(); // main modeling method };
为了方便下单,我们将使用标准的终端类CTrade,它包含了我们所需要的所有功能。这将节省我们开发辅助类的时间。在创建类实例时,为了传递输入参数,我们定义了一个带有一个输入参数的构造函数,该参数为容器。
//+------------------------------------------------------------------+ //| TradeModel | //+------------------------------------------------------------------+ TradeModel::TradeModel(const TradeInputs &inputs[]) { size = ArraySize(inputs); // get container size ArrayResize(container, size); // resize for(int i=0; i<size; i++) // loop through inputs { container[i] = inputs[i]; // copy to internal } //--- trade class cTrade=new CTrade(); // create trade instance if(CheckPointer(cTrade)==POINTER_INVALID) // if instance not created, { Print(__FUNCTION__+IntegerToString(__LINE__)+" Error creating object!"); // notify } cTrade.SetTypeFillingBySymbol(Symbol()); // fill type for the symbol cTrade.SetDeviationInPoints(1000); // deviation cTrade.SetExpertMagicNumber(123); // magic number cTrade.SetAsyncMode(false); // asynchronous method }
在构造函数中,我们使用所需的值初始化输入参数的容器,记住其大小,并使用必要的设置创建CTrade类的对象。这里的大多数参数无需用户配置,因为它们不会影响我们创建单元测试的目的,所以我们将它们硬编码。
我们的TradeModel类的析构函数将只需要删除一个CTrade对象。
//+------------------------------------------------------------------+ //| ~TradeModel | //+------------------------------------------------------------------+ TradeModel::~TradeModel(void) { if(CheckPointer(cTrade)!=POINTER_INVALID) // if there is an instance, { delete cTrade; // delete } }
现在,我们将在整个项目中实现类的主要处理方法。让我们根据图3实现下单的逻辑:
//+------------------------------------------------------------------+ //| Processing | //+------------------------------------------------------------------+ void TradeModel::Processing(void) { datetime timeCurr = TimeCurrent(); // request current time double bid = SymbolInfoDouble(Symbol(),SYMBOL_BID); // take bid double ask = SymbolInfoDouble(Symbol(),SYMBOL_ASK); // take ask for(int i=0; i<size; i++) // loop through inputs { if(container[i].done==false && // if we haven't traded yet AND container[i].tradedate <= timeCurr) // date is correct { switch(container[i].direction) // check trade direction { //--- case POSITION_TYPE_BUY: // if Buy, if(container[i].price >= ask) // check if price has reached and { if(cTrade.Buy(0.1)) // by the same lot { container[i].done = true; // if time has passed, put a flag Print("Buy has been done"); // notify } else // if hasn't passed, { Print("Error: buy"); // notify } } break; // complete the case //--- case POSITION_TYPE_SELL: // if Sell if(container[i].price <= bid) // check if price has reached and { if(cTrade.Sell(0.1)) // sell the same lot { container[i].done = true; // if time has passed, put a flag Print("Sell has been done"); // notify } else // if hasn't passed, { Print("Error: sell"); // notify } } break; // complete the case //--- default: Print("Wrong inputs"); // notify return; break; } } } }
这个方法的逻辑相当简单。如果容器中有未处理的记录,并且这些记录的建模时间已到,我们就根据图3中标出的分形方向和价格来下单。这个功能足以用于测试风险管理器,因此我们可以将其集成到我们的主项目中。
首先,让我们将测试类连接到EA代码,如下所示:
#include <TradeModel.mqh>
现在,在OnInit()函数中,我们创建了TradeInputs数据数组结构的一个实例,并将这个数组传递给TradeModel类的构造函数以进行初始化。
//--- TradeInputs modelInputs[] = { {"USDJPYz", POSITION_TYPE_SELL, 146.636, D'2024-01-31',false}, {"USDJPYz", POSITION_TYPE_BUY, 148.794, D'2024-02-05',false}, {"USDJPYz", POSITION_TYPE_BUY, 148.882, D'2024-02-08',false}, {"USDJPYz", POSITION_TYPE_SELL, 149.672, D'2024-02-08',false} }; //--- tModel = new TradeModel(modelInputs);
不要忘记在DeInit()函数中清除tModel对象的内存。主要功能将在OnTick()函数中执行,并补充以下代码:
tModel.Processing(); // place orders MqlDateTime time_curr; // current time structure TimeCurrent(time_curr); // request current time if(time_curr.hour >= 23) // if end of day { RMB.AllOrdersClose(); // close all positions }
现在,让我们比较一下在有无风险控制类的情况下,应用同一策略的结果。我们先运行不包含风险控制方法的单元测试文件ManualRiskManager(UniTest1)。针对2024年1月至3月期间,我们得到了以下策略结果。
Figure 4. 不使用风险管理器的测试数据
因此,我们获得了该策略在以下参数下的正数学期望。
# | 参数名称 | 参数值 |
---|---|---|
1 | EA | ManualRiskManager(UniTest1) |
2 | 品类 | USDJPY |
3 | Chart Timeframes | М15 |
4 | 时间范围 | 2024.01.01 - 2024.03.18 |
5 | 前向验证测试 | NO |
6 | Delays | 无延迟的理想情况 |
7 | 模拟法 | Every Tick |
8 | 初始投资 | USD 10,000 |
9 | 杠杆比率 | 1:100 |
Table 1. 策略测试器的输入参数
现在让我们运行单元测试文件ManualRiskManager(UniTest2),其中我们使用具有以下输入参数的风险管理类。
输入参数名称 | 可变值 |
---|---|
inp_riskperday | 0.25 |
inp_riskperweek | 0.75 |
inp_riskpermonth | 2.25 |
inp_plandayprofit | 0.78 |
dayProfitControl | true |
Table 2. 风险管理器的输入参数
生成输入参数的逻辑与在Part 3中设计输入参数结构时描述的逻辑相似。利润曲线如下图所示:
Figure 5. 使用风险管理器的测试数据
以下是两个案例测试结果的统计表:
# | 值 | 无风险管理器 | 有风险管理器 | 变化 |
---|---|---|---|---|
1 | 净盈利总计: | 41.1 | 144.48 | +103.38 |
2 | 最大余额回撤: | 0.74% | 0.25% | Reduced by 3 times |
3 | 最大净值回撤: | 1.13% | 0.58% | Reduced by 2 times |
4 | 期望收益: | 10.28 | 36.12 | More than 3 times growth |
5 | 夏普比率: | 0.12 | 0.67 | 5 times growth |
6 | 交易盈利交易 (百分比): | 75% | 75% | - |
7 | 交易平均盈利: | 38.52 | 56.65 | Growth by 50% |
8 | 交易平均亏损: | -74.47 | -25.46 | Reduced by 3 times |
9 | 平均风险回报 | 0.52 | 2.23 | 4 times growth |
Table 3. 有无风险管理时交易数据结果比较
基于我们单元测试的结果,我们可以得出以下结论:通过使用风险管理器类进行风险控制,即使采用相同的简单策略,交易的效率也能显著提高。这是因为它限制了每笔交易的风险,并相对于固定风险固定了利润。这一做法使得账户余额的回撤减少了3倍,账户净值减少了2倍。使用策略使期望收益增加了3倍以上,夏普比率(Sharpe Ratio)也提高了5倍以上。交易盈利的平均水平提高了50%,而交易亏损的平均水平则降低了3倍,这样使得账户的平均风险回报率接近1到3的目标值。下表详细地比较了我们交易池中每笔交易的盈亏结果:
日期 | 品类 | 方向 | Lot | 无风险管理器 | 有风险管理器 | 变化 |
---|---|---|---|---|---|---|
2024.01.31 | USDJPY | buy | 0.1 | 25.75 | 78 | + 52.25 |
2024.02.05 | USDJPY | sell | 0.1 | 13.19 | 13.19 | - |
2024.02.08 | USDJPY | sell | 0.1 | 76.63 | 78.75 | + 2.12 |
2024.02.08 | USDJPY | buy | 0.1 | -74.47 | -25.46 | + 49.01 |
总计 | - | - | - | 41.10 | 144.48 | + 103.38 |
Table 4. 有无风险管理器时的已执行交易对比
结论
基于文章中提出的论点,我们可以得出以下结论:即使在手动交易中,使用风险管理器也能显著提升策略的有效性,包括那些本身已经盈利的策略。对于亏损的策略,使用风险管理器有助于保护资金安全,限制损失。正如引言部分所述,我们试图减轻投资者心理因素对于交易的影响。即使在试图立即挽回损失时,也不应该关闭风险管理器。更好的做法是等待限制期结束,然后在没有情绪波动的情况下重新开始交易。利用风险管理器禁止交易的这段时间来分析你的交易策略,了解导致亏损的原因,并思考如何在未来避免这些损失。
感谢每一位坚持阅读完这篇文章的朋友。真心希望这篇文章能帮助减少投资损失,至少不让资金完全亏空。如果能做到这点,那我们的努力就没有白费。我很乐意看到你们的评论或私信,特别是关于我是否应该开始撰写一篇新文章,探讨如何将这个类适应到纯算法的EA中。期待你们的反馈。谢谢大家!
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/14340
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。




这篇文章很有用。非常感谢!
非常感谢)非常感谢。你认为我应该写下一篇关于风险经理的算法继承者的文章吗?
当然,你写的东西 - 对初学者 非常有用,但既然你的文章是面向初学者的(主观意见)、
那就多注意 "咀嚼 "代码吧。
祝你好运)
当然,写作初学者非常有用,但由于您的文章是面向初学者的(主观意见)、
请多注意 "啃 "代码。
祝你好运)
接受,谢谢)
正如您所提到的,没有办法阻止用户手动交易。但您的 EA 可以立即关闭超出限制后开启的任何新交易(这只会给交易者造成与点差相等的损失)。 这将导致交易者避免开启更多交易。 也许这种方法比使用注释通知用户更好。
祝您好运
正如您提到的,没有办法阻止用户手动交易。但您的 EA 可以立即关闭超出限制后开启的任何新交易(这只会给交易者造成与点差相等的损失)。 这将导致交易者避免开启更多交易。 也许这种方法比使用注释通知用户更好。
祝您好运
Servus!我同意您的观点。在很多情况下,纪律在交易中的作用远远大于应用知识,例如在技术分析中。