Архитектура машинного обучения для MetaTrader 5 (Часть 14): Моделирование транзакционных издержек для разметки методом тройного барьера в MQL5
Оглавление
- Введение
- Проблема жёстко заданных издержек
- Три компонента издержек и способы их измерения
- MQL5: скрипт сбора торговых издержек
- Python: модель торговых издержек
- Интеграция с пайплайном разметки и расчёта P&L
- Практические рекомендации
- Заключение
- Прилагаемые файлы
Введение
Представьте, что вы проектируете мост, но не учитываете в расчётах собственный вес конструкции: такая конструкция не выдержит нагрузки, хотя причина ошибки была очевидна с самого начала. Похожая практическая ошибка встречается во многих процессах разметки тройным барьером. Исследователи часто задают 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 по каждой сделке.
Три компонента издержек и способы их измерения
Спред — единственный компонент, который можно точно измерять в MQL5 в реальном времени. Это разница между Ask и Bid в момент предполагаемого входа. Для построения меток важен не мгновенный спред на одном баре, а распределение спреда по часам и торговым сессиям, в которые стратегия действительно торгует. Стратегия, работающая по 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() чтобы использовать почасовое среднее для входов в эти конкретные часы, а не один процентиль по всему распределению.

Рисунок 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× может быть слишком мягким. Корректная калибровка — сравнить распределение меток до и после фильтра.

Рисунок 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
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Особенности написания Пользовательских Индикаторов
Рекуррентный количественный анализ (RQA) в MQL5: Разработка полноценной библиотеки для анализа
Разработка динамического мультивалютного советника (Часть 5): Скальпинг и свинг-трейдинг
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования