За пределами максимальной просадки: Создание анализатора ДНК просадки на MQL5
Введение
Некоторое время назад я параллельно запустил две свои стратегии, и обе показали почти одинаковое значение ключевого показателя: максимальную просадку примерно 18%. На бумаге они выглядели как близнецы. На практике они не имели друг с другом ничего общего. Одна из них резко просела, а затем примерно за две недели постепенно отыграла потери и вышла на новый максимум эквити. У другой эквити постепенно снижалось небольшими шагами и оставалось ниже предыдущего пика почти четыре месяца. Значение показателя в отчете было одинаковым. Но ощущения от торговли по каждой из этих стратегий были совершенно разными.
Именно этому различию и посвящена статья. Максимальная просадка — показатель, который трейдеры чаще всего приводят при описании риска, однако он сводит всю картину изменения эквити во времени к одному числу. Он показывает, насколько значение эквити снизилось относительно своего максимума. Он ничего не говорит о том, как часто это происходило, как долго счет оставался в просадке и как быстро он восстанавливался.
В этой статье мы создадим скрипт, который считывает ряд ежедневных финансовых результатов по закрытым сделкам, восстанавливает по нему кривую эквити и подробно анализирует структуру ее просадок. Я буду называть эту структуру "ДНК просадки": глубина, частота, продолжительность и профиль восстановления, которые вместе описывают, как на самом деле ведет себя кривая эквити в период снижения. В итоге у вас получится работающий анализатор, который:
- восстанавливает кривую эквити и соответствующую кривую просадки на основе файла с ежедневными данными о прибыли и убытках;
- разбивает кривую просадки на отдельные эпизоды, каждый со своей глубиной, продолжительностью и временем восстановления;
- рассчитывает индекс Ulcer, индекс боли, фактор восстановления и долю времени, в течение которого эквити остается ниже предыдущего пика;
- объединяет эти показатели в единый балл устойчивости, которому соответствует оценка от A+ до F, и формирует текстовые рекомендации.
Все работает непосредственно в MetaTrader 5. Здесь нет ни внешних библиотек, ни Python, ни машинного обучения. Весь инструмент состоит из одного скрипта на языке MQL5 и небольшого вспомогательного компонента, который экспортирует данные тестирования на исторических данных.
Почему максимальной просадки недостаточно
Рассмотрим пример из начала статьи подробнее. Предположим, что две стратегии тестируются за один и тот же период с одинаковым начальным капиталом и у обеих максимальная просадка достигает 18%. Посмотрим, насколько по-разному они могут прийти к этому значению:
- У стратегии A происходит одно глубокое снижение. За три недели она теряет 18%, а затем в течение двух недель восстанавливается до нового максимума. До конца года ее эквити остается вблизи достигнутого максимума.
- У стратегии B эквити никогда не снижается на 18% за один раз. Вместо этого ее эквити постепенно снижается из-за длинной серии небольших убытков и семь месяцев остается ниже предыдущего пика. Просадка достигает 18% лишь в самой нижней точке этого затяжного снижения.
У обеих фиксируется одинаковая максимальная просадка. Фильтр риска, учитывающий только это число, считал бы их равноценными. Но это не так. Для стратегии A характерны резкая просадка и последующее уверенное восстановление. Для стратегии B характерно медленное истощение счета, из-за которого счет большую часть года остается в просадке. Продолжать торговать по такой стратегии гораздо труднее, и трейдер с гораздо большей вероятностью откажется от системы в самый неподходящий момент.
Суть проста. Просадку, которую вы способны выдержать, можно пережить. Просадка, длящаяся месяцами, испытывает вашу дисциплину, а также терпение всех, чьими деньгами вы управляете. Чтобы различить эти два случая, нужно не ограничиваться самой глубокой точкой просадки, а оценить характер всего снижения.

Рис. 1. Две кривые эквити с одинаковой максимальной просадкой, но совершенно разными профилями восстановления.
Анатомия просадки
Прежде чем писать код, нужно точно определить термины. Каждый эпизод просадки описывается тремя контрольными точками на кривой эквити:
- пик, также называемый историческим максимумом эквити, — наивысшее значение эквити, достигнутое до начала снижения;
- дно просадки — самое низкое значение эквити, достигнутое во время снижения;
- точка восстановления — момент, когда эквити возвращается к прежнему пику и достигает нового максимума.
На основе этих трех точек мы рассчитываем показатели, описывающие отдельный эпизод:
- глубина — величина снижения от пика до дна просадки, выраженная в процентах от значения эквити на пике;
- длительность просадки — время от пика до дна просадки, измеряемое количеством записей, то есть торговых дней, представленных в файле;
- длительность восстановления — время от дна просадки до возврата к прежнему пику, измеряемое количеством записей;
- период в просадке — общее время от пика до полного восстановления, то есть сумма двух предыдущих показателей.
Если для каждой точки построить значение отклонения эквити от максимума, достигнутого к этому моменту, получится кривая просадки. Ее значение равно нулю всякий раз, когда эквити счета достигает нового максимума, и становится отрицательным во время каждого снижения. Самая глубокая впадина соответствует максимальной просадке, но кривая в целом показывает и другие ее характеристики: сколько эпизодов просадки возникает, насколько они глубоки и сколько времени занимает восстановление после каждого из них.

Рис. 2. Контрольные точки и показатели одного эпизода просадки.
Основные показатели
Описать один эпизод довольно просто. Более сложная задача — дать общую характеристику всей кривой эквити, на которой обычно наблюдается множество просадок разной величины. Анализатор выводит следующие показатели.
Максимальная и средняя глубина. Максимальная просадка — это наибольшая глубина отдельного эпизода просадки. Средняя просадка — это средняя глубина по всем эпизодам; она показывает, как выглядит типичная просадка, а не худшая.
Частота и самый длительный период в просадке. Количество отдельных эпизодов показывает, как часто стратегия опускается ниже предыдущего пика. Самый длительный период в просадке — важнейший показатель продолжительности: максимальное количество дней подряд, в течение которых эквити счета не достигало нового максимума.
Время в просадке. Это доля дней, в которые эквити счета было ниже предыдущего пика, в общем количестве дней. Стратегия, которая находится в просадке 30% времени, воспринимается совсем иначе, чем стратегия, которая проводит в просадке 70% времени, даже если их максимальные просадки совпадают.
Индекс Ulcer. Индекс Ulcer, также известный как "язвенный индекс", предложенный Питером Мартином в 1987 году и позже описанный в книге, написанной им совместно с Байроном Макканном, объединяет глубину и продолжительность просадок в одном показателе. Это среднеквадратичное значение глубин просадки:
Индекс Ulcer = sqrt((1 / n) * Sum(D_i^2)).
Здесь D_i — просадка в процентах в день i, а n — количество дней. Поскольку каждое значение глубины перед усреднением возводится в квадрат, индекс Ulcer быстро растет при глубоких и продолжительных просадках и остается низким при неглубоких и кратковременных. В отличие от стандартного отклонения, этот показатель полностью игнорирует движения вверх и учитывает только те потери, которые трейдер действительно ощущает.
Индекс боли. Индекс боли — это среднее значение ежедневной глубины просадки, рассчитанное по всем записям:
Индекс боли = (1 / n) * Sum(D_i)
Его проще интерпретировать, чем индекс Ulcer, поскольку он выражается непосредственно в процентных пунктах и отвечает на простой вопрос: насколько ниже пика в среднем находится эквити счета? Иногда его условно называют средней просадкой, но он отличается от описанной выше средней глубины эпизодов. Индекс боли усредняет значение просадки по всем записям, включая дни с нулевой просадкой, тогда как средняя глубина эпизодов рассчитывается только по значениям в точках дна каждой просадки.
Фактор восстановления. Фактор восстановления соотносит прибыль с максимальной просадкой:
Фактор восстановления = Чистая прибыль / Максимальная просадка (в денежном выражении)
Фактор восстановления, равный пяти, означает, что чистая прибыль стратегии за период тестирования в пять раз превышает ее максимальную просадку в денежном выражении. Это тот же показатель, который выводит Тестер стратегий; он помогает оценить, оправдывает ли полученная прибыль риск, принятый ради нее. Значение ниже единицы означает, что чистая прибыль меньше максимальной просадки, а отрицательное значение — что стратегия не смогла компенсировать свою максимальную просадку за анализируемый период.
В таблице ниже кратко показано, что учитывает каждый показатель, а что оставляет за рамками анализа. Это помогает рассматривать показатели в совокупности, а не по отдельности.
| Показатель | Что учитывает | Что не учитывает |
|---|---|---|
Максимальная просадка | Максимальное снижение от пика до дна просадки | Частота и продолжительность просадок |
| Время в просадке | Как часто и как долго эквити счета остается ниже пика | Глубина этих просадок |
| Индекс Ulcer | Глубина и продолжительность просадок в совокупности | Структура отдельных эпизодов просадки |
| Индекс боли | Среднее значение ежедневной просадки | Крайние значения скрыты за средним |
| Фактор восстановления | Доходность относительно максимальной просадки | Длительность восстановления |
Важное ограничение — дневной финансовый результат по закрытым сделкам, а не плавающая эквити
Нужно четко обозначить одно ограничение, поскольку от него зависит, как следует интерпретировать все приведенные здесь показатели. Анализатор работает с рядом ежедневных финансовых результатов по закрытым сделкам. Он восстанавливает кривую эквити, близкую по смыслу к кривой баланса, последовательно прибавляя к начальному капиталу финансовый результат по закрытым сделкам за каждый день. Это не плавающая эквити счета.
Из этого вытекают два следствия. Во-первых, инструмент не учитывает внутридневные просадки: если позиция в течение дня глубоко ушла в просадку, но закрылась вблизи уровня безубыточности, эта просадка не отражается в ряде ежедневных финансовых результатов по закрытым сделкам. Во-вторых, он не учитывает просадку по позиции, которая остается открытой несколько дней: убыток отражается в данных в день ее закрытия, а не по мере накопления. Таким образом, эта кривая отражает просадки эквити, рассчитанного по результатам закрытых сделок с ежедневным шагом. Такой способ анализа полезен и широко распространен, но полученные просадки отличаются от просадок плавающей эквити, рассчитанных по тиковым данным.
Это также означает, что продолжительность измеряется количеством записей, то есть торговых дней, представленных в файле, а не календарных дней. Чтобы проанализировать фактическую просадку плавающей эквити, нужно передать инструменту ряд ее снимков (дату и значение эквити счета для каждого бара или при каждом срабатывании таймера), а не ежедневные финансовые результаты по закрытым сделкам. При этом тот же движок можно использовать без изменений.
Архитектура анализатора ДНК просадки
Полное решение состоит из одного скрипта на языке MQL5, разделенного на четыре логических модуля, и одного вспомогательного скрипта:
- загрузчик данных — считывает дневные значения финансового результата из CSV-файла;
- движок кривой просадки — восстанавливает кривую эквити, ряд ее исторических максимумов и кривую просадки, а затем разбивает последнюю на отдельные эпизоды;
- показатели риска — модуль рассчитывает индекс Ulcer, индекс боли, фактор восстановления и время в просадке;
- балл устойчивости — модуль объединяет показатели во взвешенный балл, определяет соответствующую оценку и выводит рекомендации.
Мы поочередно создадим все модули, а затем объединим их в основном скрипте. Начнем с данных, которые требуются анализатору.
Примечание о прилагаемых файлах: основной скрипт (DrawdownDNA.mq5) содержит все четыре модуля и выводит полный отчет. Каждый модуль также предоставляется в виде отдельного скрипта для компиляции и изучения: DataLoader.mq5 (модуль 1), UnderwaterEngine.mq5 (модуль 2) и RiskMetrics.mq5 (модуль 3). Вспомогательный скрипт Utility_ExportDailyEquity.mq5 экспортирует данные истории сделок во входной CSV-файл. Каждый отдельный файл содержит короткий демонстрационный пример; соответствующий скрипт можно скомпилировать и запустить независимо от остальных.
Модуль 1 — загрузчик данных
Этот модуль также предоставляется в виде отдельного скрипта DataLoader.mq5. Анализатор работает с простым CSV-файлом, в котором каждому торговому дню соответствует одна строка. Файл содержит два столбца: дату и финансовый результат по сделкам, закрытым в этот день. В строке заголовков указаны названия столбцов. Данные любого тестирования на исторических данных можно с минимальными усилиями сохранить в этом формате. Далее в статье мы добавим вспомогательный скрипт, который будет делать это автоматически.
Начнем со свойств скрипта, входных параметров и двух структур данных, которые использует этот инструмент. Одна структура описывает отдельный эпизод просадки; другая содержит сводный отчет.
//--- Input parameters input string InpFileName = "EquityCurve.csv"; // CSV file: Date,DailyPnL input double InpStartCapital = 10000.0; // Starting capital input double InpMinDepthPct = 1.0; // Ignore drawdowns shallower than this (%) input double InpWeightDepth = 0.40; // Weight: depth dimension input double InpWeightRecov = 0.30; // Weight: recovery dimension input double InpWeightUlcer = 0.30; // Weight: stability dimension input double InpDepthFactor = 2.0; // Depth score: points lost per 1% of max drawdown input double InpUlcerFactor = 5.0; // Stability score: points lost per 1.0 of Ulcer Index //--- One drawdown episode: peak -> trough -> recovery struct SEpisode { int peakIdx; // record at the high-water mark the episode is measured from int troughIdx; // record of the deepest point int recoveryIdx; // record the equity reached the old peak again (-1 if never) datetime peakTime; // date of the peak record datetime troughTime; // date of the trough record datetime recoveryTime; // date of the recovery record (0 if never recovered) double depthPct; // deepest decline of the episode, in percent double depthMoney; // deepest decline of the episode, in account currency int ddDuration; // peak -> trough, in records (trading days in the file) int recDuration; // trough -> recovery, in records (-1 if still ongoing) int underwaterLen; // peak -> recovery, or peak -> end of data if ongoing, in records bool recovered; // true if the old peak was reached again }; //--- Aggregated report struct SDrawdownReport { int nDays; // number of records (trading days) in the file int nEpisodes; // number of drawdown episodes detected double maxDepthPct; // deepest drawdown over the whole curve, in percent double maxDepthMoney; // deepest drawdown over the whole curve, in account currency double avgDepthPct; // average episode depth, in percent int longestUnderwater; // longest underwater stretch, in records int avgRecovery; // average trough -> recovery time of recovered episodes, in records double timeUnderwaterPct; // share of records spent below a previous peak, in percent double ulcerIndex; // root mean square of the daily underwater depths double painIndex; // mean of the daily underwater depths double recoveryFactor; // net profit divided by the deepest drawdown in money double netProfit; // final equity minus the starting capital double depthScore; // depth dimension of the score, 0..100 double recovScore; // recovery dimension of the score, 0..100 double ulcerScore; // stability dimension of the score, 0..100 double resilience; // composite resilience score, 0..100 string grade; // letter grade from A+ to F bool hasOngoing; // true if a drawdown was still open at the end of the data }; //--- Global series (one entry per record) double g_pnl[]; // daily profit/loss read from the file datetime g_dates[]; // date of each record double g_equity[]; // running equity: starting capital plus cumulative PnL double g_peak[]; // running high-water mark up to each record double g_ddPct[]; // drawdown from the peak at each record, in percent double g_ddMoney[]; // drawdown from the peak at each record, in account currency int g_hwmIdx[]; // index of the most recent high-water mark, per record int g_n = 0; // number of records loaded
Загрузчик считывает файл как текст и разбирает каждую строку; такой подход устойчивее к пустым строкам, лишним пробелам и особенностям кодировки, чем последовательное считывание отдельных полей. Каждая строка разбивается по запятой на дату и значение. Строка заголовков пропускается, а строки, в которых не удалось распознать дату, игнорируются. Сохраняются и само значение, и его дата, поэтому впоследствии для каждого эпизода можно указать реальные календарные даты пика, дна просадки и восстановления.
//+------------------------------------------------------------------+ //| Read a Date,DailyPnL file into the pnl and date arrays | //| Read as text and parse each line, which is more robust than | //| field-by-field CSV reading against blank lines and stray spaces. | //+------------------------------------------------------------------+ bool LoadEquitySeries(string filename, double &pnl[], datetime &dates[]) { int handle = FileOpen(filename, FILE_READ|FILE_TXT|FILE_ANSI); if(handle == INVALID_HANDLE) { PrintFormat("Could not open %s. Error: %d", filename, GetLastError()); return false; } int count = 0; bool firstLine = true; while(!FileIsEnding(handle)) { string row = FileReadString(handle); StringTrimLeft(row); StringTrimRight(row); if(firstLine) // the first line is the header { firstLine = false; continue; } if(row == "") // skip blank lines continue; string parts[]; if(StringSplit(row, ',', parts) < 2) continue; StringTrimLeft(parts[0]); StringTrimRight(parts[0]); datetime d = StringToTime(parts[0]); if(d <= 0) // skip rows whose date does not parse continue; double value = StringToDouble(parts[1]); count++; ArrayResize(pnl, count, 256); ArrayResize(dates, count, 256); pnl[count - 1] = value; dates[count - 1] = d; } FileClose(handle); return (count > 0); }
Считывание только ежедневного результата позволяет ограничить размер входных данных и сохранить их переносимость. Сама кривая эквити восстанавливается в следующем модуле, поэтому один и тот же файл можно заново проанализировать с другим начальным капиталом без редактирования данных.
Модуль 2 — движок кривой просадки
Этот модуль, также предоставляемый в виде отдельного скрипта UnderwaterEngine.mq5, преобразует список ежедневных результатов в три ряда данных, на которых основан анализ: значения эквити, ее исторические максимумы и значения просадки. Исторический максимум эквити — это наибольшее ее значение, достигнутое к рассматриваемому моменту; значение просадки показывает, насколько текущая эквити ниже этого максимума.
//+------------------------------------------------------------------+ //| Build the equity, high-water mark, underwater and HWM-index data | //+------------------------------------------------------------------+ void BuildSeries(const double &pnl[], int n, double capital, double &equity[], double &peak[], double &ddPct[], double &ddMoney[], int &hwmIdx[]) { ArrayResize(equity, n); ArrayResize(peak, n); ArrayResize(ddPct, n); ArrayResize(ddMoney, n); ArrayResize(hwmIdx, n); double run = capital; double hwm = capital; // high-water mark int hi = 0; // record of the most recent high-water mark for(int i = 0; i < n; i++) { run += pnl[i]; equity[i] = run; if(run >= hwm) // a new high (or a tie) moves the peak record forward { hwm = run; hi = i; } peak[i] = hwm; hwmIdx[i] = hi; ddMoney[i] = hwm - run; ddPct[i] = (hwm > 0.0) ? (ddMoney[i] / hwm * 100.0) : 0.0; } }
Получив кривую просадки, мы разбиваем ее на эпизоды. Алгоритм последовательно просматривает точки кривой слева направо. Каждый раз, когда эквити опускается ниже достигнутого к этому моменту максимума, начинается новый эпизод. Затем функция отслеживает точку максимальной глубины просадки, пока эквити не вернется к этому максимуму; в этот момент эпизод завершается и сведения о нем сохраняются. Если ряд данных заканчивается, а эквити счета все еще остается ниже предыдущего пика, последний эпизод помечается как продолжающийся, а не просто как эпизод без восстановления: считать его завершившимся восстановлением означало бы занизить реальный риск.
//+------------------------------------------------------------------+ //| Split the underwater curve into discrete drawdown episodes | //+------------------------------------------------------------------+ int DetectEpisodes(const double &equity[], const double &peak[], const double &ddPct[], const double &ddMoney[], const datetime &dates[], const int &hwmIdx[], int n, double minDepth, SEpisode &eps[]) { int nEp = 0; double tol = 1e-8; ArrayResize(eps, 0); int i = 0; while(i < n) { if(ddPct[i] > tol) { //--- The peak day is the record of the high-water mark this decline measures from. int startPeak = hwmIdx[i]; if(startPeak < 0) startPeak = 0; double curPeak = peak[i]; int troughIdx = i; double maxDd = ddPct[i]; int j = i; while(j < n && equity[j] < curPeak - tol) { if(ddPct[j] > maxDd) { maxDd = ddPct[j]; troughIdx = j; } j++; } SEpisode e; e.peakIdx = startPeak; e.troughIdx = troughIdx; e.peakTime = dates[startPeak]; e.troughTime = dates[troughIdx]; e.depthPct = maxDd; e.depthMoney = ddMoney[troughIdx]; e.ddDuration = troughIdx - startPeak; if(j < n) { e.recovered = true; e.recoveryIdx = j; e.recoveryTime = dates[j]; e.recDuration = j - troughIdx; e.underwaterLen = j - startPeak; } else { e.recovered = false; e.recoveryIdx = -1; e.recoveryTime = 0; e.recDuration = -1; // not recovered: no recovery time exists e.underwaterLen = (n - 1) - startPeak; // days elapsed since the peak, still open } if(e.depthPct >= minDepth) { nEp++; ArrayResize(eps, nEp); eps[nEp - 1] = e; } i = (j < n) ? j : n; } else i++; } return nEp; }
Стоит отметить два решения в реализации. Во-первых, эпизоды с глубиной меньше настраиваемого порога игнорируются, поэтому единичный шумовой тик не регистрируется как просадка. Во-вторых, если в конце ряда эпизод все еще продолжается, в отчете прямо указывается, что восстановление не произошло: считать такой эпизод завершившимся восстановлением означало бы занизить реальный риск.
Чтобы результат было легко оценить с первого взгляда, инструмент также выводит текстовую версию кривой просадки на вкладке "Эксперты". Каждый столбец соответствует отдельному интервалу времени, а строки — возрастающим значениям глубины просадки, поэтому форму просадок можно увидеть, не открывая график.
//+------------------------------------------------------------------+ //| Draw an ASCII underwater chart in the Experts tab | //+------------------------------------------------------------------+ void PrintUnderwaterChart(const double &ddPct[], int n, double maxDepth) { int cols = 50; int rows = 8; double bucket[]; ArrayResize(bucket, cols); ArrayInitialize(bucket, 0.0); for(int c = 0; c < cols; c++) { int a = (int)MathFloor((double)c * n / cols); int b = (int)MathFloor((double)(c + 1) * n / cols); if(b <= a) b = a + 1; double mx = 0.0; for(int i = a; i < b && i < n; i++) if(ddPct[i] > mx) mx = ddPct[i]; bucket[c] = mx; } double scale = (maxDepth > 0.0) ? maxDepth : 1.0; Print("Underwater curve (deeper is lower, each column is a time bucket):"); for(int rr = 1; rr <= rows; rr++) { double level = scale * rr / rows; string line = StringFormat("%5.1f%%|", level); for(int c = 0; c < cols; c++) line += (bucket[c] >= level - 1e-9) ? "#" : "."; Print(line); } }

