Мультиброкерский и мультивалютный граф: симуляция арбитражной торговли
Введение
Арбитражный сканер на исторических данных может очень легко показать красивую прибыль. Но такие результаты ничего не значат, если заранее не определить, можно ли было действительно совершить эти сделки на рынке. Чаще всего проблема возникает не в математической формуле, а в том, как именно обрабатываются данные.
Например, для одной сделки может использоваться цена закрытия свечи вместо реальной цены покупки или продажи. В другом месте может не учитываться разница между ценой покупки и ценой продажи. Иногда данные по разным валютным парам относятся к разному времени, но программа всё равно объединяет их в одну арбитражную цепочку.
Ещё одна проблема появляется, когда используются данные сразу из нескольких источников. Если заранее не определить, откуда была взята каждая цена, потом невозможно понять, на основании каких именно данных программа нашла прибыльную возможность.
Отдельная ошибка связана с капиталом. Если в одну минуту программа находит сразу несколько подходящих сделок, она может несколько раз использовать один и тот же капитал. В реальной торговле так сделать невозможно. В результате получается красивая графика роста капитала, но её нельзя нормально проверить, повторить или честно сравнить с результатами другого брокера или с другими выгрузками из MetaTrader 5.
Поэтому задача состоит не в том, чтобы добавить ещё один индикатор. Необходимо создать понятную и проверяемую систему, которая превращает минутные исторические данные в список арбитражных возможностей и при этом строго учитывает реальные ограничения торговли.
В полном историческом тесте было обработано 10 143 738 строк данных и 1 706 530 уникальных минут. При расчёте учитывались торговые издержки: 1 базисный пункт комиссии и ещё 1 базисный пункт возможного ухудшения цены при исполнении на каждом этапе сделки. После всех проверок система выбрала 991 минуту, в которых существовала подходящая арбитражная возможность.
Если условно начать со 100 000 долларов и учитывать все найденные возможности, расчётный капитал увеличивается до 135 504,86 доллара. Однако важно понимать: этот график нельзя считать результатом реальной торговли. В расчёт заранее попадают только те исторические моменты, где после учёта заданных расходов результат оставался прибыльным. Поэтому отсутствие падений капитала на таком графике не означает, что реальная торговля не несёт риска. Это всего лишь особенность метода отбора исторических возможностей.
Цель статьи — показать весь процесс построения такой системы: от выгрузки минутных данных из MetaTrader 5 до результата, который можно проверить и воспроизвести. Будет рассмотрено, как рассчитывается арбитражная возможность, как восстанавливаются реальные цены покупки и продажи, как работать с несколькими источниками котировок, как выбирать только одну сделку в конкретный момент времени, как автоматически проверять корректность расчётов, как проводить полный исторический тест и как торговые расходы влияют на итоговый результат.
Отдельное внимание уделяется главному вопросу: где заканчивается математически прибыльная возможность на исторических данных и начинается сделка, которую действительно можно было бы исполнить на реальном рынке.
Практическая проблема и границы эксперимента
На экране EURGBP, EURUSD и GBPUSD выглядят как три независимых инструмента. Для арбитража полезнее рассматривать их как систему конвертаций между тремя валютами. Добавление второго источника цен превращает обычный треугольник в мультиграф: между одной и той же парой валют существуют параллельные варианты обмена с разными Bid и Ask.
Первая типичная ошибка — использовать Close как универсальную цену. В реальном направлении BASE→QUOTE продаётся базовая валюта, поэтому исполнимый курс равен Bid. Для обратного направления QUOTE→BASE базовую валюту необходимо купить по Ask, следовательно, курс конвертации равен 1/Ask. Подмена Ask ценой Close или Bid создаёт искусственное арбитражное преимущество.
Вторая ошибка — смешивать цены разных моментов времени. В исходных потоках больше десяти миллионов строк, но набор временных меток не совпадает полностью. Итоговый режим использует строгое правило: нужные ноги цикла должны существовать на одной и той же минуте. Forward fill не применяется.
Третья ошибка — повторное использование капитала. Если в одну минуту прибыльны два или три маршрута, нельзя без дополнительной модели капитала считать их как независимые сделки на весь счёт. Поэтому в итоговой стратегии одна минута даёт максимум одну запись — маршрут с максимальным net multiplier после заданных издержек.
| Параметр эксперимента | Фиксированное значение |
|---|---|
| Валюты | EUR, GBP, USD |
| Инструменты | EURGBP, EURUSD, GBPUSD |
| Источники | pro и default для каждой пары |
| Таймфрейм | M1 |
| Синхронизация | строго одна временная метка; forward fill выключен |
| Циклов | 5 |
| Правило отбора | не более одного лучшего цикла в минуту |
| Базовый cost scenario | 1 базисный пункт комиссии + 1 базисный пункт проскальзывания на каждую ногу |
| Начальный условный капитал | 100 000 USD |
Временной мультиграф и исполнимый курс
В момент времени t система строит ориентированный временной мультиграф G_t=(V,E_t). Вершины V — это EUR, GBP и USD. Каждая комбинация символа и источника создаёт два направленных ребра. Потоки pro и default не анализируются в отдельных моделях: они сосуществуют в одном графе и конкурируют за каждую конкретную ногу маршрута.
Для котировки BASE/QUOTE прямое ребро BASE→QUOTE имеет курс Bid. Обратное ребро QUOTE→BASE имеет курс 1/Ask. Если суммарные издержки одной ноги выражены в базисных пунктах, то множитель издержек равен c=1-cost_bps/10000, где cost_bps — издержки в базисных пунктах. Логарифмический вес ребра задаётся выражением w_e=-ln(r_e·c).
Для направленного цикла C длины L итоговый множитель равен M(C,t)=Πr_e·c^L. Возможность существует при M(C,t)>1. В логарифмической форме это соответствует отрицательной сумме весов. Логарифм здесь используется как вычислительное представление: он превращает произведение положительных курсов в сумму и не меняет экономический смысл маршрута.
# --- Сокращённый фрагмент класса DirectedEdge @dataclass(frozen=True, slots=True) class DirectedEdge: src: str dst: str source: str symbol: str raw_rate: float def log_weight(self, cost_factor: float = 1.0) -> float: # --- переводим произведение курсов в сумму весов executable_rate = self.raw_rate * cost_factor if executable_rate <= 0.0: return math.inf return -math.log(executable_rate)
Класс DirectedEdge сохраняет не только курс, но и направление, символ и источник. Благодаря этому результат можно развернуть обратно в конкретную последовательность конвертаций и проверить, где использовался Bid, где 1/Ask и какой поток победил на каждой ноге.

Рисунок 1. Архитектура расчёта: M1-потоки → временной мультиграф → пять циклов → один выбранный маршрут → аудируемая opportunity equity.
Подготовка M1-данных и контроль качества
Исходные файлы имеют типичный формат экспорта MetaTrader 5: DATE, TIME, OPEN, HIGH, LOW, CLOSE, TICKVOL, VOL и SPREAD. В проекте Close трактуется как Bid, а Ask восстанавливается по формуле Ask=Close+Spread·Point. В опубликованном наборе EURGBP, EURUSD и GBPUSD имеют пятизнаковую точность, поэтому исторический расчёт использовал Point=0.00001. В публикационной версии кода это значение больше не считается универсальным: новый MQL5-экспортёр записывает POINT и DIGITS в каждый поток, а Python-загрузчик либо читает POINT из файла, либо требует явный контракт Point. Это исключает тихое применение 0.00001 к символам с другой точностью.
Это допущение объявляется до расчёта, потому что M1-бар не содержит внутриминутной последовательности Bid/Ask и не подтверждает, что котировки двух источников реально существовали в одну и ту же миллисекунду. Совпадение минутной метки — необходимое условие синхронизации в этом эксперименте, но не доказательство тикового одновременного исполнения.
Публикационный загрузчик фиксирует временной контракт: DATE+TIME трактуются как серверное время; дубликаты минут запрещены; строки сортируются по времени; секунды не «срезаются» неявным округлением вниз. Если поток содержит 12:34:05, загрузка завершается ошибкой вместо преобразования такой строки в 12:34. Благодаря этому внешнее объединение не может создать ложную синхронность из-за неявной нормализации временной метки.
# --- Фрагмент TemporalQuoteMultigraph.update_quote() def update_quote(self, quote: QuoteEvent) -> None: # --- прямое ребро: продаём BASE и получаем QUOTE по Bid forward = DirectedEdge( src=quote.base, dst=quote.quote, source=quote.source, symbol=quote.symbol, raw_rate=quote.bid, ) # --- обратное ребро: покупаем BASE за QUOTE по Ask reverse = DirectedEdge( src=quote.quote, dst=quote.base, source=quote.source, symbol=quote.symbol, raw_rate=1.0 / quote.ask, ) self._edges[(forward.src, forward.dst)][quote.source] = forward self._edges[(reverse.src, reverse.dst)][quote.source] = reverse
До создания рёбер проверяются положительность цен и условие Ask≥Bid. Широкая временная таблица строится через внешнее объединение, однако пропуски не заполняются последней известной ценой. Если для какой-либо ноги нет конечного значения на текущей минуте, соответствующий цикл не оценивается.
Контроль качества данных сохраняется как часть воспроизводимости. Для каждого потока фиксируются число строк, доля нулевого spread, средний spread и временные границы. Именно здесь обнаруживается важная особенность набора: доля нулевого spread у отдельных потоков очень велика, поэтому историческое арбитражное преимущество нельзя автоматически трактовать как доступную бесплатную ликвидность.
| Поток | Строк | Нулевой spread | Средний spread, points |
|---|---|---|---|
| EURGBP.pro | 1 700 053 | 51,99% | 2,8071 |
| EURGBP.default | 1 683 612 | 24,88% | 4,0062 |
| EURUSD.pro | 1 703 992 | 90,91% | 0,9953 |
| EURUSD.default | 1 683 349 | 75,19% | 1,4296 |
| GBPUSD.pro | 1 692 138 | 73,94% | 2,0586 |
| GBPUSD.default | 1 680 594 | 24,09% | 4,9381 |
Нулевой spread может быть следствием особенностей экспорта, агрегации или спецификации конкретного источника. Поэтому таблица выше является не декоративной статистикой, а частью интерпретации результата. Если другой брокер или другой экспорт создаст иное распределение spread, финансовые результаты должны измениться даже при неизменном алгоритме.
Параллельные источники в одном графе
Параллельные источники создают два вида возможностей. Первый — двухрёберный round-trip между потоками одной пары: например, EUR продаётся за USD по лучшему Bid одного источника и выкупается обратно по лучшему Ask другого. Второй — треугольник, в котором каждая нога может быть взята из своего потока.
Для фиксированного цикла выбор максимального положительного исполнимого rate на каждой независимой ноге максимизирует произведение множителей. При этом источник не теряется: вместе с rate хранится код победившего потока.
# --- Векторизованный выбор параллельного ребра с явной маской валидности def _best_parallel_edge(wide, symbol: str, reverse: bool): pro_bid = wide[f"{symbol}_pro_bid"].to_numpy(dtype=np.float64, copy=False) pro_ask = wide[f"{symbol}_pro_ask"].to_numpy(dtype=np.float64, copy=False) default_bid = wide[f"{symbol}_default_bid"].to_numpy(dtype=np.float64, copy=False) default_ask = wide[f"{symbol}_default_ask"].to_numpy(dtype=np.float64, copy=False) with np.errstate(divide="ignore", invalid="ignore"): pro_rate = np.divide(1.0, pro_ask) if reverse else pro_bid default_rate = np.divide(1.0, default_ask) if reverse else default_bid pro_ok = np.isfinite(pro_rate) & (pro_rate > 0.0) default_ok = np.isfinite(default_rate) & (default_rate > 0.0) # --- документированный tie-break: при равенстве выбирается pro choose_pro = pro_ok & (~default_ok | (pro_rate >= default_rate)) choose_default = default_ok & ~choose_pro rate = np.full(pro_rate.shape, np.nan, dtype=np.float64) source = np.full(pro_rate.shape, -1, dtype=np.int8) rate[choose_pro] = pro_rate[choose_pro] source[choose_pro] = 0 rate[choose_default] = default_rate[choose_default] source[choose_default] = 1 return rate, source
Код 0 соответствует pro, код 1 — default, а -1 означает отсутствие доступной цены. Такое векторизованное представление позволяет выполнить полный расчёт на объединённой временной оси без Python-цикла по каждой из 1,7 миллиона минут.

Рисунок 2. Три валюты и два параллельных источника для каждой пары. Прямое направление использует Bid, обратное — 1/Ask.
Пять проверяемых циклов
В системе с тремя валютами исследуются три двухрёберных возврата и два направленных треугольника. Обратные направления треугольника сохраняются раздельно, потому что используют разные Bid/Ask и могут выбирать разные источники.
| Цикл | Ног | Смысл |
|---|---|---|
| EUR→GBP→EUR | 2 | межпотоковое расхождение EURGBP |
| EUR→USD→EUR | 2 | межпотоковое расхождение EURUSD |
| GBP→USD→GBP | 2 | межпотоковое расхождение GBPUSD |
| EUR→GBP→USD→EUR | 3 | первое направление треугольника |
| EUR→USD→GBP→EUR | 3 | обратное направление треугольника |
Потоковый сканер способен перечислять маршруты более универсально, тогда как векторизованный модуль использует фиксированную пятёрку, поскольку состав валют заранее известен. Оба режима должны возвращать один и тот же экономический множитель при одинаковых входах.
# --- Сокращённый фрагмент TemporalQuoteMultigraph.evaluate_cycle() def evaluate_cycle( self, cycle: tuple[str, ...], timestamp: str, current_minute: int, cost_factor: float, ) -> CycleEvaluation | None: selected = [] log_rate_sum = 0.0 # --- выбираем лучшее параллельное ребро для каждой ноги for index, src in enumerate(cycle): dst = cycle[(index + 1) % len(cycle)] edge = self.best_edge(src, dst, current_minute, 0) if edge is None: return None selected.append(edge) log_rate_sum += math.log(edge.raw_rate) raw_multiplier = math.exp(log_rate_sum) net_multiplier = raw_multiplier * cost_factor ** len(cycle) return CycleEvaluation( timestamp=timestamp, minute=current_minute, cycle=cycle, sources=tuple(edge.source for edge in selected), raw_multiplier=raw_multiplier, net_multiplier=net_multiplier, net_profit_bps=(net_multiplier - 1.0) * 10_000.0, )
Если хотя бы одна нога отсутствует, функция возвращает None и не создаёт синтетическую цену. Издержки применяются по числу ног: двухрёберный цикл получает два cost factor, треугольный — три.
Один лучший маршрут на минуту
Полный скан обнаружил 1 372 прибыльных цикла после базовых издержек, но уникальных выбранных минут оказалось 991. Это означает, что часть минут содержала более одного положительного маршрута. Итоговый бэктест не складывает их и не использует капитал несколько раз: сохраняется только максимальный net multiplier.
# --- run_best_cycle_backtest(): NaN/inf и all-invalid минуты raw = np.stack([ eur_gbp * gbp_eur, eur_usd * usd_eur, gbp_usd * usd_gbp, eur_gbp * gbp_usd * usd_eur, eur_usd * usd_gbp * gbp_eur, ]) lengths = np.array([2, 2, 2, 3, 3])[:, None] net = raw * np.power(cost_factor, lengths) valid = np.isfinite(net) & (net > 0.0) valid_any = valid.any(axis=0) safe = np.where(valid, net, -np.inf) best_cycle_index = np.argmax(safe, axis=0) minute_index = np.arange(safe.shape[1]) best_net = safe[best_cycle_index, minute_index] # --- argmax вернёт 0 даже для полностью -inf столбца, поэтому valid_any обязателен threshold = 1.0 + min_net_profit_bps / 10_000.0 selected_mask = valid_any & np.isfinite(best_net) & (best_net > threshold) selected_minutes = np.flatnonzero(selected_mask)
Матрица net имеет размер 5×N. Каждый столбец соответствует одной минуте, а строки — пяти фиксированным маршрутам. Перед argmax строится отдельная маска valid_any. Это принципиально: NumPy вернёт индекс 0 даже для столбца, полностью заполненного -inf. Поэтому сделкой считается только минута, где существует хотя бы один конечный положительный маршрут и лучший multiplier строго превышает заранее заданный threshold после издержек.
Если два значения совпадают в пределах double, argmax выбирает первый маршрут в установленном порядке: сначала три двухрёберных цикла, затем два треугольных. Это детерминированно и поэтому воспроизводимо. В рабочей системе такой tie-break можно было бы заменить критерием меньшего числа ног, ликвидности или меньшего суммарного spread, но в опубликованном эксперименте порядок фиксирован и не подбирается постфактум.
Параметр min_net_profit_bps задаёт минимальный дополнительный запас в базисных пунктах выше математического break-even. В базовом историческом прогоне положительный остаток уже рассчитывается после фиксированных комиссий и проскальзывания; повышение минимального буфера должно уменьшать число выбранных минут.
Opportunity equity и правильная интерпретация
Стартовый условный капитал равен 100 000 USD. На каждой выбранной минуте он умножается на net multiplier лучшего маршрута. allocation_fraction задаёт долю капитала, участвующую в последовательности; при значении 1.0 используется полное реинвестирование.
# --- Фрагмент построения opportunity equity # --- капитал меняется только на выбранных прибыльных минутах allocation_multiplier = 1.0 + allocation_fraction * ( trades["net_multiplier"] - 1.0 ) capital = starting_capital_usd * allocation_multiplier.cumprod() equity = pd.DataFrame({ "timestamp": trades["timestamp"], "route": trades["route"], "net_multiplier": trades["net_multiplier"], "equity_usd": capital, "cumulative_return_pct": ( capital / starting_capital_usd - 1.0 ) * 100.0, })
Ключевой момент: таблица trades уже отфильтрована по условию net_multiplier>1. Поэтому полученная последовательность капитала монотонна по построению. Нулевая просадка такой кривой не является метрикой риска торгового счёта. Она лишь показывает результат последовательного перемножения исторически отобранных математических возможностей.
Именно поэтому в статье используется термин theoretical opportunity equity. Он отделяет воспроизводимую математику от предположения о мгновенном и полном исполнении. Для настоящей trading equity нужны фактические цены исполнения, latency, частичное заполнение, leg risk, ограничения объёма и состояние капитала на каждом источнике.
Архитектура Python-проекта
Проект разделён на модульное ядро и независимые инструменты аудита. Пакет graph_arbitrage содержит модели графа, загрузку MetaTrader 5, векторизованный расчёт, отчётность и CLI. Каталог tools сохраняет потоковый сканер, который можно использовать как независимый путь проверки результатов.
| Файл или каталог | Назначение |
|---|---|
| src/graph_arbitrage/core.py | рёбра, временной мультиграф, циклы и выбор результата |
| src/graph_arbitrage/mt5_io.py | чтение ZIP/CSV, восстановление Ask и контроль качества |
| src/graph_arbitrage/backtest.py | пять циклов, один лучший маршрут и капитал в USD |
| src/graph_arbitrage/reporting.py | CSV, JSON и четыре PNG шириной 700 пикселей |
| tests/ | проверка рёбер, издержек, staleness и правила одной минуты |
| tools/ | исходный потоковый сканер и независимый расчёт |
| data/БАРЫ_ГРАФ.zip | шесть M1-потоков из MetaTrader 5 |
Такое разделение важно для статьи: финансовый результат не должен зависеть от единственного монолитного скрипта, который невозможно проверить по частям. Отдельные модули позволяют тестировать преобразование Bid/Ask, выбор параллельного ребра, cost factor и правило одной минуты независимо от полного исторического прогона.
CLI поддерживает self-test и backtest. Параметры капитала, комиссии, проскальзывания, минимального арбитражного преимущества и доли размещения передаются явно через командную строку. Следовательно, повторный эксперимент не требует редактирования исходного кода и не скрывает ключевые экономические константы внутри функций.
Тесты и воспроизводимость
Проверка разделена на четыре уровня. Первый тестирует экономические инварианты: Ask≥Bid, положительный Point, корректный cost factor и фиксированный tie-break. Второй проверяет данные: дубликаты минут и временные метки с ненулевыми секундами отвергаются, а Ask восстанавливается строго как Close+Spread·Point. Третий уровень проверяет argmax-контракт: полностью invalid-минута не должна порождать цикл 0. Векторизованный результат сравнивается с независимым построчным эталонным расчётом по каждой выбранной минуте, маршруту и multiplier. Четвёртый уровень — исходный полный исторический прогон: модульный код прочитал 10 143 738 строк, объединил 1 706 530 минут и воспроизвёл конечный капитал исходного архива с точным совпадением double.
| Проверка | Статус |
|---|---|
| Python smoke self-test | PASS |
| Публикационные контрактные тесты | 8/8 PASS |
| NaN/inf + all-invalid argmax | PASS |
| Ask = Close + Spread·Point и Ask≥Bid | PASS |
| Duplicate-minute / seconds rejection | PASS |
| Векторизованный ↔ эталонный поминутный расчёт | PASS на синтетическом контракте |
| Исходный полный исторический прогон | PASS |
| Совпадение исходного конечного капитала | точное |
На машине, где выполнялся исходный прогон, векторизованный расчёт занял 18,9 секунды и использовал около 1,57 ГБ памяти. Это не универсальный бенчмарк: показатели приведены только как характеристика конкретного набора из шести потоков и конкретной среды.
SHA256 используется как контроль версии данных и кода. Хэш не подтверждает правильность стратегии, но помогает отличить расхождение алгоритма от ситуации, когда читатель запускает другой исторический экспорт под тем же именем.
Технический аудит Python и MetaTrader 5-данных
После редакционного технического аудита риски разделены на две группы: ошибки, которые можно закрыть программным контрактом, и ограничения исходных M1-данных, которые код устранить не способен.
| Риск | Что сделано | Статус |
|---|---|---|
| NaN/inf и argmax | valid mask + valid_any + конечный threshold после argmax | закрыто тестом |
| Ask и Point | Ask=Close+Spread·Point; POINT экспортируется из SYMBOL_POINT либо задаётся явно; Ask≥Bid проверяется | закрыто контрактом |
| DATE/TIME, секунды, дубликаты | единый парсер, запрет неявного округления вниз, отклонение дубликатов минут, сортировка индекса | закрыто контрактом |
| Float-граница около 1.0 | float64 и явный min_net_profit_bps вместо скрытого округления | снижен |
| Два вычислительных пути | независимый построчный эталонный расчёт и поминутная проверка совпадения по временной метке, маршруту и multiplier | закрыто контрактными тестами |
| Жёсткий tie-break | правило фиксировано: при равном executable rate выбирается pro | явный контракт |
| Память | критические массивы — float64 и copy=False там, где это безопасно; 1,57 ГБ остаётся характеристикой исходного полного запуска | ограничение масштабирования |
| M1 не доказывает одновременность ног | результат называется opportunity, а не реальным исполнением | неустранимо на M1 |
| Synthetic Ask / zero spread | нулевой spread вынесен в data-quality audit; нужен tick replay | ограничение корпуса |
| Неатомарные три ноги | live требует state machine, fill-confirmation и emergency hedge | execution risk |
Публикационная версия устраняет тихие программные ошибки, способные создать ложные положительные сигналы, но не выдаёт ограничения M1-истории за решённые. Тиковая одновременность, реальный Ask, latency и атомарность нескольких торговых операций остаются предметом следующего эксперимента.
Полный исторический прогон
История начинается 3 января 2022 года и заканчивается 29 июля 2026 года. Последняя выбранная возможность датирована 8 июля 2026 года. При сценарии 1 базисный пункт комиссии и 1 базисный пункт проскальзывания на каждую ногу итоговая последовательность содержит 991 минуту.
| Метрика | Значение | Интерпретация |
|---|---|---|
| Строк котировок | 10 143 738 | сумма шести потоков |
| Уникальных минут | 1 706 530 | по объединённой временной оси |
| Полных оценок циклов | 8 508 474 | только минуты с доступными ногами |
| Прибыльных циклов после издержек | 1 372 | до выбора одного маршрута |
| Выбранных минут | 991 | не более одной записи на минуту |
| Начальный капитал | 100 000 USD | полное реинвестирование |
| Конечный капитал | 135 504,86 USD | теоретическая кривая возможностей |
| Накопительная доходность | 35,5049% | не доходность в live-режиме |
| Медианное арбитражное преимущество | 1,7124 базисного пункта | после 2 базисных пунктов на каждую ногу |
| Максимальное арбитражное преимущество | 64,7535 базисного пункта | требует отдельной проверки выброса |
Наиболее часто выбирался маршрут EUR→USD→GBP→EUR — 529 минут. Вторым по частоте оказался EUR→GBP→EUR — 256 минут. Это важно для интерпретации: система действительно использует и треугольные, и двухрёберные межпотоковые round-trip возможности.

Рисунок 3. Теоретическая opportunity equity для выбранных исторических возможностей.
Финальный уровень 135 504,86 USD соответствует накопительному изменению 35,5049% относительно условных 100 000 USD. Это число нельзя переносить в live-ожидание без модели исполнения: в расчёте предполагается, что выбранный multiplier доступен полностью и мгновенно.
Чувствительность к издержкам
Самая сильная проверка результата находится не на графике капитала, а в cost sensitivity. Без издержек сканер отмечает более 4,2 миллиона циклов. При 0,5 базисного пункта на ногу остаётся 12 904 возможности, при 2 базисных пунктах — 1 372, а при 10 базисных пунктах — только 39.
Такой спад на несколько порядков показывает, что исследование живёт в области очень малого арбитражного преимущества. Даже небольшая ошибка в spread, комиссии, latency или slippage способна радикально изменить число возможностей. Поэтому положительный результат базового сценария должен читаться вместе с графиком чувствительности, а не отдельно от него.

Рисунок 4. Чувствительность числа прибыльных циклов к издержкам на одну ногу; вертикальная шкала логарифмическая.
Именно эта зависимость задаёт следующий этап исследования: вместо одной фиксированной константы slippage требуется эмпирическое распределение из реального execution log с разбивкой по времени суток, источнику, направлению и размеру заявки.
Ограничения исполнения
- M1-бары не восстанавливают последовательность котировок внутри минуты. Одинаковая метка времени не доказывает одновременную доступность трёх ног.
- Close интерпретируется как Bid, а Ask восстанавливается через Spread·Point. Качество результата зависит от корректности этих полей в конкретном экспорте.
- Высокая доля нулевого spread в нескольких потоках может отражать специфику исторических данных, а не реальную бесплатную ликвидность.
- Фиксированное проскальзывание в базисных пунктах является сценарием, а не измеренным распределением исполнения.
- Полное реинвестирование не ограничено доступным объёмом, маржой, стаканом и market impact.
- Выбор лучшего источника на каждой ноге предполагает возможность использовать оба потока в одном операционном контуре. Раздельные счета требуют собственного учёта капитала и ребалансировки.
- Opportunity equity построена только из ex-post положительных множителей и поэтому не является торговой equity с рыночной просадкой.
- Правило одного маршрута на минуту исключает двойное использование капитала, но не моделирует конкуренцию маршрутов за одну и ту же ликвидность.
- Исторический результат не является live-forward и не доказывает возможность атомарного исполнения всего цикла.
Для исполняемого прототипа цикл необходимо превратить в конечный автомат. До отправки первой ноги капитал должен быть зарезервирован на нужных источниках; после каждого fill нужно пересчитывать оставшееся арбитражное преимущество; при ухудшении цены или неполном исполнении требуется тайм-аут и аварийное хеджирование. Три последовательных market order сами по себе не образуют атомарную операцию.
Кроме того, рабочий журнал исполнения должен разделять временную метку сигнала и временную метку исполнения и хранить время получения каждой котировки, отправки запроса, подтверждения, фактическую цену и объём. Только после этого можно измерять leg latency и строить реальную модель потерь арбитражного преимущества.
Как воспроизвести результат
Публикационный пакет распространяется одним архивом MQL5.zip. Его корень содержит только папку MQL5. После распаковки MQL5-экспортёр находится в MQL5/Scripts/GraphArbitrage/, а Python-код, тесты и документация — в MQL5/Files/GraphArbitrage/. Готовые CSV/JSON/PNG результатов и исполняемые EX5 в архив не включаются.
Для точного повторения опубликованных чисел нужны те же шесть исходных M1-потоков. Если они отсутствуют, приложенный MQL5-скрипт позволяет получить шесть потоков из доступных читателю символов и повторить методологию на собственной истории; численные значения при другом брокере закономерно будут отличаться.
# --- после распаковки MQL5.zip
cd MQL5/Files/GraphArbitrage/Python
python -m venv .venv
.venv/bin/pip install -r requirements.txt
export PYTHONPATH=src
python -m graph_arbitrage self-test
python -m pytest -q
python -m graph_arbitrage backtest \
--input ../Data \
--output ../Results \
--starting-capital-usd 100000 \
--commission-bps 1 \
--slippage-bps 1 \
--parity - Распаковать MQL5.zip в каталог данных терминала. В корне архива уже находится папка MQL5.
- При необходимости скомпилировать MQL5/Scripts/GraphArbitrage/GRAPH_ARBITRAGE_EXPORT_M1.mq5 и экспортировать шесть доступных потоков в MQL5/Files/GraphArbitrage/Data.
- Перейти в MQL5/Files/GraphArbitrage/Python, создать виртуальное окружение и установить requirements.txt.
- Выполнить self-test и pytest. Публикационная версия должна показать 8/8 PASS.
- Запустить backtest с параметром --parity: векторизованный путь сравнивается с независимым эталонным расчётом поминутно.
- Для исходного корпуса сравнить summary.json с контрольными значениями раздела 11: 10 143 738 строк, 1 706 530 минут, 991 выбранная минута и 135 504,86 USD. На другом корпусе финансовые числа закономерно изменятся.
Такой порядок важнее наличия заранее сгенерированных CSV. Читатель должен иметь возможность получить результаты повторным расчётом, а не только открыть готовую таблицу. Готовые отчёты полезны как контрольный эталон, но не заменяют воспроизводимый путь от данных до результата.
Что получает разработчик
Результат работы — не одна формула поиска отрицательного цикла, а исследовательский контур с несколькими независимыми уровнями проверки. Модульное ядро реализует граф и векторизованный бэктест, потоковый сканер даёт независимый аудит, tests проверяют локальные инварианты, а CLI фиксирует экономические параметры запуска.
После полного прогона разработчик получает CSV/JSON с выбранными маршрутами и сводными метриками, а также изображения результатов. Важнее другое: каждый маршрут можно разложить до источника и направления каждой ноги, поэтому найденное арбитражное преимущество не остаётся анонимным числом.
Следующий инженерный шаг — не добавлять больше циклов, а повысить достоверность исполнения: тиковый replay Bid/Ask, общая временная шкала, измеренное распределение latency/slippage, ограничение объёма, отдельные балансы по источникам и конечный автомат корзины.
Заключение
В работе проверялась корректность выявления арбитражных возможностей на исторических данных с учётом реальных цен Bid/Ask, синхронности котировок и ограничения на использование только одного маршрута в каждый момент времени.
Всего было обработано 10 143 738 строк данных. При комиссии и дополнительном ухудшении цены по 1 базисному пункту для каждой операции было найдено 991 арбитражное событие. При условном капитале 100 000 USD расчётный результат составил 135 504,86 USD, однако эту величину нельзя считать ожидаемой доходностью реальной торговли.
Результаты показали высокую чувствительность арбитража к торговым издержкам: без их учёта обнаруживалось более 4,2 млн потенциальных циклов, а при издержках 10 базисных пунктов — только 39. Поэтому полученные значения следует рассматривать прежде всего как проверку модели и качества исходных данных, а не как прогноз реальной прибыли.
Следующим этапом исследования должна стать проверка уже на тиковых данных. В отличие от минутных свечей они позволяют точнее восстановить последовательность изменения Bid и Ask и оценить, могли ли все операции действительно быть выполнены по наблюдаемым ценам.
Для приближения модели к реальной торговле также необходимо учитывать время отправки и исполнения заявок, изменение цены между операциями, частичное исполнение, отказ в исполнении и объём средств, который необходимо заранее резервировать для проведения всего арбитражного цикла.
Только после такой проверки можно переходить от оценки исторических арбитражных возможностей к оценке результатов и рисков реальной торговой системы.
Таким образом, проведённый эксперимент показывает не гарантированный способ получения прибыли, а метод последовательной проверки арбитражной идеи: от исходных минутных котировок и правильного использования Bid и Ask до учёта торговых расходов и подготовки к проверке на тиковых данных. Такой подход позволяет отделить действительно существовавшие ценовые расхождения от ошибок, возникающих из-за особенностей обработки исторических данных.
К статье прикрепляется только один файл. Он соответствует стандартной структуре каталога терминала; внутренние Python-модули, тесты и документация отдельно не прикладываются.
| Файл | Назначение |
|---|---|
| MQL5.zip | Архив проекта, который можно распаковать в каталог данных терминала. В корне находится папка MQL5; MQL5-скрипт расположен в MQL5/Scripts/GraphArbitrage/, а Python-код, тесты и документация — в MQL5/Files/GraphArbitrage/. Исполняемые EX5, копии статьи и заранее рассчитанные результаты отсутствуют. |
Дополнительные исторические корпуса и диагностические выгрузки при необходимости передаются отдельно в обсуждении статьи.
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Особенности написания Пользовательских Индикаторов
От одиночного CatBoost к Cross-Fitted Stack в MQL5: ансамбль без утечки будущего и отдельный audit-forward 2026
Машинное обучение и Data Science (Часть 48): Насколько важны трансформеры для трейдинга?
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования