English
preview
Архитектура машинного обучения для MetaTrader 5 (Часть 14): Моделирование транзакционных издержек для разметки методом тройного барьера в MQL5

Архитектура машинного обучения для MetaTrader 5 (Часть 14): Моделирование транзакционных издержек для разметки методом тройного барьера в MQL5

MetaTrader 5Торговые системы |
94 0
Patrick Murimi Njoroge
Patrick Murimi Njoroge

Оглавление

  1. Введение
  2. Проблема жёстко заданных издержек
  3. Три компонента издержек и способы их измерения
  4. MQL5: скрипт сбора торговых издержек
  5. Python: модель торговых издержек
  6. Интеграция с пайплайном разметки и расчёта P&L
  7. Практические рекомендации
  8. Заключение
  9. Прилагаемые файлы


Введение

Представьте, что вы проектируете мост, но не учитываете в расчётах собственный вес конструкции: такая конструкция не выдержит нагрузки, хотя причина ошибки была очевидна с самого начала. Похожая практическая ошибка встречается во многих процессах разметки тройным барьером. Исследователи часто задают min_ret произвольной константой (0,5–1%) или опираются на устаревшие допущения о спреде и комиссии, а затем считают каждое историческое движение цены выше этого порога настоящим сигналом. Недостающий шаг — строгий воспроизводимый способ ответить на вопрос: каковы фактические издержки полного цикла сделки для этого символа у этого брокера с учётом типичного периода удержания позиции и часов входа стратегии?

Конвейер транзакционных издержек — поток данных

Рисунок 1. Двухэтапная пайплайн: от сбора брокерских данных до порога разметки

  • Этап 1 (MQL5): TransactionCostCollector.mq5 запускается на любом графике, считывает историю спреда через CopySpread(), считывает ставки свопа и свойства символа через SymbolInfoDouble() и записывает структурированный CSV в каталог терминала Files.
  • Этап 2 (Python): load_cost_model() читает CSV и создаёт TransactionCostModel с методами min_ret_for_symbol() для пайплайна разметки и summary() для проверки издержек перед выбором порога.

В этой статье показано, как получить такую оценку. Описан воспроизводимый двухэтапный пайплайн: (1) компактный скрипт MQL5, который считывает историю спреда брокера, ставки свопа и свойства символа и записывает структурированный CSV; (2) модель Python TransactionCostModel, которая загружает CSV, приводит все компоненты к единой мере — относительной доходности — и предоставляет min_ret_for_symbol() вместе с диагностическими процедурами. На выходе получаются конкретные результаты: CSV с процентилями спреда и средними значениями спреда по часам, объект модели с разбивкой издержек по сделкам, min_ret, откалиброванный по издержкам и готовый к передаче в get_events(), а также те же параметры для расчёта P&L по каждой сделке.

Примечание: min_ret здесь — это порог разметки для построения обучающих данных, а не целевой уровень фиксации прибыли, который советник выставляет в торговом терминале.


Три компонента издержек и способы их измерения

Спред — единственный компонент, который можно точно измерять в MQL5 в реальном времени. Это разница между Ask и Bid в момент предполагаемого входа. Для построения меток важен не мгновенный спред на одном баре, а распределение спреда по часам и торговым сессиям, в которые стратегия действительно торгует. Стратегия, работающая по EURUSD в лондонскую сессию, работает в иных условиях спреда, чем стратегия, торгующая в азиатскую сессию или вблизи выхода новостей.

Распределение спреда EURUSD по часам суток

Рисунок 2. Распределение спреда по сессиям и часам суток (время брокера)

  • Панель (a): средний спред в пипсах по часам на всей истории спреда. Пересечение сессий Лондона и Нью-Йорка (часы 12–17) показывает самые узкие спреды; часы вне активных сессий дают заметно более широкие спреды, которые оценка издержек по среднему значению систематически занизила бы.

Функция MQL5 CopySpread() даёт доступ к полной истории значений спреда на уровне баров — именно такие данные нужны, чтобы описать это распределение.

Проскальзывание — это разница между ценой, по которой вы планировали войти, и ценой фактического исполнения. Её нельзя измерить по историческим данным баров: она измеряется только по журналам реального или демо-исполнения. Для построения меток есть два практических варианта: использовать опубликованную брокером статистику проскальзывания, если она доступна, или рассчитать эмпирически подобранную константу из журнала демо-сделок. Консервативное стартовое допущение для рыночного ордера по основной валютной паре при нормальной ликвидности — 0,3–0,7 пипса. Для индексов, металлов и экзотических пар проскальзывание выше и менее стабильно. Модель Python принимает его как параметр, поэтому его можно обновить при появлении более качественных данных.

Своп — это стоимость переноса позиции через ночь или начисление, применяемое при удержании позиции после ежедневного ролловера. Это самый часто игнорируемый компонент и именно он систематически искажает метки для стратегий с многодневным удержанием. Стратегия, которая по одной только цене размечена как прибыльная, после учёта свопов на двухнедельном удержании может оказаться убыточной. MQL5 напрямую предоставляет ставки свопа через SymbolInfoDouble() с SYMBOL_SWAP_LONG и SYMBOL_SWAP_SHORT. Режим свопа — выражена ли ставка в пунктах, валюте счёта или в процентах годовых — зависит от брокера и инструмента; модель Python обрабатывает все три случая.

Есть ещё один компонент, который стоит отметить, хотя он не относится к издержкам микроструктуры рынка: день тройного свопа. Большинство брокеров начисляют тройную ночную ставку свопа в определённый день недели — для Forex обычно в среду — чтобы компенсировать выходные. Для стратегий со средним удержанием от двух до четырёх дней попадает ли позиция на этот день не случайно: оно зависит от времени входа. Модель явно учитывает это при периоде удержания три дня и более.


MQL5: скрипт сбора торговых издержек

Это автономный скрипт MQL5 — не советник и не индикатор, — который компилируется один раз и запускается на любом графике целевого инструмента. У него нет цикла событий, а вывод в терминале ограничивается краткой сводкой на вкладке «Эксперты». Скрипт выполняет один последовательный проход, собирает необходимые брокерские данные, записывает структурированный CSV и завершает работу. CSV — единственный сохраняемый выходной файл.

Сохраните скрипт в Scripts/TransactionCostCollector.mq5 и скомпилируйте его в MetaEditor. Чтобы запустить скрипт, откройте любой график целевого символа, щёлкните скрипт правой кнопкой мыши в окне «Навигатор» и выберите Присоединить к графику. В диалоговом окне параметров скрипта доступны два параметра: InpBars (количество баров истории спреда для выборки, по умолчанию 50 000) и InpOutputFile (необязательное имя выходного файла; пустое значение означает <symbol>_costs.csv). Нажмите OK. Выходной файл появится в <terminal_data_path>/MQL5/Files/<symbol>_costs.csv. Скопируйте этот файл в каталог данных проекта Python и передайте путь в load_cost_model().

Как работает скрипт

Тело скрипта организовано в шесть последовательных разделов внутри OnStart(); каждый раздел обрабатывается отдельной вспомогательной функцией. Точка входа выглядит как понятная последовательность вызовов; вся логика измерений и форматирования вынесена во вспомогательные функции. CSV записывается в едином пятиколоночном формате — section, key, value, unit, note — через единую повторно используемую функцию записи строк. Шесть разделов формируют четыре основных раздела CSV (symbol_properties, swap, commission, spread_summary) и затем по одной строке на каждый активный час в spread_by_hour.

Раздел 1 — свойства символа считывает все статические метаданные инструмента: размер пункта, размер и стоимость тика, размер контракта, коэффициент пункта (10 для пятизначных котировок, 1 для четырёхзначных), минимальный лот и тройку валют base/profit/margin. Модель Python использует эти значения, чтобы переводить спред из пунктов в ценовые единицы и рассчитывать номинал сделки для масштабирования комиссии.

Раздел 2 — ставки свопа считывает SYMBOL_SWAP_LONG, SYMBOL_SWAP_SHORT, SYMBOL_SWAP_MODE и SYMBOL_SWAP_ROLLOVER3DAYS. Вместо преобразования к общей единице измерения на этом этапе скрипт записывает исходные значения и строку режима. Модель Python обрабатывает все три варианта преобразования.

Раздел 3 — диагностика комиссии считывает ACCOUNT_COMMISSION_BLOCKED и записывает его вместе со встроенным примечанием, объясняющим процедуру контрольной сделки для получения фактической ставки на лот. Когда открытых позиций нет, это значение равно нулю и не может напрямую использоваться как ставка на лот; см. примечание о комиссии в разделе «Практические рекомендации».

Раздел 4 — распределение спреда вызывает CopySpread() для всей запрошенной истории и рассчитывает сводную статистику: среднее, стандартное отклонение и процентили p25/p50/p75/p90/p95/p99 — как в пунктах терминала, так и в пипсах.

Раздел 5 — спред с разбивкой по сессиям сопоставляет массив спреда с временными метками баров через CopyTime() и рассчитывает средний спред для каждого часа суток (0–23, время брокера). Эти почасовые значения заполняют раздел spread_by_hour CSV и используются функцией session_adjusted_spread_pips() в модели Python для оценки издержек с учётом сессии.

Листинг ниже начинается с заголовка файла, параметров скрипта и предварительных объявлений. Все шесть вспомогательных функций объявлены до OnStart(), поэтому точка входа выглядит как последовательность именованных шагов, а не как неструктурированный блок.

//+------------------------------------------------------------------+
//| TransactionCostCollector.mq5                                     |
//|                                                                  |
//| Collects transaction cost data for a given symbol: spread        |
//| distribution, swap rates, and commission diagnostics.            |
//|                                                                  |
//| Run as a Script (not EA) on any chart of the target symbol.      |
//| Output: <terminal_data_path>/MQL5/Files/<symbol>_costs.csv       |
//+------------------------------------------------------------------+
#property script_show_inputs

//--- Input parameters
input int    InpBars       = 50000;  // Bars of spread history to sample
input string InpOutputFile = "";     // Override filename (blank = <symbol>_costs.csv)

#define CSV_SEP ","

//--- Forward declarations
void WriteCsvRow(int handle, string section, string key,
                 string value, string unit, string note);

void CollectSymbolProperties(int fh, string symbol, int digits,
                             double point, double pip_factor,
                             double tick_size, double tick_value,
                             double contract_sz, double min_lot,
                             string cur_base, string cur_profit,
                             string cur_margin);

void CollectSwapInfo(int fh, string symbol, double swap_long,
                     double swap_short, int swap_mode,
                     int swap_3day, string swap_mode_str);

void CollectCommissionInfo(int fh, double commission_blocked);

void CollectSpreadDistribution(int fh, string symbol,
                               int digits, double point, double pip_factor,
                               const int &spread_arr[], int n,
                               int bars_sampled);

void CollectSessionSpread(int fh, string symbol,
                          int digits, double point, double pip_factor,
                          const int &spread_arr[], int n);

OnStart() один раз в начале определяет все свойства символа и ставки свопа, затем передаёт каждый шаг измерения вспомогательной функции. Массив спреда копируется после записи статических свойств, чтобы CopySpread() получила окончательное количество баров после всех проверок.

void OnStart()
  {
   string symbol = Symbol();
   int    digits = (int)SymbolInfoInteger(symbol, SYMBOL_DIGITS);

   //--- Core symbol properties
   double point       = SymbolInfoDouble(symbol, SYMBOL_POINT);
   double tick_size   = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_SIZE);
   double tick_value  = SymbolInfoDouble(symbol, SYMBOL_TRADE_TICK_VALUE);
   double contract_sz = SymbolInfoDouble(symbol, SYMBOL_TRADE_CONTRACT_SIZE);
   double min_lot     = SymbolInfoDouble(symbol, SYMBOL_VOLUME_MIN);
   string cur_base    = SymbolInfoString(symbol, SYMBOL_CURRENCY_BASE);
   string cur_profit  = SymbolInfoString(symbol, SYMBOL_CURRENCY_PROFIT);
   string cur_margin  = SymbolInfoString(symbol, SYMBOL_CURRENCY_MARGIN);
   double pip_factor  = (digits == 3 || digits == 5) ? 10.0 : 1.0;

   //--- Swap rates
   double swap_long  = SymbolInfoDouble(symbol, SYMBOL_SWAP_LONG);
   double swap_short = SymbolInfoDouble(symbol, SYMBOL_SWAP_SHORT);
   int    swap_mode  = (int)SymbolInfoInteger(symbol, SYMBOL_SWAP_MODE);
   int    swap_3day  = (int)SymbolInfoInteger(symbol, SYMBOL_SWAP_ROLLOVER3DAYS);

   string swap_mode_str = "";
   switch(swap_mode)
     {
      case SYMBOL_SWAP_MODE_POINTS:           swap_mode_str = "points";        break;
      case SYMBOL_SWAP_MODE_CURRENCY_SYMBOL:  swap_mode_str = "currency";      break;
      case SYMBOL_SWAP_MODE_INTEREST_OPEN:    swap_mode_str = "interest_open"; break;
      case SYMBOL_SWAP_MODE_INTEREST_CURRENT: swap_mode_str = "interest_curr"; break;
      case SYMBOL_SWAP_MODE_CURRENCY_MARGIN:  swap_mode_str = "currency_mrgn"; break;
      case SYMBOL_SWAP_MODE_CURRENCY_DEPOSIT: swap_mode_str = "currency_dep";  break;
      default:                                swap_mode_str = "unknown";       break;
     }

   double commission_blocked = AccountInfoDouble(ACCOUNT_COMMISSION_BLOCKED);

   //--- Open CSV
   string fname = InpOutputFile != "" ? InpOutputFile
                                       : symbol + "_costs.csv";
   int fh = FileOpen(fname, FILE_WRITE | FILE_CSV | FILE_ANSI, ',');
   if(fh == INVALID_HANDLE)
     {
      Print("ERROR: Cannot open output file: ", fname);
      return;
     }
   FileWrite(fh, "section" + CSV_SEP + "key" + CSV_SEP + "value"
                + CSV_SEP + "unit" + CSV_SEP + "note");

   //--- Static data sections
   CollectSymbolProperties(fh, symbol, digits, point, pip_factor,
                           tick_size, tick_value, contract_sz, min_lot,
                           cur_base, cur_profit, cur_margin);
   CollectSwapInfo(fh, symbol, swap_long, swap_short, swap_mode,
                   swap_3day, swap_mode_str);
   CollectCommissionInfo(fh, commission_blocked);

   //--- Spread history
   int spread_arr[];
   int bars_copied = CopySpread(symbol, PERIOD_CURRENT, 0, InpBars, spread_arr);
   if(bars_copied <= 0)
     {
      Print("ERROR: CopySpread failed. Bars available: ", bars_copied);
      FileClose(fh);
      return;
     }
   CollectSpreadDistribution(fh, symbol, digits, point, pip_factor,
                             spread_arr, bars_copied, bars_copied);
   CollectSessionSpread(fh, symbol, digits, point, pip_factor,
                        spread_arr, bars_copied);
   FileClose(fh);

   Print("TransactionCostCollector: wrote ", fname);
   Print("  Bars sampled: ", bars_copied);
  }

Четыре вспомогательные функции ниже обрабатывают разделы статических данных. WriteCsvRow() — единственная точка, через которую проходит весь вывод CSV; чтобы изменить разделитель полей или правила кавычек, достаточно отредактировать только эту функцию. Остальные три вспомогательные функции последовательно вызывают её для своих строк.

//+------------------------------------------------------------------+
//| Write a single CSV row in the standard 5-column format           |
//+------------------------------------------------------------------+
void WriteCsvRow(int handle, string section, string key,
                 string value, string unit, string note)
  {
   FileWrite(handle,
             section + CSV_SEP + key   + CSV_SEP +
             value   + CSV_SEP + unit  + CSV_SEP + note);
  }

//+------------------------------------------------------------------+
//| Symbol properties section                                        |
//+------------------------------------------------------------------+
void CollectSymbolProperties(int fh, string symbol, int digits,
                             double point, double pip_factor,
                             double tick_size, double tick_value,
                             double contract_sz, double min_lot,
                             string cur_base, string cur_profit,
                             string cur_margin)
  {
   string sec = "symbol_properties";
   WriteCsvRow(fh, sec, "symbol",          symbol,                          "—", "");
   WriteCsvRow(fh, sec, "digits",          (string)digits,                  "—", "");
   WriteCsvRow(fh, sec, "point",           DoubleToString(point, 10),       "price", "");
   WriteCsvRow(fh, sec, "pip_factor",      DoubleToString(pip_factor, 1),   "points_per_pip",
               "10 for 5-digit; 1 for 4-digit");
   WriteCsvRow(fh, sec, "tick_size",       DoubleToString(tick_size, 10),   "price", "");
   WriteCsvRow(fh, sec, "tick_value",      DoubleToString(tick_value, 6),   "account_currency_per_lot", "");
   WriteCsvRow(fh, sec, "contract_size",   DoubleToString(contract_sz, 2),  "units", "");
   WriteCsvRow(fh, sec, "min_lot",         DoubleToString(min_lot, 2),      "lots", "");
   WriteCsvRow(fh, sec, "currency_base",   cur_base,   "—", "");
   WriteCsvRow(fh, sec, "currency_profit", cur_profit, "—", "");
   WriteCsvRow(fh, sec, "currency_margin", cur_margin, "—", "");
  }

//+------------------------------------------------------------------+
//| Swap rates section                                               |
//+------------------------------------------------------------------+
void CollectSwapInfo(int fh, string symbol, double swap_long,
                     double swap_short, int swap_mode,
                     int swap_3day, string swap_mode_str)
  {
   string sec = "swap";
   WriteCsvRow(fh, sec, "swap_long",  DoubleToString(swap_long, 6),
               swap_mode_str, "per night; negative = debit from account");
   WriteCsvRow(fh, sec, "swap_short", DoubleToString(swap_short, 6),
               swap_mode_str, "per night; negative = debit from account");
   WriteCsvRow(fh, sec, "swap_mode",  swap_mode_str,
               "—", "see SYMBOL_SWAP_MODE enum");
   WriteCsvRow(fh, sec, "swap_3day",  (string)swap_3day, "weekday",
               "0=Sun … 6=Sat; triple swap charged on this day");
  }

//+------------------------------------------------------------------+
//| Commission diagnostic section                                    |
//+------------------------------------------------------------------+
void CollectCommissionInfo(int fh, double commission_blocked)
  {
   string sec = "commission";
   WriteCsvRow(fh, sec, "commission_blocked_now",
               DoubleToString(commission_blocked, 4),
               "account_currency",
               "ACCOUNT_COMMISSION_BLOCKED; see derivation note");
   WriteCsvRow(fh, sec, "derivation_note",
               "Open a reference trade of 1.0 lot on this symbol; "
               "read ACCOUNT_COMMISSION_BLOCKED; that value is the "
               "per-side per-lot rate", "—", "");
  }

Обе функции сбора спреда работают с одним и тем же массивом спреда, возвращённым CopySpread(). CollectSpreadDistribution() сортирует копию для извлечения процентилей; CollectSessionSpread() сопоставляет тот же массив с временными метками баров через CopyTime() для расчёта почасовых средних. Передача массива по const ссылке в обе функции позволяет избежать двукратного копирования 50 000 целых чисел.

//+------------------------------------------------------------------+
//| Spread distribution: percentiles and summary statistics          |
//+------------------------------------------------------------------+
void CollectSpreadDistribution(int fh, string symbol,
                               int digits, double point, double pip_factor,
                               const int &spread_arr[], int n,
                               int bars_sampled)
  {
   double sum = 0.0, sum_sq = 0.0;
   int    min_sp = INT_MAX, max_sp = 0;
   for(int i = 0; i < n; i++)
     {
      int s = spread_arr[i];
      sum    += s;
      sum_sq += (double)s * s;
      if(s < min_sp) min_sp = s;
      if(s > max_sp) max_sp = s;
     }
   double mean_sp = sum / n;
   double var_sp  = (sum_sq / n) - (mean_sp * mean_sp);
   double std_sp  = MathSqrt(MathMax(var_sp, 0.0));

   int sorted[];
   ArrayCopy(sorted, spread_arr, 0, 0, n);
   ArraySort(sorted);

   double p25 = sorted[(int)(n * 0.25)];
   double p50 = sorted[(int)(n * 0.50)];
   double p75 = sorted[(int)(n * 0.75)];
   double p90 = sorted[(int)(n * 0.90)];
   double p95 = sorted[(int)(n * 0.95)];
   double p99 = sorted[(int)(n * 0.99)];

   string sec = "spread_summary";
   WriteCsvRow(fh, sec, "bars_sampled", (string)bars_sampled, "bars",    "");
   WriteCsvRow(fh, sec, "mean_points",  DoubleToString(mean_sp, 4), "points", "");
   WriteCsvRow(fh, sec, "std_points",   DoubleToString(std_sp, 4),  "points", "");
   WriteCsvRow(fh, sec, "p25_points",   DoubleToString(p25, 2),     "points", "");
   WriteCsvRow(fh, sec, "p50_points",   DoubleToString(p50, 2),     "points", "");
   WriteCsvRow(fh, sec, "p75_points",   DoubleToString(p75, 2),     "points", "");
   WriteCsvRow(fh, sec, "p90_points",   DoubleToString(p90, 2),     "points", "");
   WriteCsvRow(fh, sec, "p95_points",   DoubleToString(p95, 2),     "points", "");
   WriteCsvRow(fh, sec, "p99_points",   DoubleToString(p99, 2),     "points", "");
   WriteCsvRow(fh, sec, "mean_pips",    DoubleToString(mean_sp / pip_factor, 4), "pips", "");
   WriteCsvRow(fh, sec, "p50_pips",     DoubleToString(p50     / pip_factor, 4), "pips", "");
   WriteCsvRow(fh, sec, "p95_pips",     DoubleToString(p95     / pip_factor, 4), "pips", "");
   WriteCsvRow(fh, sec, "p99_pips",     DoubleToString(p99     / pip_factor, 4), "pips", "");
  }

//+------------------------------------------------------------------+
//| Session-stratified spread: mean by hour of day (broker time)     |
//+------------------------------------------------------------------+
void CollectSessionSpread(int fh, string symbol,
                          int digits, double point, double pip_factor,
                          const int &spread_arr[], int n)
  {
   datetime times[];
   int time_copied = CopyTime(symbol, PERIOD_CURRENT, 0, n, times);
   if(time_copied != n)
      Print("WARNING: CopyTime returned ", time_copied, " bars, expected ", n,
            ". Session spread may be incomplete.");

   double hour_sum[24];
   int    hour_cnt[24];
   ArrayInitialize(hour_sum, 0);
   ArrayInitialize(hour_cnt, 0);

   int limit = MathMin(n, time_copied);
   for(int i = 0; i < limit; i++)
     {
      MqlDateTime dt;
      TimeToStruct(times[i], dt);
      hour_sum[dt.hour] += spread_arr[i];
      hour_cnt[dt.hour]++;
     }

   string sec = "spread_by_hour";
   for(int h = 0; h < 24; h++)
     {
      if(hour_cnt[h] > 0)
        {
         double hmean_pips = (hour_sum[h] / hour_cnt[h]) / pip_factor;
         string hour_str   = StringFormat("hour_%02d", h);
         WriteCsvRow(fh, sec, hour_str,
                     DoubleToString(hmean_pips, 4), "pips",
                     "broker_time; n=" + (string)hour_cnt[h]);
        }
     }
  }


Python: модель торговых издержек

Часть на Python состоит из dataclass TransactionCostModel и фабричной функции load_cost_model(). Dataclass хранит все параметры брокера и инструмента и предоставляет методы для расчёта каждого компонента издержек как доли цены входа. Фабричная функция читает CSV, экспортированный скриптом MQL5, и автоматически создаёт модель. Разместите оба определения в afml/transaction_costs.py.

Перед чтением кода стоит отметить два проектных решения. Во-первых, каждая издержка выражается не в пунктах, а в единой мере — относительной доходности. Благодаря этому модель не зависит от инструмента: один и тот же класс одинаково работает с валютными парами, металлами и индексами без ручного преобразования единиц, а результат напрямую сопоставим с рядом доходностей, используемым для разметки тройным барьером. Во-вторых, commission_per_lot выражается как ставка за одну сторону сделки — комиссия, взимаемая за одно открытие или закрытие сделки объёмом один стандартный лот. При расчёте полного цикла сделки она внутри функции удваивается. Это соответствует тому, как большинство ECN-брокеров указывают комиссию (например, $7 за лот за одну сторону), и предотвращает скрытую ошибку удвоения, когда ставка за полный цикл передаётся в функцию, которая сама применяет множитель ×2.

Ниже приведено определение dataclass со всеми объявлениями полей. Поля, для которых нет универсального значения по умолчанию, — symbol и spread_pips — размещены первыми и не имеют значения по умолчанию; для остальных используются нули или консервативные стартовые значения.

"""
afml/transaction_costs.py

Loads broker transaction cost data exported by TransactionCostCollector.mq5
and derives the min_ret threshold for triple-barrier label construction.

The threshold is the minimum return a trade must achieve to cover the
round-trip transaction cost at a given spread percentile, slippage
assumption, and holding-period-adjusted swap accrual.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path

import pandas as pd

@dataclass
class TransactionCostModel:
    """
    Broker-specific transaction cost model for a single symbol.

    All costs are expressed as fractional returns (e.g., 0.0001 = 1 pip
    on a 1.0000 priced instrument) so they can be compared directly to the
    return series used for triple-barrier labeling.

    Parameters
    ----------
    symbol : str
        Instrument identifier (e.g., "EURUSD").
    spread_pips : float
        Spread for cost calculation. Use p95 from the collected distribution,
        not the mean; entries during high-spread periods are disproportionately
        costly and the mean systematically understates them.
    slippage_pips : float
        One-way slippage estimate. Derived from live or demo trade log.
        Default 0.5 pips is conservative for major forex pairs.
    commission_per_lot : float
        Per-side commission in account currency per standard lot.
        Confirm from a reference trade; see CollectCommissionInfo() note.
        Set to zero for spread-only brokers.
    swap_long_per_night : float
        Swap in native MQL5 units for long positions.
        MQL5 sign convention: negative = broker debits your account.
    swap_short_per_night : float
        Swap in native MQL5 units for short positions.
        Usually negative; sometimes positive for short carry trades.
    swap_mode : str
        MQL5 SYMBOL_SWAP_MODE string from the CSV.
    swap_triple_day : int
        Weekday on which triple swap is charged (0=Sun, 6=Sat).
    pip_factor : float
        Points per pip. 10 for 5-digit brokers, 1 for 4-digit.
    point : float
        Broker point size (e.g., 0.00001 for EURUSD 5-digit).
    tick_value : float
        Account currency value of one tick per standard lot.
    tick_size : float
        Minimum price movement.
    contract_size : float
        Units per standard lot (e.g., 100,000 for forex).
    lot_size : float
        Lot size used for this strategy (e.g., 0.01 mini-lot).
    account_currency_rate : float
        Exchange rate from profit currency to account currency.
        Set to 1.0 when profit currency equals account currency.
    spread_by_hour : dict[int, float]
        Hour-of-day mean spread in pips (broker time). Used by
        session_adjusted_spread_pips() for hour-aware cost estimation.
    """

    symbol:                str
    spread_pips:           float
    slippage_pips:         float            = 0.5
    commission_per_lot:    float            = 0.0
    swap_long_per_night:   float            = 0.0
    swap_short_per_night:  float            = 0.0
    swap_mode:             str              = "points"
    swap_triple_day:       int              = 3        # Wednesday default
    pip_factor:            float            = 10.0
    point:                 float            = 0.00001
    tick_value:            float            = 10.0
    tick_size:             float            = 0.00001
    contract_size:         float            = 100_000.0
    lot_size:              float            = 0.01
    account_currency_rate: float            = 1.0
    spread_by_hour:        dict[int, float] = field(default_factory=dict)

Каждый из четырёх базовых методов расчёта издержек ниже возвращает один компонент издержек полного цикла сделки как долю цены входа. pip_value — служебное свойство, показывающее стоимость одного пункта в валюте счёта при заданном размере лота; внутри модели оно не используется, но полезно для быстрой проверки параметров модели на правдоподобие.

    # ── Derived helpers ───────────────────────────────────────────────────────

    @property
    def pip_value(self) -> float:
        """Account currency value of one pip per lot_size."""
        return (
            (self.tick_value / self.tick_size)
            * (self.pip_factor * self.point)
            * self.lot_size
            * self.account_currency_rate
        )

    def spread_cost_frac(self, entry_price: float) -> float:
        """
        Round-trip spread cost as a fraction of entry price.

        For triple-barrier entries (market orders), the round-trip spread
        is the full bid-ask spread: you enter at ask and exit at bid on a
        market order stop or time exit, so the full spread is crossed once.
        """
        spread_price = self.spread_pips * self.pip_factor * self.point
        return spread_price / entry_price

    def slippage_cost_frac(self, entry_price: float) -> float:
        """Round-trip slippage as a fraction of entry price (2× one-way)."""
        slippage_price = (
            self.slippage_pips * self.pip_factor * self.point * 2
        )
        return slippage_price / entry_price

    def commission_cost_frac(self, entry_price: float) -> float:
        """
        Round-trip commission as a fraction of entry price.

        commission_per_lot is the per-side rate; multiply by 2 to get the
        round-trip cost (entry commission + exit commission). Divide by
        notional value to convert to a fractional return.
        """
        notional = entry_price * self.contract_size * self.lot_size
        if notional == 0:
            return 0.0
        return (
            self.commission_per_lot * 2 * self.account_currency_rate
        ) / notional

Метод расчёта свопа самый сложный, потому что должен учитывать три разных режима свопа MQL5 и день тройного свопа. В MQL5 принято соглашение о знаках, по которому отрицательное значение означает списание со счёта (издержку). Метод следует этому соглашению: он меняет знак ставки, поэтому отрицательный своп даёт положительную долю издержек, а положительный своп — отрицательную долю издержек и снижает эффективную стоимость полного цикла сделки. Для сделок с положительным переносом на парах вроде AUDJPY или USDMXN такая асимметрия важна; использование в исходном коде abs() незаметно превращало положительный своп в издержку.

    def swap_cost_frac(
        self,
        entry_price: float,
        holding_days: float,
        side: int = 1,
    ) -> float:
        """
        Swap accrual as a fraction of entry price for a given holding period.

        Returns a positive value when swap is a net cost and a negative value
        when swap is a carry credit that reduces the effective round-trip cost.

        MQL5 sign convention: negative swap rate = broker debits the account
        (a cost to you). This method negates the rate so that a debit produces
        a positive cost fraction and a carry credit produces a negative one.

        Triple-swap day: when holding_days >= 3, two additional nights of swap
        are charged on the assumption that the position spans the rollover day.
        This is a conservative approximation; the actual exposure depends on
        entry timing. See the Practical Considerations section for a note on
        calendar days vs. trading bars.

        Parameters
        ----------
        entry_price  : float  Entry price of the trade.
        holding_days : float  Expected holding period in calendar days.
        side         : int    +1 for long, -1 for short.
        """
        rate = (
            self.swap_long_per_night if side >= 0
            else self.swap_short_per_night
        )
        nights = holding_days + (2 if holding_days >= 3 else 0)

        if self.swap_mode == "points":
            # rate in broker points; negative = debit
            # Negate: negative rate → positive cost fraction
            swap_price = -rate * self.point * nights
            return swap_price / entry_price if entry_price > 0 else 0.0

        elif self.swap_mode in ("currency", "currency_mrgn", "currency_dep"):
            # rate in account currency per lot per night; negative = debit
            total_swap = -rate * self.lot_size * nights
            notional   = entry_price * self.contract_size * self.lot_size
            return total_swap / notional if notional > 0 else 0.0

        elif self.swap_mode in ("interest_open", "interest_curr"):
            # Annual interest rate; negative = you pay
            nightly_rate = -rate / 100.0 / 365.0
            return nightly_rate * nights

        return 0.0

Оставшиеся четыре метода образуют открытый интерфейс. round_trip_cost_frac() суммирует все четыре компонента и является единым вызовом, который min_ret_for_symbol() применяет к ряду цен. Нижняя граница ноль в min_ret_for_symbol() обрабатывает крайний случай сильной сделки с положительным переносом, когда начисление свопа снижает общие издержки ниже нуля; отрицательный порог разметки не имеет смысла и пометил бы каждую сделку как положительную независимо от доходности. session_adjusted_spread_pips() и summary() — диагностические утилиты.

    def round_trip_cost_frac(
        self,
        entry_price: float,
        holding_days: float = 0.0,
        side: int = 1,
    ) -> float:
        """
        Total round-trip cost as a fraction of entry price.

        Parameters
        ----------
        entry_price  : Reference price (e.g., close at the label bar).
        holding_days : Expected holding period in calendar days.
                       Pass 0.0 for intraday strategies (no overnight cost).
        side         : +1 long, -1 short. Affects swap direction.
        """
        return (
            self.spread_cost_frac(entry_price)
            + self.slippage_cost_frac(entry_price)
            + self.commission_cost_frac(entry_price)
            + self.swap_cost_frac(entry_price, holding_days, side)
        )

    def min_ret_for_symbol(
        self,
        price_series: pd.Series,
        holding_days: float = 0.0,
        side: int = 1,
        cost_multiplier: float = 1.5,
    ) -> float:
        """
        Derive the min_ret threshold for triple-barrier labeling.

        The threshold is cost_multiplier × median round-trip cost across the
        price series. A cost_multiplier of 1.5 means the profit barrier must
        exceed 1.5× the round-trip cost to receive a positive label; trades
        that barely cover costs are treated as non-events.

        The result is floored at zero. For strong carry pairs the swap credit
        can reduce the median cost below zero, which would yield a negative
        threshold — a result that labels every trade as positive regardless of
        return and defeats the purpose of the filter.

        Parameters
        ----------
        price_series    : pd.Series  Close prices indexed the same as labels.
        holding_days    : float      Expected average holding period in calendar days.
        side            : int        +1 or -1. Use 1 if direction is unknown.
        cost_multiplier : float      Safety margin above break-even cost.
                                     1.0 = break-even; 1.5 is recommended.

        Returns
        -------
        float
            min_ret value to pass to get_events() or equivalent.
        """
        costs = price_series.apply(
            lambda p: self.round_trip_cost_frac(p, holding_days, side)
        )
        return float(max(0.0, costs.median() * cost_multiplier))

    def session_adjusted_spread_pips(self, hour: int) -> float:
        """
        Return the mean spread for a given hour-of-day (broker time).
        Falls back to spread_pips if that hour has no data.
        """
        return self.spread_by_hour.get(hour, self.spread_pips)

    def summary(self, entry_price: float, holding_days: float = 1.0) -> dict:
        """Human-readable cost breakdown at a reference entry price and holding period."""
        pip_price = self.pip_factor * self.point
        return {
            "spread_frac":      self.spread_cost_frac(entry_price),
            "slippage_frac":    self.slippage_cost_frac(entry_price),
            "commission_frac":  self.commission_cost_frac(entry_price),
            "swap_long_frac":   self.swap_cost_frac(entry_price, holding_days,  1),
            "swap_short_frac":  self.swap_cost_frac(entry_price, holding_days, -1),
            "total_long_frac":  self.round_trip_cost_frac(entry_price, holding_days,  1),
            "total_short_frac": self.round_trip_cost_frac(entry_price, holding_days, -1),
            "total_long_pips":  self.round_trip_cost_frac(entry_price, holding_days,  1)
                                * entry_price / pip_price,
            "total_short_pips": self.round_trip_cost_frac(entry_price, holding_days, -1)
                                * entry_price / pip_price,
        }

Фабричная функция разбирает CSV, созданный скриптом MQL5, и на его основе создаёт dataclass. Аргумент spread_percentile определяет, какая строка раздела spread_summary используется как spread_pips; slippage_pips и commission_per_lot отсутствуют в CSV и должны передаваться вызывающим кодом, поскольку их нельзя измерить по историческим данным баров.

# ── CSV loader ────────────────────────────────────────────────────────────────


def load_cost_model(
    csv_path: Path,
    spread_percentile: str = "p95_pips",
    slippage_pips: float = 0.5,
    commission_per_lot: float = 0.0,
    lot_size: float = 0.01,
    account_currency_rate: float = 1.0,
) -> TransactionCostModel:
    """
    Build a TransactionCostModel from a CSV exported by TransactionCostCollector.mq5.

    Parameters
    ----------
    csv_path : Path
        Path to the <symbol>_costs.csv file.
    spread_percentile : str
        Which spread statistic to use as the model spread. Options: mean_pips,
        p50_pips, p95_pips, p99_pips. Default p95_pips is recommended.
    slippage_pips : float
        One-way slippage. Not in the CSV — must be supplied from live or demo trade
        log analysis.
    commission_per_lot : float
        Per-side commission per standard lot in account currency. Confirmed from a
        reference trade; zero for spread-only brokers.
    lot_size : float
        Strategy lot size.
    account_currency_rate : float
        Profit-to-account-currency exchange rate.

    Returns
    -------
    TransactionCostModel
    """
    df = pd.read_csv(csv_path)

    def get(section: str, key: str) -> str:
        row = df[(df["section"] == section) & (df["key"] == key)]
        if row.empty:
            raise KeyError(f"Missing: section={section!r} key={key!r} in {csv_path}")
        return str(row["value"].iloc[0]).strip()

    symbol = get("symbol_properties", "symbol")
    point = float(get("symbol_properties", "point"))
    pip_factor = float(get("symbol_properties", "pip_factor"))
    tick_size = float(get("symbol_properties", "tick_size"))
    tick_value = float(get("symbol_properties", "tick_value"))
    contract_sz = float(get("symbol_properties", "contract_size"))

    swap_long = float(get("swap", "swap_long"))
    swap_short = float(get("swap", "swap_short"))
    swap_mode = get("swap", "swap_mode")
    swap_3day = int(get("swap", "swap_3day"))

    spread_pips = float(get("spread_summary", spread_percentile))

    hour_rows = df[df["section"] == "spread_by_hour"]
    spread_by_hour: dict[int, float] = {}
    for _, row in hour_rows.iterrows():
        hour = int(str(row["key"]).replace("hour_", ""))
        spread_by_hour[hour] = float(row["value"])

    return TransactionCostModel(
        symbol=symbol,
        spread_pips=spread_pips,
        slippage_pips=slippage_pips,
        commission_per_lot=commission_per_lot,
        swap_long_per_night=swap_long,
        swap_short_per_night=swap_short,
        swap_mode=swap_mode,
        swap_triple_day=swap_3day,
        pip_factor=pip_factor,
        point=point,
        tick_value=tick_value,
        tick_size=tick_size,
        contract_size=contract_sz,
        lot_size=lot_size,
        account_currency_rate=account_currency_rate,
        spread_by_hour=spread_by_hour,
    )


Интеграция с пайплайном разметки и расчёта P&L

Когда обе части готовы, интеграция в пайплайн состоит из пяти шагов: загрузить модель, проверить разбивку издержек на контрольной цене, получить min_ret для разметки, передать его в get_events(), а затем передать те же параметры издержек в triple_barrier_pnl(). Первые два шага — обязательная предварительная проверка: изучите вывод summary() перед выбором любого порога. Если какой-либо компонент выглядит аномально — например, доля комиссии больше доли спреда или издержка свопа составляет большую часть общей суммы, — проверьте исходные данные до продолжения.

from pathlib import Path
import pandas as pd
from afml.transaction_costs import load_cost_model

# Step 1: Load model from MQL5 export
model = load_cost_model(
    csv_path           = Path("data/EURUSD_costs.csv"),
    spread_percentile  = "p95_pips",   # conservative: 95th percentile
    slippage_pips      = 0.4,          # derived from demo trade log
    commission_per_lot = 7.0,          # per-side; confirmed from reference trade
    lot_size           = 0.01,
)

# Step 2: Inspect cost breakdown before committing to a threshold
print(pd.Series(model.summary(entry_price=1.08500, holding_days=1.0)))

summary() — первое, что нужно проверить. Этот метод показывает каждый компонент издержек как долю цены входа, а также итоговые значения в долях и пипсах. Для EURUSD по цене 1,085, при спреде 95-го процентиля 1,2 пипса, проскальзывании 0,4 пипса и комиссии, эквивалентной примерно 0,07 пипса для 0,01 лота, общие внутридневные издержки полного цикла сделки составляют около 1,67 пипса. После проверки разбивки получите min_ret и передайте его в вызов разметки.

close = pd.read_parquet("data/EURUSD_H1.parquet")["close"]

# Step 3: Derive min_ret for the labeling call
min_ret_intraday = model.min_ret_for_symbol(
    price_series=close,
    holding_days=0.0,   # no overnight exposure
    cost_multiplier=1.5,
)

# For H1 with max_hold=48: 48 bars × 1 hour = 48 hours = 2.0 calendar days
min_ret_swing = model.min_ret_for_symbol(
    price_series=close,
    holding_days=2.0,
    cost_multiplier=1.5,
)

print(f"min_ret (intraday): {min_ret_intraday:.6f}")
print(f"min_ret (swing):    {min_ret_swing:.6f}")

# Step 4: Pass to get_events for triple-barrier label construction
events = get_events(
    close=close,
    t_events=sampled_idx,
    pt_sl=[2, 1],
    target=volatility,
    min_ret=min_ret_swing,  # cost-derived labeling threshold
    num_threads=4,
    vertical_barrier_times=barriers,
)

Самая частая ошибка интеграции — использовать один набор допущений об издержках для разметки и другой набор при расчёте ряда P&L, который затем подаётся в последующую оценку параметров. Если min_ret откалиброван по спреду уровня p95 в 1,2 пункта, но пайплайн P&L вычитает только заданные вручную 0,5 пункта, то E[P&L] и среднее значение прибыльной сделки окажутся оптимистичнее, чем метки. Оценки параметров O-U из части 15 — и оптимальные PT и SL, выведенные из них, — будут установлены шире, чем допускает фактическая среда издержек. Чтобы этого избежать, берите параметры издержек из объекта модели, а не задавайте их вручную в каждом месте вызова.

# Step 5: Pass the same cost parameters to the P&L pipeline
#          model.spread_pips and model.slippage_pips are the p95 values from
#          the CSV — the same figures used to compute min_ret. Do not substitute
#          separate hardcoded constants here.
pnl_series = triple_barrier_pnl(
    df=bars,
    signals=sigs,
    atr=at,
    pt_mult=2.0,
    sl_mult=1.0,
    max_hold=48,
    spread_pips=model.spread_pips,     # from cost model, not hardcoded
    slippage_pips=model.slippage_pips,   # include slippage in P&L
    pip=0.0001,
)

Таблица ниже связывает каждый параметр модели с его источником, рекомендуемым значением, если оно есть, и конкретным предупреждением по использованию.

  Параметр Источник Примечания
 1. spread_pips CSV, созданный скриптом MQL5 (spread_summary) Используйте p95_pips по умолчанию. Передавайте model.spread_pips и в min_ret_for_symbol() и triple_barrier_pnl() — никогда не задавайте отдельную константу вручную для каждого места вызова.
 2. slippage_pips Журнал реальных или демо-сделок Нельзя вывести из исторических данных баров. 0,4–0,7 пипса — консервативный стартовый диапазон для основных валютных пар. Учитывайте и в издержках разметки, и в вычитании при расчёте P&L.
 3. commission_per_lot Контрольная сделка на демо-счёте Ставка за одну сторону сделки. Откройте сделку объёмом 1,0 лота, считайте ACCOUNT_COMMISSION_BLOCKED на вкладке «Счёт» — это значение является комиссией за одну сторону на один лот. Подтвердите его по выписке счёта. Для брокеров без комиссии укажите ноль.
 4. swap_long_per_night / swap_short_per_night CSV, созданный скриптом MQL5 (swap) Собирается автоматически. Соглашение о знаках MQL5: отрицательное значение = списание. Перезапускайте скрипт ежеквартально; ставки свопа меняются вместе с политикой центральных банков.
 5. holding_days Логика стратегии Календарные дни, а не торговые бары. Для H1 с max_hold=48: 48 × 1 час = 2,0 календарных дня. Для H4 с max_hold=30: 30 × 4 часа = 120 часов = 5,0 календарных дня. Значение должно быть согласовано между min_ret_for_symbol() и triple_barrier_pnl().
 6. cost_multiplier Исследовательский параметр 1,5 — разумное значение по умолчанию. Применяется только при выводе min_ret — не при вычитании из P&L. Рекомендации по калибровке см. в разделе «Практические рекомендации».


Практические рекомендации

Для спреда используйте p95, а не среднее. Средний спред — это усреднение по всем рыночным условиям, включая ликвидные часы лондонской сессии и спокойные периоды азиатской консолидации. Стратегия иногда будет входить в периоды низкой ликвидности — на новостях, на открытии торговых сессий, в малоликвидные ранние утренние часы, — и именно там спред максимален, а модель издержек, калиброванная по среднему, ошибается сильнее всего. 95-й процентиль честнее отражает среду спредов, с которой фактически столкнутся ваши входы. Если стратегия жёстко фильтрует часы входа, можно подставить session_adjusted_spread_pips() чтобы использовать почасовое среднее для входов в эти конкретные часы, а не один процентиль по всему распределению.

Round-Trip Cost Breakdown by Holding Period (EURUSD)

Рисунок 3. Двухпанельная иллюстрация разбивки компонентов издержек для внутридневного и свингового удержания

  • Панель (a) — внутридневная торговля (holding_days=0.0): спред и проскальзывание составляют все издержки полного цикла сделки; своп равен нулю. Комиссия даёт небольшую, но заметную долю на ECN-счетах.
  • Панель (b) — свинговая сделка (holding_days=2.0): начисление свопа заметно увеличивает общие издержки по сравнению с внутридневной оценкой. Стратегии, откалиброванные по внутридневным издержкам, будут систематически неверно размечать сделки со свинговым удержанием.

Разрыв в описании комиссии сделан намеренно.ACCOUNT_COMMISSION_BLOCKED — это не ставка на лот, а зарезервированная комиссия по текущим открытым позициям. Когда открытых позиций нет, возвращается ноль. Чтобы оценить ставку за одну сторону сделки, откройте контрольную сделку объёмом 1,0 лота на демо-счёте, считайте комиссию на вкладке «Счёт» и подтвердите её по дневной выписке. Рассматривайте это как разовую калибровку для конкретного брокерского счёта и повторяйте её при смене счёта или брокера.

cost_multiplier=1.5 — исследовательский параметр, а не константа. Для стратегии с высокой долей прибыльных сделок и коротким удержанием множитель 1,5× может быть слишком консервативным и отсеет реальные сигналы. Для стратегии с умеренной долей прибыльных сделок и более долгим удержанием 1,5× может быть слишком мягким. Корректная калибровка — сравнить распределение меток до и после фильтра.

Label Distribution Before vs After Cost-Calibrated min_ret

Рисунок 4. Двухпанельная иллюстрация распределения меток до и после фильтра min_ret калиброванного по издержкам

  • Панель (a) — до: исходное распределение меток, включая сделки, которые едва покрывают или не покрывают издержки полного цикла сделки. Класс нулевых меток представлен слабее, чем должно быть при данной среде издержек.
  • Панель (b) — после: распределение меток после применения калиброванного по издержкам min_ret. Если фильтр удаляет сделки, которые ранее имели положительную метку, это может означать, что он убирает реальное преимущество — множитель слишком агрессивен. Если удалённые сделки равномерно распределены по всем исходным исходам меток, фильтр убирает шум.

Если удалённые метки примерно равномерно распределены между прибыльными, убыточными и нулевыми классами, фильтр удаляет шум — пограничные сделки, которые рынок фактически воспринимает как подбрасывание монеты. Если удалённые метки непропорционально сосредоточены в прибыльных сделках, фильтр убирает торговое преимущество; порог прибыли выше уровня, который оправдан текущей структурой рынка, и его следует ослабить. Цель — отфильтровать метки, неотличимые от рыночных издержек, а не метки, отражающие реальную возможность.

cost_multiplier применяется только к разметке, а не к расчёту P&L. Когда вы передаёте model.spread_pips и model.slippage_pips в triple_barrier_pnl(), вы вычитаете исходные издержки из результата каждой сделки без множителя. Множитель — это запас прочности для двоичного фильтра «прошёл / не прошёл» при разметке. Завышение издержек в ряду P&L исказило бы распределение, используемое для оценки параметров O-U в части 15.

Календарные дни и торговые бары. holding_days задаётся в календарных днях для начисления свопа, а max_hold — в торговых барах. Для входов в будние дни это одна и та же величина: 48 баров H1, начиная с утра понедельника, заканчиваются утром среды и охватывают ровно 2 календарных дня. У выходных эти величины расходятся. Позиция, открытая в четверг в 10:00 с max_hold=48 барами, закроется примерно в понедельник в 10:00, охватив 4 календарные ночи, хотя торговых часов будет только 48. Для стратегий с устойчивыми внутридневными паттернами входа расхождение невелико. Для стратегий, которые могут входить ближе к концу торговой недели, при задании holding_days лучше брать максимально возможный календарный интервал, а не среднее значение.

Перезапускайте ежеквартально и всегда после смены брокера. Распределения спреда меняются вместе со структурой рынка и условиями брокерской ликвидности. Ставки свопа меняются вслед за политикой центральных банков. Модель издержек, откалиброванная двенадцать месяцев назад у одного брокера, может существенно занижать издержки у другого брокера или в текущей среде ставок. Скрипт MQL5 выполняется за несколько секунд на любом графике и сразу создаёт свежий CSV. Запускайте его заново в начале каждого нового цикла обучения.


Заключение

Два описанных здесь компонента закрывают конкретный операционный пробел: они заменяют произвольные заданные вручную константы min_ret измеренной у брокера воспроизводимой калибровкой издержек, которую можно напрямую использовать и в разметке, и в расчёте P&L. На практике цепочка даёт четыре практических артефакта, которые стоит встроить в исследовательский процесс:

  • экспорт CSV из MetaTrader 5 с процентилями спреда, средними значениями спреда по часам, параметрами свопа и метаданными символа;
  • TransactionCostModel, преобразующая эти значения в относительные доходности и рассчитывающую компоненты издержек по каждой сделке;
  • числовой min_ret, полученный из медианной издержки полного цикла сделки (при необходимости умноженной на коэффициент запаса) и готовый к передаче в get_events();
  • те же параметры издержек, передаваемые в triple_barrier_pnl(), чтобы метки и реализованный P&L рассчитывались на единой согласованной базе издержек.

На практике важны три правила. Выражайте издержки в виде относительных доходностей, чтобы модель не зависела от инструмента; используйте консервативный процентиль спреда (p95) или почасовые спреды с учётом сессии вместо среднего; считайте проскальзывание и комиссию на лот настройками, которые задаёт вызывающий код, поскольку для них нужны журналы исполнения или контрольная сделка. Наконец, сделайте это измерение регулярной частью процесса: перезапускайте сборщик MQL5 ежеквартально и при каждой смене брокера или счёта, а для разметки и P&L всегда используйте одни и те же результаты TransactionCostModel, чтобы избежать тонких, но существенных несоответствий при последующей оценке параметров.


Прилагаемые файлы

  Файл Модуль Роль в статье Ключевые зависимости
 1. TransactionCostCollector.mq5 Скрипты MQL5 Автономный скрипт. Собирает процентили распределения спреда и почасовые средние по сессиям через CopySpread(), ставки свопа через SymbolInfoDouble(), все свойства символа и диагностику комиссии из ACCOUNT_COMMISSION_BLOCKED. Записывает структурированный CSV в каталог MQL5 Files. Запускайте один раз на инструмент в квартал или после любой смены брокера. Только стандартная библиотека MQL5.
 2. transaction_costs.py afml Модуль Python, содержащий dataclass TransactionCostModel и фабричную функцию load_cost_model(). Рассчитывает спред, проскальзывание, комиссию и своп как относительные доходности. Обрабатывает все три режима свопа MQL5 и день тройного свопа. Своп учитывается со знаком: положительный своп снижает эффективные издержки, а не завышает их. Предоставляет min_ret_for_symbol() для пайплайна разметки, session_adjusted_spread_pips() для стратегий с учётом часа входа и summary() для проверки издержек. model.spread_pips и model.slippage_pips также следует передавать в triple_barrier_pnl() в цепочке P&L. pandas, dataclasses, pathlib

Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/22372

Прикрепленные файлы |
Особенности написания Пользовательских Индикаторов Особенности написания Пользовательских Индикаторов
Написание пользовательских индикаторов в торговой системе MetaTrader 4
Рекуррентный количественный анализ (RQA) в MQL5: Разработка полноценной библиотеки для анализа Рекуррентный количественный анализ (RQA) в MQL5: Разработка полноценной библиотеки для анализа
В этой статье создаётся полный набор средств для количественного анализа рекуррентности (Recurrence Quantification Analysis, RQA) для MetaTrader 5 на чистом MQL5. Мы рассмотрим реконструкцию фазового пространства, вложение с временной задержкой, построение матрицы расстояний и рекуррентной матрицы, извлечение метрик RQA, автоматический выбор эпсилон и расчёт в скользящем окне с помощью модульной архитектуры библиотеки. В завершение статья показывает, как применить библиотеку в практическом индикаторе, который выводит RR, DET, LAM, ENTR и TREND непосредственно на график, создавая надёжную основу для нелинейного анализа временных рядов в MQL5.
Особенности написания экспертов Особенности написания экспертов
Написание и тестирование экспертов в торговой системе MetaTrader 4.
Разработка динамического мультивалютного советника (Часть 5): Скальпинг и свинг-трейдинг Разработка динамического мультивалютного советника (Часть 5): Скальпинг и свинг-трейдинг
В этой части рассматривается, как разработать динамический мультивалютный советник, способный адаптироваться к режимам скальпинга и свинг-трейдинга. В ней рассматриваются структурные и алгоритмические различия в генерации сигналов, исполнении сделок и управлении рисками, благодаря которым советник может гибко переключаться между стратегиями в зависимости от рыночного поведения и входных параметров.