Рис. 3. ASCII-график кривой просадки и таблица эпизодов просадки на вкладке "Эксперты".
Модуль 3 — показатели риска
Этот модуль также предоставляется в виде отдельного скрипта RiskMetrics.mq5. Если кривая просадки представлена массивом ежедневных значений глубины, расчет сводных показателей несложен. Индекс Ulcer — среднеквадратичное значение глубин просадки; индекс боли — их среднее арифметическое. Фактор восстановления и время в просадке рассчитываются на следующем этапе, при формировании сводных данных по эпизодам, поскольку для их расчета используются значения, уже собранные для отчета. Расчет каждого показателя в отдельной небольшой автономной функции упрощает тестирование инструмента: в эти функции можно передать любой массив глубин просадки и вручную проверить результат на нескольких значениях.
//+------------------------------------------------------------------+ //| Ulcer Index: root mean square of the underwater depths | //+------------------------------------------------------------------+ double UlcerIndex(const double &ddPct[], int n) { if(n <= 0) return 0.0; double sum = 0.0; for(int i = 0; i < n; i++) sum += ddPct[i] * ddPct[i]; return MathSqrt(sum / n); } //+------------------------------------------------------------------+ //| Pain Index: average underwater depth across every record | //+------------------------------------------------------------------+ double PainIndex(const double &ddPct[], int n) { if(n <= 0) return 0.0; double sum = 0.0; for(int i = 0; i < n; i++) sum += ddPct[i]; return sum / n; }
Модуль 4 — балл устойчивости
Последний модуль сводит все к одному числу. При проектировании нужно определить, какие веса присвоить различным характеристикам просадок. Тестирование на нескольких кривых эквити показало, что три характеристики отражают большую часть различий между стратегией, которой комфортно следовать, и стратегией, которой следовать некомфортно:
- глубина (40%) — балл определяется максимальной просадкой (чем она меньше, тем выше балл);
- восстановление (30%) — балл определяется временем в просадке (кривые эквити, которые быстро достигают новых максимумов, получают более высокие баллы);
- стабильность (30%) — балл определяется индексом Ulcer: чем мельче и короче просадки, тем выше балл.
Каждая характеристика оценивается по шкале от 0 до 100 баллов. Оценки объединяются с учетом соответствующих весов, а итоговый балл преобразуется в буквенную оценку. Веса задаются входными параметрами, поэтому их можно настраивать с учетом приемлемого для вас уровня риска — например, увеличить вес восстановления, если больше всего вы опасаетесь длительных периодов в просадке.
Важно четко понимать, что представляет собой этот балл. Это эвристическая оценка для быстрого сравнения стратегий из одного набора при одинаковом периоде тестирования и способе формирования выборки. Кроме того, этот показатель не является отраслевым стандартом. Три веса, коэффициенты масштабирования отдельных компонентов (сколько баллов снимается на единицу максимальной просадки и на единицу индекса Ulcer; оба коэффициента задаются входными параметрами), а также границы оценок A-F — это настраиваемые значения по умолчанию, а не константы, полученные статистическим путем. Для разных типов стратегий характерны разные профили просадок, поэтому трендовую систему и систему возврата к среднему лучше сравнивать с учетом особенностей каждой из них, а не по единой универсальной шкале.
//+------------------------------------------------------------------+ //| Aggregate the episodes and metrics into a single report | //+------------------------------------------------------------------+ void BuildReport(const SEpisode &eps[], int nEp, const double &ddPct[], int n, const double &equity[], double capital, SDrawdownReport &r) { r.nDays = n; r.nEpisodes = nEp; r.maxDepthPct = 0.0; r.maxDepthMoney = 0.0; r.longestUnderwater = 0; r.hasOngoing = false; double sumDepth = 0.0; int sumRec = 0, recCount = 0; for(int k = 0; k < nEp; k++) { if(eps[k].depthPct > r.maxDepthPct) r.maxDepthPct = eps[k].depthPct; if(eps[k].depthMoney > r.maxDepthMoney) r.maxDepthMoney = eps[k].depthMoney; sumDepth += eps[k].depthPct; if(eps[k].underwaterLen > r.longestUnderwater) r.longestUnderwater = eps[k].underwaterLen; if(eps[k].recovered) { sumRec += eps[k].recDuration; recCount++; } else r.hasOngoing = true; } r.avgDepthPct = (nEp > 0) ? sumDepth / nEp : 0.0; r.avgRecovery = (recCount > 0) ? (int)MathRound((double)sumRec / recCount) : 0; int underwaterDays = 0; for(int i = 0; i < n; i++) if(ddPct[i] > 1e-8) underwaterDays++; r.timeUnderwaterPct = (n > 0) ? 100.0 * underwaterDays / n : 0.0; r.ulcerIndex = UlcerIndex(ddPct, n); r.painIndex = PainIndex(ddPct, n); r.netProfit = equity[n - 1] - capital; r.recoveryFactor = (r.maxDepthMoney > 0.0) ? r.netProfit / r.maxDepthMoney : 0.0; //--- Dimension scores (higher means more resilient). The factors are inputs. r.depthScore = MathMax(0.0, 100.0 - r.maxDepthPct * InpDepthFactor); r.recovScore = MathMax(0.0, 100.0 - r.timeUnderwaterPct); r.ulcerScore = MathMax(0.0, 100.0 - r.ulcerIndex * InpUlcerFactor); //--- Normalize the weights so the result does not depend on their sum double wd = MathMax(0.0, InpWeightDepth); double wr = MathMax(0.0, InpWeightRecov); double wu = MathMax(0.0, InpWeightUlcer); double wsum = wd + wr + wu; if(wsum <= 0.0) { wd = 0.40; wr = 0.30; wu = 0.30; wsum = 1.0; Print("Warning: weights were non-positive; reverting to the 0.40 / 0.30 / 0.30 default."); } wd /= wsum; wr /= wsum; wu /= wsum; r.resilience = r.depthScore * wd + r.recovScore * wr + r.ulcerScore * wu; if(r.resilience >= 90) r.grade = "A+"; else if(r.resilience >= 80) r.grade = "A"; else if(r.resilience >= 70) r.grade = "B"; else if(r.resilience >= 60) r.grade = "C"; else if(r.resilience >= 50) r.grade = "D"; else r.grade = "F"; }
Шкала оценок намеренно укрупнена. Ее задача — не ранжировать две стратегии, различающиеся на три балла, а сразу показать, комфортно ли следовать стратегии с такой кривой эквити (A или B), можно ли применять ее с осторожностью (C или D) или она создает проблемы, требующие внимания (F).
Основной скрипт — объединяем все вместе
Функция OnStart связывает модули воедино. Она загружает файл, а если он не найден, генерирует демонстрационную кривую, чтобы скрипт можно было запустить без дополнительной настройки. Затем она строит ряды данных, выявляет эпизоды просадки, рассчитывает показатели для отчета и выводит кривую просадки, таблицу эпизодов, показатели, балл и рекомендации.
//+------------------------------------------------------------------+ //| Script entry point | //+------------------------------------------------------------------+ void OnStart() { Print("========================================"); Print(" DRAWDOWN DNA ANALYZER v1.1"); Print(" Underwater, Recovery and Resilience Tool"); Print("========================================"); if(!LoadEquitySeries(InpFileName, g_pnl, g_dates)) { Print(">> File not found or empty. Generating a sample curve..."); if(!EnsureSampleFile(InpFileName)) { Alert("Could not create the sample file."); return; } if(!LoadEquitySeries(InpFileName, g_pnl, g_dates)) { Alert("Could not load the sample file."); return; } } g_n = ArraySize(g_pnl); if(g_n < 20) { Alert("At least 20 daily records are required."); return; } BuildSeries(g_pnl, g_n, InpStartCapital, g_equity, g_peak, g_ddPct, g_ddMoney, g_hwmIdx); SEpisode eps[]; int nEp = DetectEpisodes(g_equity, g_peak, g_ddPct, g_ddMoney, g_dates, g_hwmIdx, g_n, InpMinDepthPct, eps); SDrawdownReport rep; BuildReport(eps, nEp, g_ddPct, g_n, g_equity, InpStartCapital, rep); Print("\nNote: this analysis is built from a daily closed-PnL path, not floating equity."); Print("It does not see intraday drawdowns or open-position drawdowns of trades closed later."); //--- Underwater curve Print(""); PrintUnderwaterChart(g_ddPct, g_n, rep.maxDepthPct); //--- Episode table, now with real dates PrintFormat("\n=== DRAWDOWN EPISODES (depth >= %.1f%%) ===", InpMinDepthPct); Print(" # Depth Peak Trough Recovery UWdays"); for(int k = 0; k < nEp; k++) { string recDate = eps[k].recovered ? TimeToString(eps[k].recoveryTime, TIME_DATE) : "ongoing"; PrintFormat(" %-3d %6.2f%% %s %s %-11s %d", k + 1, eps[k].depthPct, TimeToString(eps[k].peakTime, TIME_DATE), TimeToString(eps[k].troughTime, TIME_DATE), recDate, eps[k].underwaterLen); } //--- Risk metrics Print("\n=== RISK METRICS ==="); PrintFormat(" Max drawdown: %.2f%% ($%.2f)", rep.maxDepthPct, rep.maxDepthMoney); PrintFormat(" Average drawdown: %.2f%% (mean depth across episodes)", rep.avgDepthPct); PrintFormat(" Drawdown episodes: %d", rep.nEpisodes); PrintFormat(" Longest underwater: %d records", rep.longestUnderwater); PrintFormat(" Avg recovery time: %d records", rep.avgRecovery); if(rep.hasOngoing) Print(" (Average recovery excludes the ongoing, not-yet-recovered drawdown.)"); PrintFormat(" Time underwater: %.1f%%", rep.timeUnderwaterPct); PrintFormat(" Ulcer Index: %.2f (RMS of daily underwater values)", rep.ulcerIndex); PrintFormat(" Pain Index: %.2f (mean of daily underwater values)", rep.painIndex); PrintFormat(" Recovery Factor: %.2f", rep.recoveryFactor); if(rep.recoveryFactor < 0.0) Print(" (Negative: the net result did not recover the deepest loss over this history.)"); else if(rep.recoveryFactor < 1.0) Print(" (Below 1: the net profit is smaller than the deepest loss.)"); //--- Resilience score Print("\n========================================"); Print(" DRAWDOWN RESILIENCE SCORE (heuristic)"); Print("========================================"); PrintFormat(" Depth score: %.1f / 100", rep.depthScore);
Рекомендации формируются на основе пороговых значений, а не только оценки, поэтому они указывают на конкретные слабые стороны. Большая максимальная просадка, высокая доля времени в просадке, высокий индекс Ulcer и очень длительный период в просадке — каждый из этих факторов вызывает отдельное сообщение.

Рис. 4. Показатели риска, балл устойчивости, оценка устойчивости и рекомендации на вкладке "Эксперты".
Подготовка данных — практический порядок действий
Для анализа реальной стратегии вам понадобится CSV-файл с данными о ее ежедневных результатах. Порядок действий прост:
- запустите тестирование на исторических данных в Тестере стратегий с настройками, которые вы хотите оценить;
- экспортируйте ежедневный финансовый результат в CSV-файл со столбцами Date и DailyPnL;
- поместите файл в папку MQL5\Files терминала и укажите его во входном параметре анализатора.
Чтобы автоматизировать экспорт, добавьте следующую функцию в свой советник и вызовите ее из обработчика события OnTester. Эта же функция включена в отдельный вспомогательный скрипт Utility_ExportDailyEquity.mq5, который также можно запускать самостоятельно. Функция группирует закрытые сделки на покупку и продажу по дням, суммирует финансовый результат за каждый день и записывает файл в формате, необходимом анализатору. Учитываются только торговые сделки, поэтому балансовые операции и другие неторговые записи не искажают кривую. Функция считывает историю, доступную в текущем контексте: историю тестера при вызове из OnTester или историю счета при запуске вспомогательного скрипта.
//+------------------------------------------------------------------+ //| Aggregate closed-deal profit per day and write it to a CSV file | //+------------------------------------------------------------------+ void ExportDailyEquity(string filename) { int handle = FileOpen(filename, FILE_WRITE|FILE_CSV|FILE_ANSI, ','); if(handle == INVALID_HANDLE) { PrintFormat("Cannot create %s. Error: %d", filename, GetLastError()); return; } FileWriteString(handle, "Date,DailyPnL\n"); //--- This reads whatever history is available in the current context: //--- the Strategy Tester history when called from OnTester(), or the //--- account history when run as a script. In the tester the result //--- depends on the execution model and on netting/partial closes. if(!HistorySelect(0, TimeCurrent())) { Print("HistorySelect failed."); FileClose(handle); return; } int total = HistoryDealsTotal(); datetime lastDate = 0; double dayPnL = 0.0; int rows = 0; for(int i = 0; i < total; i++) { ulong ticket = HistoryDealGetTicket(i); if(ticket == 0) continue; //--- Keep only real trade deals (buy/sell). This excludes balance, //--- credit, charge, correction, bonus, interest and similar entries, //--- which is stricter and safer than skipping DEAL_TYPE_BALANCE alone. ENUM_DEAL_TYPE dtype = (ENUM_DEAL_TYPE)HistoryDealGetInteger(ticket, DEAL_TYPE); if(dtype != DEAL_TYPE_BUY && dtype != DEAL_TYPE_SELL) continue; double profit = HistoryDealGetDouble(ticket, DEAL_PROFIT) + HistoryDealGetDouble(ticket, DEAL_SWAP) + HistoryDealGetDouble(ticket, DEAL_COMMISSION); datetime dealTime = (datetime)HistoryDealGetInteger(ticket, DEAL_TIME); MqlDateTime dt; TimeToStruct(dealTime, dt); datetime dateOnly = StringToTime(StringFormat("%04d.%02d.%02d", dt.year, dt.mon, dt.day)); if(dateOnly != lastDate && lastDate != 0) { MqlDateTime ld; TimeToStruct(lastDate, ld); FileWriteString(handle, StringFormat("%04d.%02d.%02d,%.2f\n", ld.year, ld.mon, ld.day, dayPnL)); rows++; dayPnL = 0.0; } lastDate = dateOnly; dayPnL += profit; } //--- Write the final day if(lastDate != 0) { MqlDateTime ld; TimeToStruct(lastDate, ld); FileWriteString(handle, StringFormat("%04d.%02d.%02d,%.2f\n", ld.year, ld.mon, ld.day, dayPnL)); rows++; } FileClose(handle); PrintFormat("Exported %d daily rows to %s", rows, filename); }
Добавьте вызов ExportDailyEquity("EquityCurve.csv") в OnTester и один раз запустите тестирование на исторических данных. В папке MQL5\Files появится файл, готовый для обработки анализатором. Эта же функция включена в отдельный скрипт, который можно запустить на счете с уже имеющейся историей сделок.
Интерпретация результатов и принятие решений
Оценка устойчивости — это отправная точка. Главную ценность представляют отдельные показатели, которые подсказывают конкретные действия.
Сценарий 1 — глубокая максимальная просадка, быстрое восстановление. Балл по глубине низкий, а балл по восстановлению высокий. Стратегия испытывает сильные просадки, но быстро восстанавливается. Решение здесь — подобрать размер позиции: если максимальная просадка превышает ваши финансовые возможности, уменьшайте размер позиции в лотах, пока просадка в денежном выражении не станет приемлемой для вашего счета. Не стоит из-за этого отказываться от системы, которая быстро восстанавливается.
Сценарий 2 — неглубокие просадки, длительное время в просадке. Балл по глубине высокий, но балл по восстановлению низкий, а индекс Ulcer повышен. Это описанный во введении профиль медленного истощения счета. Проверьте, не перестала ли стратегия работать в текущем рыночном режиме: если кривая эквити редко уходит в глубокую просадку, но почти постоянно остается ниже предыдущего максимума, это часто указывает на то, что статистическое преимущество стратегии ослабло.
Сценарий 3 — высокий индекс Ulcer. Если индекс Ulcer высок, а максимальная просадка выглядит приемлемой, проблема заключается в продолжительности просадок: эквити счета подолгу остается значительно ниже своего максимума. Посмотрите на самый длительный период в просадке и таблицу эпизодов, чтобы понять, что стало причиной: один длительный эпизод или множество эпизодов средней продолжительности.
Сценарий 4 — высокая оценка по всем показателям. Неглубокие просадки, быстрое восстановление и низкий индекс Ulcer дают оценку A или B. В этом случае задача — сохранить достигнутый результат. Добавляя новую стратегию для торговли на счете, повторно запустите анализатор для совокупной кривой эквити и убедитесь, что оценка не снизилась.
Из всех четырех случаев следует один практический вывод: максимальная просадка, которую обычно приводят как основной показатель риска, сама по себе наименее информативна среди этих показателей. Время в просадке и индекс Ulcer, как правило, дают более полное представление о том, будете ли вы по-прежнему торговать по этой стратегии через год.
Расширение возможностей анализатора
Анализатор — это основа. Несколько расширений сделают его значительно мощнее:
- скользящий индекс Ulcer — рассчитывайте индекс Ulcer в скользящем окне, а не по всей истории, чтобы увидеть, становится ли со временем следование стратегии более или менее психологически напряженным;
- внутридневная выборка — передавайте инструменту значения эквити, зафиксированные несколько раз в день, а не один раз, чтобы учитывать внутридневные просадки, не видимые в дневных данных;
- моделирование порядка эпизодов — многократно перемешивайте дневные результаты и каждый раз заново измеряйте самый длительный период в просадке, чтобы получить доверительный диапазон его возможной продолжительности;
- интеграция с портфелем — запустите анализатор для совокупной кривой эквити нескольких стратегий и учитывайте оценку устойчивости как дополнительный фактор при выборе стратегии для торговли на счете.
Заключение
Максимальная просадка — полезный показатель, но сама по себе она скрывает ту сторону риска, от которой зависит, продолжит ли трейдер торговать по стратегии: как долго счет остается в просадке и как затем восстанавливается. В этой статье мы создали инструмент, который считывает дневную кривую эквити, восстанавливает кривую просадки, разбивает ее на эпизоды и выводит глубину, длительность просадки, время восстановления, индекс Ulcer, индекс боли и фактор восстановления. Затем на их основе формируется единая оценка устойчивости с конкретными рекомендациями.
Основные выводы для практиков:
- Измеряйте всю просадку, а не только ее самую глубокую точку. Две кривые эквити с одинаковой максимальной просадкой могут вести себя совершенно по-разному.
- Время в просадке часто позволяет предсказать, сможет ли реальный трейдер продолжать следовать стратегии.
- Индекс Ulcer объединяет глубину и продолжительность просадок в одном показателе и часто лучше отражает, как трейдер переживает просадку на практике. Он дополняет максимальную просадку, а не заменяет ее.
- Используйте оценку для сравнения, но принимайте решения на основе отдельных показателей, поскольку каждый из них указывает на свой способ устранения проблемы.
Исходный код доступен в MQL5 CodeBase: Анализатор ДНК просадки в MQL5 CodeBase. Скачайте анализатор, примените его к своим стратегиям и начните измерять те характеристики просадки, которые не отражает показатель максимальной просадки.
В следующей таблице описаны файлы исходного кода, прилагаемые к статье.
| Имя файла | Описание |
|---|---|
DrawdownDNA.mq5: | Основной скрипт. Скрипт загружает данные, строит кривую просадки, выявляет ее отдельные эпизоды, рассчитывает показатели и балл устойчивости, а также выводит полный отчет на вкладке "Эксперты". |
| DataLoader.mq5: | Модуль 1 — считывает CSV-файл с ежедневными финансовыми результатами и восстанавливает кривую эквити, выводя краткую сводку для проверки. |
| UnderwaterEngine.mq5: | Модуль 2 — строит ряды значений эквити, ее исторических максимумов и просадки, а также разбивает кривую просадки на отдельные эпизоды. |
| RiskMetrics.mq5: | Модуль 3 — рассчитывает индекс Ulcer, индекс боли, фактор восстановления, время в просадке и балл устойчивости на основе демонстрационного ряда данных. |
| Utility_ExportDailyEquity.mq5: | Вспомогательный скрипт, экспортирующий данные истории сделок в файл со столбцами Date и DailyPnL. Скопируйте функцию из этого файла в свой советник и вызовите ее из OnTester либо запустите сам файл как скрипт. |
| MQL5.zip: | Архив с папкой MQL5 в корне, содержащий все перечисленные выше файлы. Распакуйте архив в каталог установки терминала, и каждый файл окажется на своем месте: все пять скриптов будут размещены в папке MQL5\Scripts\DrawdownDNA, не загромождая стандартные каталоги терминала. |
- Документация MQL5: Файлы
- Документация MQL5: Математика
- Документация MQL5: Массивы
- Документация MQL5: Свойства сделок
- Peter G. Martin and Byron B. McCann, "The Investor's Guide to Fidelity Funds", 1989 (источник индекса Ulcer)
- Исходный код анализатора ДНК просадки, MQL5 CodeBase Анализатор ДНК просадки в MQL5 CodeBase
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/23175
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Теория экстремальных значений в MQL5: Создание индикатора риска обвала с оценкой хвоста распределения — за рамками VaR по методу Монте-Карло
Нейронная сеть на практике: Чтение PNG-изображений из MNIST
Нейросети в трейдинге: Диффузионная генерация торговых планов (Сценарный расчёт)
От начального к среднему уровню: Файл шаблона графика (III)
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования