Управление позициями: Построение самокалибрующейся лестницы выхода по историческим данным MFE в MQL5
Содержание
- Введение
- Конвейер обработки данных: что на входе и что на выходе
- Почему фиксированная лестница выхода — это лишь предположение
- Определение наблюдения: MFE в единицах R
- CExcursionTracker — регистрация отклонения цены в реальном времени
- CExitLadderCalibrator — от наблюдений к ступеням
- CLadderExecutor — закрытие позиций на ступенях без повторного срабатывания
- SelfCalibratingLadderEA.mq5 — подключение компонентов
- Проверка с помощью TestExcursionLadder.mq5
- Примечания по тестированию на исторических данных
- Известные ограничения
- Заключение
Введение
При разработке советника, который поэтапно закрывает позиции, практический вопрос заключается не в том, что выглядит красиво, а в том, когда прибыль по позиции становится достаточно большой, чтобы часть ее стоило зафиксировать. Это становится инженерной задачей, как только выходы автоматизируются для множества позиций, инструментов и расстояний до стоп-лосса: фиксированное правило вроде "закрыть треть позиции при 1R, треть — при 2R, а остальную часть оставить открытой" удобно, но по сути представляет собой догадку, замаскированную под правило. На бумаге это выглядит как дисциплинированный подход. На практике же стратегия может почти никогда не достигать 3R, так что последняя ступень становится скорее декоративной, либо регулярно достигать 5R, и тогда ступень на уровне 2R не столько служит целью, сколько ограничивает собственное торговое преимущество стратегии. Расстояния между ступенями, рассчитанные на основе ATR, помогают адаптироваться к волатильности, но по-прежнему ничего не говорят о том, насколько далеко цена обычно движется в пользу позиции при данной логике входа на данном инструменте.
В этой статье догадки заменяются конвейером обработки данных, основанным на измерениях и пригодным для использования в советниках, работающих в реальных условиях. Максимальное благоприятное отклонение (MFE) каждой позиции измеряется в единицах R (MFE-in-R), где 1R — расстояние по цене от входа до первоначального стоп-лосса. Полученные наблюдения сохраняются для дальнейшего использования, а по последним наблюдениям рассчитываются ступени лестницы выхода для поэтапного закрытия позиции на основе собственной истории счета, а не путем ручного выбора. "CExcursionTracker" отслеживает MFE в единицах R в реальном времени по каждому тикету и записывает наблюдения для долговременного хранения. "CExitLadderCalibrator" преобразует последние N наблюдений в ступени на основе процентилей, используя настраиваемое окно ретроспективного анализа, минимальное число наблюдений и резервную лестницу выхода. "CLadderExecutor" применяет эти ступени к открытым позициям, выполняя округление объема, ведя учет состояния ступеней по каждому тикету и при необходимости выполняя перенос стоп-лосса в безубыток. В результате получается самокалибрующаяся лестница выхода с уровнями в единицах R, которую можно подключить к любому советнику. Пока не накопится достаточно исторических данных, используется резервная лестница выхода, после чего процентили по последним закрытым позициям определяют уровни поэтапного закрытия, отражающие реальное поведение стратегии, а не предположения разработчика.

Рис. 1. Обзор архитектуры системы.
Эта система напрямую основана на механизме отслеживания MAE/MFE, представленном в советнике CTradeJournal из одной из предыдущих статей. Если в том проекте данные об отклонениях собирались для анализа после закрытия позиций, то здесь те же измерения используются непосредственно в процессе торговли на счете.
Эта система отличается от (1) панели анализа MAE/MFE и (2) настраиваемой лестницы фиксации прибыли в двух аспектах. Во-первых, измерения ведутся в реальном времени, а их результаты накапливаются. "CExcursionTracker" отслеживает каждую открытую позицию тик за тиком. По мере закрытия позиций выборка пополняется, а калибровка пересчитывается, вместо того чтобы один раз выдать неизменную рекомендацию для ручного ввода. Во-вторых, результатом является многоступенчатая лестница выхода, а не единственный уровень стоп-лосса или целевой уровень. Каждый уровень выражается в единицах R, а не в пунктах. Благодаря этому одна и та же система процентилей может применяться к различным инструментам и размерам стопов. Лестница фиксации прибыли, которая поэтапно закрывает позицию при достижении заданных уровней, кратных R, — лишь половина системы. В этой статье рассматривается прежде всего то, откуда берутся эти кратные R уровни.
Конвейер обработки данных: что на входе и что на выходе
Прежде чем переходить к реализации, рассмотрим весь конвейер обработки данных. Он объединяет три модуля с чётко разграниченными задачами, которые взаимодействуют внутри советника, не сливаясь в один большой класс.
"CExcursionTracker" отвечает за работу в режиме реального времени. Метод "Register()" вызывается один раз, сразу при открытии позиции, с передачей цены входа, первоначального стоп-лосса и направления позиции. Затем на каждом тике вызывается метод "UpdateTick()", который отслеживает наибольшее достигнутое на текущий момент MFE в единицах R. Когда позиция закрывается, вызывается метод "Finalize()", которому передается реализованный результат в единицах R, рассчитанный советником по сделкам из истории, поскольку сам модуль отслеживания никогда не запрашивает данные о закрытой позиции. После этого завершенное наблюдение добавляется в файл для долговременного хранения данных.
"CExitLadderCalibrator" отвечает за обработку накопленных данных. Он использует накопленные наблюдения, загруженные из того же файла, и небольшой набор заранее заданных параметров: какие процентили распределения MFE в единицах R станут ступенями, сколько последних закрытых позиций учитывать (окно ретроспективного анализа) и какое минимальное число наблюдений необходимо, прежде чем можно будет полагаться на откалиброванную лестницу выхода. Если число наблюдений меньше этого минимума, "Calibrate()" возвращает фиксированную резервную лестницу выхода. Если больше — тот же вызов возвращает ступени, определенные непосредственно по распределению выборки. В любом случае результат имеет одинаковую структуру — упорядоченный список уровней в единицах R и долей объема для закрытия. Поэтому последующим этапам обработки не требуется знать, какой из вариантов использовался для его формирования.
Ответственность за выполнение несет "CLadderExecutor". Ему передается последняя лестница выхода, сформированная калибратором. Метод "Register()" вызывается один раз для каждой открытой позиции с указанием цены входа, стоп-лосса и объема, а "OnTick()" проверяет текущее продвижение цены относительно каждой еще не сработавшей ступени, выполняя частичное закрытие не более одного раза на каждой ступени для каждого тикета.
В советнике это сводится к небольшому набору точек вызова: "Register()" у трекера и исполнителя при открытии позиции, "UpdateTick()" и "OnTick()" на каждом тике, "Finalize()" при закрытии позиции и повторный вызов "Calibrate()", когда накопится достаточно новых наблюдений для повторной калибровки. В разделе 8 все эти компоненты объединяются. В промежуточных разделах каждый из них разрабатывается отдельно, в порядке вызова.
Почему фиксированная лестница выхода — это лишь предположение
Лестница выхода 1R/2R/3R предполагает, что распределение благоприятных отклонений у всех стратегий имеет одинаковую форму и что эта форма не меняется со временем. На практике оба предположения плохо согласуются с действительностью. У скальпера, торгующего на возврат к среднему на паре, движущейся в диапазоне, прибыльные позиции могут лишь изредка преодолевать 1,5R, прежде чем цена вернется к среднему, пройдя обратно через уровень безубыточности, так что последняя ступень на 3R оказывается по большей части декоративной. Пробойная система, следующая за трендом, на той же паре может регулярно доводить прибыльные позиции до 4R или 5R, поэтому последняя ступень на 2R становится не целью, а ограничением торгового преимущества самой стратегии.
Лестницы выхода на основе ATR частично решают эту проблему: расстояние между ступенями адаптируется под текущую волатильность. Но они не показывают, насколько далеко цена обычно проходит в пользу позиции в рамках самой стратегии после учета волатильности. Две стратегии с одинаковыми расстояниями до стоп-лосса, рассчитанными на основе ATR, могут иметь совершенно разные распределения MFE, поскольку распределение определяется логикой входа и поведением инструмента в связи с этим входом, а не только волатильностью. Единственным источником, который отражает и то, и другое, является сама история торговых операций.
Определение наблюдения: MFE в единицах R
Прежде чем приступить к написанию кода, необходимо точно определить единицу измерения. Для данной позиции R — это расстояние по цене между ценой входа и первоначальным стоп-лоссом. Это расстояние фиксируется при открытии позиции. Максимальное благоприятное отклонение (MFE) в единицах R (MFE-in-R) — это наибольшее движение цены в пользу позиции за все время ее существования, выраженное как кратное расстоянию R, независимо от того, по какой цене позиция в итоге закрывается.
Для длинной позиции, открытой по цене 1,1050 со стоп-лоссом на уровне 1,1030, расстояние R равно 0,0020. Если впоследствии цена коснется отметки 1,1090 до закрытия позиции, ее MFE в единицах R составит 2,0, даже если позиция в конечном итоге будет закрыта по стоп-лоссу на уровне безубыточности или с убытком. Это сделано намеренно: лестница выхода отвечает не на вопрос "Каков был итог закрытых позиций?", а на вопрос "Какой величины обычно достигали благоприятные отклонения?". Именно ответ на второй вопрос нужен для выбора уровня поэтапного закрытия.
Каждая закрытая позиция дает одно наблюдение: тикет, символ, направление, расстояние R, достигнутое MFE в единицах R, итоговый реализованный результат в единицах R и время закрытия. "SExcursionSample" в приведённом ниже коде — это именно эта запись.
//+------------------------------------------------------------------+ //| One closed-trade excursion sample | //+------------------------------------------------------------------+ struct SExcursionSample { ulong ticket; string symbol; long type; // POSITION_TYPE_BUY or POSITION_TYPE_SELL double r_distance; // price distance representing 1R at open double mfe_r; // best favorable excursion reached, in R double result_r; // final realized result, in R datetime close_time; };
CExcursionTracker — регистрация отклонения цены в реальном времени
Пока позиция открыта, "CExcursionTracker" выполняет одну задачу: отслеживает каждый тик и фиксирует наибольшее значение MFE в единицах R, достигнутое к текущему моменту. Метод "Register()" вызывается один раз, сразу после открытия позиции, и сохраняет цену входа, расстояние R, рассчитанное по первоначальному стоп-лоссу, и направление.
//+------------------------------------------------------------------+ //| Register | //| r_distance is derived from entry and initial stop and must be | //| a positive price distance representing one unit of risk (1R). | //+------------------------------------------------------------------+ bool CExcursionTracker::Register(ulong ticket, double entry_price, double stop_price, long type) { double dist = (type == POSITION_TYPE_BUY) ? (entry_price - stop_price) : (stop_price - entry_price); if(dist <= 0.0) return(false); if(FindLive(ticket) >= 0) return(true); if(m_live_count >= ArraySize(m_live)) ArrayResize(m_live, ArraySize(m_live) * 2); m_live[m_live_count].ticket = ticket; m_live[m_live_count].entry_price = entry_price; m_live[m_live_count].r_distance = dist; m_live[m_live_count].type = type; m_live[m_live_count].best_mfe_r = 0.0; m_live_count++; return(true); }
Важно сразу отклонять неположительные значения расстояния R. В противном случае стоп-лосс, размещенный не с той стороны от цены входа из-за некорректного ордера или ошибки в логике стратегии в другой части советника, незаметно исказит все последующие значения MFE в единицах R.
Метод "UpdateTick()" вызывается на каждом тике для каждого зарегистрированного тикета и сохраняет только пиковое значение: он не позволяет "best_mfe_r" уменьшаться при откате цены, поскольку лестнице выхода нужен максимум движения цены в пользу позиции, а не текущее значение этого движения.
//+------------------------------------------------------------------+ //| UpdateTick | //| Recomputes the running peak MFE in R for every registered ticket.| //+------------------------------------------------------------------+ void CExcursionTracker::UpdateTick(void) { for(int i = 0; i < m_live_count; i++) { ulong ticket = m_live[i].ticket; if(!PositionSelectByTicket(ticket)) continue; string symbol = PositionGetString(POSITION_SYMBOL); double bid = SymbolInfoDouble(symbol, SYMBOL_BID); double ask = SymbolInfoDouble(symbol, SYMBOL_ASK); double favorable_price = (m_live[i].type == POSITION_TYPE_BUY) ? bid : ask; double excursion = (m_live[i].type == POSITION_TYPE_BUY) ? (favorable_price - m_live[i].entry_price) : (m_live[i].entry_price - favorable_price); double mfe_r = excursion / m_live[i].r_distance; if(mfe_r > m_live[i].best_mfe_r) m_live[i].best_mfe_r = mfe_r; } }
Когда позиция полностью закрывается, метод "Finalize()" записывает итоговое наблюдение и удаляет тикет из реестра открытых позиций. Методу требуется аргумент с реализованным результатом в единицах R, поскольку сам трекер не обращается к истории сделок: результат рассчитывается в советнике и передается методу, как описано в разделе 8.
//+------------------------------------------------------------------+ //| Finalize | //| Called once a position has fully closed. Writes the sample and | //| removes the ticket from the live registry. | //+------------------------------------------------------------------+ bool CExcursionTracker::Finalize(ulong ticket, double result_r) { int idx = FindLive(ticket); if(idx < 0) return(false); SExcursionSample sample; sample.ticket = ticket; sample.symbol = _Symbol; sample.type = m_live[idx].type; sample.r_distance = m_live[idx].r_distance; sample.mfe_r = m_live[idx].best_mfe_r; sample.result_r = result_r; sample.close_time = TimeCurrent(); bool ok = AppendSample(sample); //--- compact: overwrite the closed slot with the last live entry m_live_count--; if(idx < m_live_count) m_live[idx] = m_live[m_live_count]; return(ok); }
Для долговременного хранения данных используется обычный CSV-файл, открытый в режиме "FILE READ | FILE WRITE | FILE CSV". Перед каждой записью указатель файла перемещается в конец, чтобы новые строки добавлялись без удаления уже записанных данных. Это позволяет сохранять наблюдения между перезапусками терминала.
//+------------------------------------------------------------------+ //| AppendSample | //| Appends one CSV row: ticket,symbol,type,r_distance,mfe_r, | //| result_r,close_time | //+------------------------------------------------------------------+ bool CExcursionTracker::AppendSample(const SExcursionSample &sample) { int handle = FileOpen(m_filename, FILE_READ | FILE_WRITE | FILE_CSV | FILE_ANSI, ','); if(handle == INVALID_HANDLE) return(false); FileSeek(handle, 0, SEEK_END); FileWrite(handle, sample.ticket, sample.symbol, (int)sample.type, sample.r_distance, sample.mfe_r, sample.result_r, TimeToString(sample.close_time, TIME_DATE | TIME_SECONDS)); FileClose(handle); return(true); }
Метод "LoadSamples()" выполняет обратную операцию: считывает все строки в массив "SExcursionSample" в порядке их записи, начиная с самой старой. Этот порядок необходим калибратору для работы с окном ретроспективного анализа, описанным в следующем разделе.
CExitLadderCalibrator — от наблюдений к ступеням
У калибратора одна задача: преобразовать массив исторических записей "SExcursionSample" в упорядоченный список ступеней, каждая из которых задается порогом в единицах R и долей первоначального объема позиции, подлежащей закрытию при достижении этого порога. Калибратор никогда не работает с открытой позицией напрямую. Такое разделение сделано намеренно: оно позволяет тестировать логику калибровки на обычных данных, что и используется в разделе 8.
В настройках задаются процентили распределения MFE в единицах R, которые станут порогами ступеней, число последних закрытых позиций, учитываемых при расчете, и минимальное число наблюдений, необходимое для начала использования результатов калибровки.
//+------------------------------------------------------------------+ //| Configure | //+------------------------------------------------------------------+ void CExitLadderCalibrator::Configure(const double &percentiles[], const double &close_fractions[], const int min_samples, const int lookback_trades) { ArrayResize(m_percentiles, ArraySize(percentiles)); ArrayResize(m_close_fractions, ArraySize(close_fractions)); ArrayCopy(m_percentiles, percentiles); ArrayCopy(m_close_fractions, close_fractions); m_min_samples = (min_samples > 0 ? min_samples : 30); m_lookback_trades = (lookback_trades > 0 ? lookback_trades : 200); }
Сама функция расчета процентиля использует линейную интерполяцию между двумя ближайшими значениями в упорядоченном ряду — тот же метод применяется для расчета процентилей в программах для работы с электронными таблицами. Это позволяет избежать резких скачков, которые возникали бы при использовании метода ближайшего ранга по мере поступления новых наблюдений.
//+------------------------------------------------------------------+ //| Percentile | //| Linear-interpolation percentile on an already-sorted array, | //| pct expressed 0-100. | //+------------------------------------------------------------------+ double CExitLadderCalibrator::Percentile(double &sorted_values[], double pct) const { int n = ArraySize(sorted_values); if(n == 0) return(0.0); if(n == 1) return(sorted_values[0]); double rank = (pct / 100.0) * (n - 1); int lower = (int)MathFloor(rank); int upper = (int)MathCeil(rank); if(lower < 0) lower = 0; if(upper > n - 1) upper = n - 1; if(lower == upper) return(sorted_values[lower]); double weight = rank - lower; return(sorted_values[lower] + weight * (sorted_values[upper] - sorted_values[lower])); }
Метод "Calibrate()" связывает все элементы воедино. Если число наблюдений меньше минимального, функция возвращает резервную лестницу выхода без изменений. Процентили, рассчитанные на основе очень небольшого количества сделок, не являются достоверными. Если число наблюдений превышает минимум, функция берет последние наблюдения в количестве, заданном параметром "lookback_trades", сортирует их значения MFE в единицах R и определяет порог каждой ступени по соответствующему заданному процентилю. Завершающий этап пузырьковой сортировки гарантирует, что возвращаемые ступени расположены по возрастанию R, даже если процентили заданы не по порядку: "CLadderExecutor" предполагает, что порядок уже соблюден, и не выполняет повторную сортировку полученных данных.
//+------------------------------------------------------------------+ //| Calibrate | //| Builds out_ladder[] from percentiles of mfe_r across the most | //| recent m_lookback_trades samples. Falls back to the configured | //| static ladder when fewer than m_min_samples samples are | //| available. Rungs are always returned in ascending R order. | //+------------------------------------------------------------------+ int CExitLadderCalibrator::Calibrate(const SExcursionSample &samples[], SLadderRung &out_ladder[]) const { int total = ArraySize(samples); if(total < m_min_samples) { int fb_count = ArraySize(m_fallback); ArrayResize(out_ladder, fb_count); for(int i = 0; i < fb_count; i++) out_ladder[i] = m_fallback[i]; return(fb_count); } //--- take the most recent m_lookback_trades samples (samples[] is assumed //--- ordered oldest to newest, as produced by CExcursionTracker::LoadSamples()) int start = (total > m_lookback_trades) ? (total - m_lookback_trades) : 0; int used = total - start; double mfe_values[]; ArrayResize(mfe_values, used); for(int i = 0; i < used; i++) mfe_values[i] = samples[start + i].mfe_r; SortAscending(mfe_values); int rung_count = ArraySize(m_percentiles); ArrayResize(out_ladder, rung_count); for(int i = 0; i < rung_count; i++) { out_ladder[i].r_level = Percentile(mfe_values, m_percentiles[i]); out_ladder[i].close_fraction = m_close_fractions[i]; } //--- enforce ascending R order regardless of configured percentile order — //--- a simple bubble pass since rung counts are always small (2-5 rungs) for(int i = 0; i < rung_count - 1; i++) { for(int j = 0; j < rung_count - i - 1; j++) { if(out_ladder[j].r_level > out_ladder[j + 1].r_level) { SLadderRung tmp = out_ladder[j]; out_ladder[j] = out_ladder[j + 1]; out_ladder[j + 1] = tmp; } } } return(rung_count); }
При стандартной конфигурации, используемой далее в советнике, с процентилями 30, 60 и 85 первая ступень находится на уровне, за которым у 70% недавних сделок еще продолжалось движение в благоприятную сторону, вторая — на уровне, за которым такое движение продолжалось у 40% сделок, а третья — вблизи предела, которого стратегия реально достигает. В этом и заключается суть: каждая ступень отражает вероятность продолжения движения, а не представляет собой произвольно выбранное круглое число.
CLadderExecutor — закрытие позиций на ступенях без повторного срабатывания
"CLadderExecutor" принимает лестницу выхода, сформированную калибратором, и применяет ее к открытым позициям. Для каждой управляемой позиции небольшой булев массив хранит сведения о том, какие ступени уже сработали. Это предотвращает повторное срабатывание ступени, когда на последовательных тиках цена колеблется вокруг ее порога.
//+------------------------------------------------------------------+ //| CLadderExecutor | //| Applies a calibrated SLadderRung[] ladder to open positions, | //| firing each rung at most once per ticket and optionally moving | //| the stop to breakeven after the first partial close. | //+------------------------------------------------------------------+ struct SManaged { ulong ticket; double entry_price; double r_distance; double original_volume; long type; bool rung_fired[8]; // supports up to 8 rungs bool breakeven_done; };
"OnTick()" перебирает все управляемые тикеты, вычисляет текущее продвижение цены в единицах R так же, как "CExcursionTracker", и сравнивает его с порогом каждой еще не сработавшей ступени. Объем для закрытия всегда рассчитывается как доля первоначального объема позиции, а не объема, оставшегося после предыдущих частичных закрытий: лестница выхода, основанная на оставшемся объеме, на каждой ступени оставляла бы уменьшающийся "хвост" вместо четкого и предсказуемого распределения объема, ради которого выполнялась калибровка.
//+------------------------------------------------------------------+ //| OnTick | //| For every managed ticket, computes current progress in R and | //| fires any rung whose threshold has been reached and has not | //| already fired for that ticket. | //+------------------------------------------------------------------+ void CLadderExecutor::OnTick(void) { for(int i = m_count - 1; i >= 0; i--) { ulong ticket = m_managed[i].ticket; if(!PositionSelectByTicket(ticket)) { Deregister(ticket); continue; } string symbol = PositionGetString(POSITION_SYMBOL); double current_volume = PositionGetDouble(POSITION_VOLUME); double bid = SymbolInfoDouble(symbol, SYMBOL_BID); double ask = SymbolInfoDouble(symbol, SYMBOL_ASK); double favorable_price = (m_managed[i].type == POSITION_TYPE_BUY) ? bid : ask; double excursion = (m_managed[i].type == POSITION_TYPE_BUY) ? (favorable_price - m_managed[i].entry_price) : (m_managed[i].entry_price - favorable_price); double progress_r = excursion / m_managed[i].r_distance; for(int r = 0; r < m_rung_count; r++) { if(m_managed[i].rung_fired[r]) continue; if(progress_r < m_ladder[r].r_level) continue; double close_volume = m_managed[i].original_volume * m_ladder[r].close_fraction; double vol_step = SymbolInfoDouble(symbol, SYMBOL_VOLUME_STEP); double vol_min = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN); close_volume = MathFloor(close_volume / vol_step) * vol_step; //--- never close more than what remains open, and skip a rung //--- outright if the rounded volume falls below the broker //--- minimum rather than risk closing the entire remainder early if(close_volume > current_volume) close_volume = current_volume; if(close_volume < vol_min) { m_managed[i].rung_fired[r] = true; // mark handled, nothing to close continue; } if(m_trade.PositionClosePartial(ticket, close_volume)) { m_managed[i].rung_fired[r] = true; PrintFormat("LadderExecutor: ticket=%I64u rung=%d R=%.2f closed=%.2f lots", ticket, r, m_ladder[r].r_level, close_volume); if(m_move_to_breakeven_after_first && !m_managed[i].breakeven_done) { double be_price = m_managed[i].entry_price; double tp = PositionGetDouble(POSITION_TP); if(m_trade.PositionModify(ticket, be_price, tp)) m_managed[i].breakeven_done = true; } } else { PrintFormat("LadderExecutor: partial close failed ticket=%I64u rung=%d retcode=%d", ticket, r, m_trade.ResultRetcode()); } } } }
Для небольших счетов важны как округление объема вниз до допустимого шага объема у брокера, так и его ограничение оставшимся открытым объемом: рассчитанная при калибровке доля в 34% от позиции объемом 0,02 лота после округления может оказаться объемом, который брокер сразу отклонит. Если помечать ступень, закрытие на которой невозможно, как обработанную, а не оставлять ее в ожидании, исполнитель не будет снова и снова пытаться выполнить частичное закрытие, которое никогда не удастся. Только после первого успешного закрытия на ступени может выполняться необязательный перенос стоп-лосса в безубыток: с помощью "CTrade::PositionModify()" стоп-лосс переносится на цену входа без изменения уровня тейк-профита.
SelfCalibratingLadderEA.mq5 — подключение компонентов
Задача советника — взять на себя функции, которые описанные выше классы намеренно не выполняют: открытие сделок, обнаружение полного закрытия позиции и вычисление реализованного результата в единицах R, необходимого методу "Finalize()". Демонстрационная логика входа основана на простом пересечении EMA со стоп-лоссом, рассчитанным по ATR. Она нужна лишь для открытия сделок, которыми будет управлять лестница выхода, а "CheckDemoEntry()" можно заменить любой реальной логикой формирования сигнала входа, не меняя трекер, калибратор или исполнитель.
После закрытия позиции получить сведения о ней через функции работы с позициями уже нельзя, поэтому реализованный результат необходимо восстановить по истории сделок. "ComputeResultR()" выбирает историю позиции по тикету, рассчитывает средневзвешенную по объему цену ее закрывающих сделок и выражает разницу между этой ценой и ценой входа в единицах R.//+------------------------------------------------------------------+ //| Computes the realized result of a fully closed position, in R | //+------------------------------------------------------------------+ double ComputeResultR(ulong ticket, double entry_price, double r_distance, long type) { if(!HistorySelectByPosition(ticket)) return(0.0); double sum_volume_price = 0.0; double sum_volume = 0.0; int total = HistoryDealsTotal(); for(int i = 0; i < total; i++) { ulong deal = HistoryDealGetTicket(i); if(deal == 0) continue; long entry_flag = HistoryDealGetInteger(deal, DEAL_ENTRY); if(entry_flag != DEAL_ENTRY_OUT && entry_flag != DEAL_ENTRY_OUT_BY) continue; double vol = HistoryDealGetDouble(deal, DEAL_VOLUME); double price = HistoryDealGetDouble(deal, DEAL_PRICE); sum_volume_price += vol * price; sum_volume += vol; } if(sum_volume <= 0.0) return(0.0); double avg_close = sum_volume_price / sum_volume; double move = (type == POSITION_TYPE_BUY) ? (avg_close - entry_price) : (entry_price - avg_close); return(move / r_distance); }
Учет всех сделок "DEAL_ENTRY_OUT", а не одной цены закрытия, здесь особенно важен, поскольку сама лестница выхода приводит к нескольким частичным закрытиям: позиция, поэтапно закрываемая на трех ступенях, дает три отдельные закрывающие сделки, и только средневзвешенная по объему цена всех этих сделок позволяет получить правильный реализованный результат.
Для выявления закрытий советник сравнивает наборы открытых позиций на последовательных тиках. Встроенный событийный механизм для этого — "OnTradeTransaction()". Функция "SweepClosedPositions()" хранит статический массив ранее известных тикетов и завершает обработку каждого отслеживаемого тикета, которого больше нет в текущем наборе.
//+------------------------------------------------------------------+ //| Sweeps tracker-registered tickets for closures. When a tracked | //| ticket is no longer an open position, its result is computed | //| from history and the sample is finalized to the persistent file. | //+------------------------------------------------------------------+ void SweepClosedPositions(void) { static ulong known_tickets[]; ulong current_tickets[]; int total = PositionsTotal(); ArrayResize(current_tickets, total); for(int i = 0; i < total; i++) current_tickets[i] = PositionGetTicket(i); for(int i = 0; i < ArraySize(known_tickets); i++) { ulong ticket = known_tickets[i]; bool still_open = false; for(int j = 0; j < ArraySize(current_tickets); j++) if(current_tickets[j] == ticket) { still_open = true; break; } if(!still_open && g_tracker.IsRegistered(ticket)) { double entry = 0.0, r_distance = 0.0; long type = 0; if(g_tracker.GetLiveInfo(ticket, entry, r_distance, type)) { double result_r = ComputeResultR(ticket, entry, r_distance, type); g_tracker.Finalize(ticket, result_r); g_samples_since_recalibration++; if(g_samples_since_recalibration >= InpRecalibrateEvery) { Recalibrate(); g_samples_since_recalibration = 0; } } } } ArrayResize(known_tickets, ArraySize(current_tickets)); ArrayCopy(known_tickets, current_tickets); }
Функция "Recalibrate()" заново загружает весь файл наблюдений, передает его содержимое модулю калибровки и устанавливает полученную лестницу выхода в модуле выполнения. Вызов этой функции через каждые "InpRecalibrateEvery" закрытых позиций — по умолчанию через пять, — а не после каждого закрытия не дает лестнице выхода меняться настолько часто, чтобы характер поэтапного закрытия позиций становился непоследовательным от одной позиции к другой.
Проверка с помощью TestExcursionLadder.mq5
Поскольку функция "CExitLadderCalibrator::Calibrate()" принимает обычный массив "SExcursionSample" и не затрагивает открытые позиции, ее можно тестировать напрямую из скрипта на синтетических данных — для самой логики калибровки не нужны ни график, ни открытые позиции, ни подключение к брокеру. В скрипте "TestExcursionLadder.mq5" на этой основе реализовано шесть проверок.
В тесте процентилей используется набор данных, специально подобранный для расчета вручную: значения MFE в единицах R от 1,0 до 41,0 с шагом 1. Поэтому 0-й, 50-й и 100-й процентили соответствуют значениям, которые можно проверить арифметически, а не принимать на веру: минимуму, медиане и максимуму соответственно.
//+------------------------------------------------------------------+ //| TestPercentileCalibration | //| Uses a hand-computable data set: mfe_r = 1..41 (41 values), so | //| percentiles land on values that are easy to verify by hand. | //+------------------------------------------------------------------+ void TestPercentileCalibration(void) { Print("--- Percentile calibration tests ---"); CExitLadderCalibrator calib; double pct[] = {0.0, 50.0, 100.0}; double frac[] = {0.34, 0.33, 0.33}; calib.Configure(pct, frac, 30, 200); SLadderRung fallback[3]; fallback[0].r_level = 1.0; fallback[0].close_fraction = 0.34; fallback[1].r_level = 2.0; fallback[1].close_fraction = 0.33; fallback[2].r_level = 3.0; fallback[2].close_fraction = 0.33; calib.SetFallback(fallback); double mfe_values[41]; for(int i = 0; i < 41; i++) mfe_values[i] = 1.0 + i; // 1.0, 2.0 ... 41.0 -- above the 30 minimum SExcursionSample samples[]; BuildSamples(mfe_values, samples); SLadderRung out_ladder[]; int rungs = calib.Calibrate(samples, out_ladder); ASSERT(rungs == 3, "percentile: returns 3 rungs when at/above min_samples"); //--- 0th percentile = minimum = 1.0; 100th percentile = maximum = 41.0; //--- 50th percentile of 41 sorted values = the 21st value = 21.0 ASSERT_CLOSE(out_ladder[0].r_level, 1.0, 0.0001, "percentile: 0th percentile equals sample minimum (1.0R)"); ASSERT_CLOSE(out_ladder[1].r_level, 21.0, 0.0001, "percentile: 50th percentile equals the median (21.0R)"); ASSERT_CLOSE(out_ladder[2].r_level, 41.0, 0.0001, "percentile: 100th percentile equals sample maximum (41.0R)"); } //+------------------------------------------------------------------+ //| TestLookbackWindow | //| Confirms only the most recent m_lookback_trades samples are used | //+------------------------------------------------------------------+ void TestLookbackWindow(void) { Print("--- Lookback window tests ---"); CExitLadderCalibrator calib; double pct[] = {50.0}; double frac[] = {1.0}; calib.Configure(pct, frac, 30, 20); // lookback = 20 most recent samples only SLadderRung fallback[1]; fallback[0].r_level = 1.0; fallback[0].close_fraction = 1.0; calib.SetFallback(fallback); //--- first 30 samples are all 100.0R (a stale, old regime), the //--- last 20 samples are all 1.0R (the current regime) double mfe_values[50]; for(int i = 0; i < 30; i++) mfe_values[i] = 100.0; for(int i = 30; i < 50; i++) mfe_values[i] = 1.0; SExcursionSample samples[]; BuildSamples(mfe_values, samples); SLadderRung out_ladder[]; calib.Calibrate(samples, out_ladder); ASSERT_CLOSE(out_ladder[0].r_level, 1.0, 0.0001, "lookback: median of the most recent 20 samples ignores the stale 100R regime"); }В тесте окна ретроспективного анализа используются два режима: 100R для первых 30 наблюдений и 1R для последних 20. При окне ретроспективного анализа в 20 наблюдений откалиброванная ступень должна отражать только последний режим. Это позволяет выявить ошибки, при которых окно ретроспективного анализа игнорируется и используется вся история. В тесте записи и последующего чтения данные записываются через "CExcursionTracker::AppendSample()" и считываются обратно через "LoadSamples()". При этом используются штатные механизмы работы класса с файлами, а не специально написанная замена, что подтверждает работоспособность слоя долговременного хранения данных, а не только правильность арифметических вычислений. Для всех тестов в файле используется единый формат записи результатов PASS/FAIL в журнал с выводом итогового числа проверок, чтобы модератор или читатель мог запустить скрипт и увидеть результат без пошагового выполнения кода.
Примечания по тестированию на исторических данных
Запустите советник "SelfCalibratingLadderEA.mq5" в Тестере стратегий на валютной паре с достаточно длительной историей — как минимум за полный год, — поскольку откалиброванная лестница выхода начинает использоваться только после закрытия "InpMinSamples" позиций, а при коротком периоде тестирования система может так и не перейти с резервной лестницы выхода на откалиброванную. Режим "Каждый тик на основе реальных тиков" здесь оправдывает дополнительное время тестирования: функция "CExcursionTracker::UpdateTick()" использует движение цены внутри бара для определения истинного пикового значения MFE, а режимы, использующие только OHLC, занижали бы отклонение на любом баре, где наиболее благоприятная цена достигалась внутри бара, а не при его закрытии.
Чтобы увидеть калибровку в действии, полезно последовательно выполнить два теста на одном и том же диапазоне: короткий, в котором число закрытых позиций не достигает "InpMinSamples", и более длинный, в котором этот порог достигается. При каждом вызове "Recalibrate()" в журнал выводятся уровни ступеней. Сравнение ступеней резервной лестницы выхода, выведенных в начале, с откалиброванными ступенями, выведенными позднее, позволяет увидеть переход от предполагаемых уровней к измеренным прямо в журнале, без отдельного отчета.
Проще всего тестировать на неттинговом счете: на хеджинговом счете может быть несколько позиций по одному символу, а проверка в "CheckDemoEntry()" текущего советника, блокирующая вход при "PositionsTotal() > 0", рассчитана на работу с одной позицией.
Известные ограничения
Отдельный файл наблюдений для каждого символа задается именем файла, но в "CExcursionTracker" это никак не контролируется: при запуске одного и того же экземпляра советника для нескольких символов с именем файла по умолчанию их распределения MFE в единицах R будут смешиваться в одном файле, что сводит на нет смысл калибровки под конкретную стратегию. При работе с несколькими символами задайте для каждого символа собственное значение параметра "InpSampleFile".
Закрытие позиций выявляется функцией "SweepClosedPositions()", которая опрашивает набор открытых позиций один раз на каждом тике, а не обрабатывает события "OnTradeTransaction()" напрямую. Этот подход надежно работает, когда один советник управляет собственными сделками, но при высокой нагрузке в терминале с несколькими советниками или при ручном закрытии позиции между тиками лучше перенести обнаружение закрытия в обработчик торговых транзакций — это естественный следующий шаг при адаптации кода для реальной эксплуатации.
Логика открытия позиций в демо-версии по замыслу предусматривает открытие одной позиции за раз — исключительно для того, чтобы в статье основное внимание уделялось управлению закрытием позиций. Для адаптации трекера и исполнителя к стратегии с несколькими позициями не требуется изменять ни один из этих классов — оба уже используют номер тикета как ключ. Однако проверку количества позиций в самом советнике и учет в "SweepClosedPositions()" потребуется доработать для отслеживания нескольких тикетов одновременно, а не исходить из наличия не более одного.
Наконец, если характер стратегии со временем меняется (из-за обновления параметров, смены режима волатильности торгуемого инструмента или изменения рыночных условий), в ее окне ретроспективного анализа будут оставаться устаревшие наблюдения отклонений, пока не закроется достаточно новых позиций, чтобы снизить долю этих наблюдений. Окно ретроспективного анализа ограничивает влияние устаревших наблюдений, но не устраняет его полностью. Даже значительная смена режима отражается в статистике постепенно, а не сразу.
Заключение
В результате у вас должен получиться рабочий, пригодный для тестирования шаблон, позволяющий заменить жестко заданные предположения об уровнях поэтапного закрытия статистикой, полученной из истории торговли на самом счете. Три основных компонента — "CExcursionTracker", "CExitLadderCalibrator" и "CLadderExecutor" — реализуют четкий конвейер обработки данных: измеряют MFE в единицах R для каждой позиции, обеспечивают долговременное хранение наблюдений, рассчитывают уровни лестницы по процентилям выборки за настраиваемое окно после накопления минимального числа наблюдений (а до этого используют резервную лестницу выхода) и выполняют частичные закрытия на откалиброванных ступенях, предотвращая двойной учет и соблюдая ограничения брокера по объему. Ступени, рассчитанные на основе распределения, выражаются в R, поэтому один и тот же подход применим к разным символам и расстояниям до стоп-лосса.
В качестве практической реализации в статье представлены демонстрационный советник, объединяющий эти компоненты, и тестовый скрипт, который проверяет расчет процентилей, работу окна ретроспективного анализа и долговременное хранение данных. Рекомендации по интеграции и ограничения приведены не случайно: храните наблюдения по каждому символу в отдельном файле, учитывайте постепенную адаптацию после смены режима (окно ретроспективного анализа ограничивает влияние устаревших данных, но не устраняет его) и предпочитайте тестирование на исторических данных с точностью до тика, чтобы фиксировать фактическое MFE внутри бара. Иными словами, этот подход превращает произвольное правило поэтапного закрытия позиции в измеримую, самонастраивающуюся систему выхода с модульной архитектурой, которую можно проверить и адаптировать для советников, используемых в реальной торговле.
Программы, использованные в статье:
| # | Имя | Тип | Описание |
|---|---|---|---|
| 1 | SelfCalibratingLadderEA.mq5 | Демо-версия советника | Советник, объединяющий демонстрационную логику входа с трекером, калибратором и исполнителем. |
| 2 | ExcursionTracker.mqh | Включаемый файл | Отслеживание MFE в единицах R в реальном времени и долговременное хранение данных о наблюдениях в CSV. |
| 3 | ExitLadderCalibrator.mqh | Включаемый файл | Расчет лестницы выхода на основе процентилей с использованием резервной лестницы выхода при необходимости. |
| 4 | LadderExecutor.mqh | Включаемый файл | Выполнение частичного закрытия по откалиброванной лестнице выхода с защитными проверками. |
| 5 | TestExcursionLadder.mq5 | Скрипт | Скрипт для проверки расчетов калибровки и долговременного хранения данных о наблюдениях. |
| 6 | SelfCalibratingLadder.zip | Архив в формате ZIP | Архив, содержащий все файлы, которые можно распаковать в каталог установки терминала, после чего все файлы окажутся в нужных местах. |
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/24466
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.