preview
Валютный граф и справедливая цена 28 пар через DFS по кросс-путям

Валютный граф и справедливая цена 28 пар через DFS по кросс-путям

MetaTrader 5Эксперты |
119 0
Yevgeniy Koshtenko
Yevgeniy Koshtenko

Валютный граф вместо интуиции: справедливая цена 28 валютных пар через DFS по кросс-путям

Подавляющее большинство арбитражных советников на MQL5.community сводятся к одной и той же идее: взять треугольник из трёх пар (например, EUR/USD, USD/JPY, EUR/JPY), посчитать синтетический курс через два плеча и сравнить с рыночным. Это работает, но использует ничтожную долю информации, доступной трейдеру, у которого в Market Watch есть все 28 комбинаций из 8 основных валют.

Если посмотреть на эти 28 пар не как на независимые тикеры, а как на рёбра ориентированного графа с 8 вершинами-валютами, то у каждой пары появляется не один, а десятки альтернативных путей подтверждения курса. EURJPY — это не только прямая котировка, но и EUR→USD→JPY, EUR→GBP→JPY, EUR→CHF→JPY→... и так далее, вплоть до путей в 5–6 рёбер, задействующих все восемь валют одновременно.

В этой статье разберу советник GraphCycleFairValueScalper28, который строит такой граф на живых котировках, обходит его в глубину (DFS) для каждой из 28 пар, взвешивает найденные пути по длине и получает справедливую цену как средневзвешенное геометрическое по всем маршрутам. Отклонение рыночной цены от этой справедливой цены — торговый сигнал.

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

1. Почему один треугольник — это мало (и что на самом деле даёт много путей)

Классический треугольный арбитраж использует ровно один путь длиной 2 ребра. У него есть системная проблема: если синтетика чуть шумит (проскальзывание при исполнении, задержка тика по одному из трёх инструментов), сигнал ложный, а альтернативы проверить нечем — путь один.

В графе на 8 валютах для пары EURJPY DFS находит и 2-хопные, и 3-хопные, и 4-хопные пути — потенциально несколько десятков маршрутов, каждый из которых даёт свою оценку истинного EURJPY. Важная оговорка, без которой легко переоценить силу этого ансамбля: эти пути не независимы в статистическом смысле слова. В no-arbitrage мире log-курс любой пары — это разность двух чисел из 7-мерного пространства (8 валют минус один общий сдвиг базиса), и абсолютно любой путь любой длины — точная линейная комбинация тех же 7 чисел. Корректнее говорить так: 20 найденных путей — это 20 проекций одного и того же 7-мерного набора базовых курсов. Чем путей больше, тем плотнее покрытие графа комбинациями, но не больше независимых подтверждений. Строгое обоснование и способ честно измерить, сколько в этом ансамбле реальной новой информации, — в разделе 6.

Тем не менее плотное покрытие всё равно полезно: оно усредняет шум конкретных котировок (спред, задержку тика, микроструктурные скачки одного символа), даже если не даёт независимых голосов в строгом смысле. Разберём это на условном числовом примере, чтобы показать, как именно взвешивание превращает разрозненные оценки в одну цифру. Пусть для EURJPY нашлось три пути:

Путь Рёбер (edges) Implied EURJPY Вес w = decay^(edges-2)
EUR→USD→JPY 2 164.820 1.000
EUR→GBP→JPY 2 164.795 1.000
EUR→CHF→CAD→JPY 3 164.910 0.650

При InpHopWeightDecay = 0.65 взвешенное геометрическое среднее в лог-пространстве даёт около 164.84 — оценка сдвинута ближе к двум коротким путям, но не игнорирует более длинный, который указывает на дополнительное расхождение через CHF/CAD. Если EURJPY в этот момент, скажем, на уровне 165.05, отклонение от справедливой цены — примерно 21 пункт. Это потенциальный сигнал SELL, подтверждённый тремя маршрутами. Но, как будет видно в разделе 6, три пути такой конфигурации дают заметно меньше эффективных наблюдений, чем три случайно выбранных пути без общих рёбер — потому что первые два пути делят общую опору на EUR, а третий делит с ними CHF/CAD-сегмент рынка лишь частично.

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

2. Математическая модель

2.1 Матрица курсов

Для 8 валют строится матрица R[i][j] — сколько единиц валюты j даёт одна единица валюты i, взятая из mid-цены (среднее bid/ask) каждой из 28 пар:

//+------------------------------------------------------------------+
//| Строит матрицу кросс-курсов R[i][j] между 8 валютами.            |
//| R[i][j] = сколько единиц валюты j даёт 1 единица валюты i.       |
//| Заполняется сразу в обе стороны из mid-цены каждой из 28 пар.    |
//+------------------------------------------------------------------+
void BuildRateMatrix(double &R[][CUR_COUNT], bool &OK[][CUR_COUNT])
  {
   ArrayInitialize(R, 0.0);
   ArrayInitialize(OK, false);

   //--- Курс валюты к самой себе всегда равен 1 — тривиальный путь длины 0.
   for(int i=0;i<CUR_COUNT;i++)
     {
      R[i][i]  = 1.0;
      OK[i][i] = true;
     }

   //--- Заполняем прямое и обратное ребро из mid-цены каждой живой пары.
   for(int s=0;s<SYM_COUNT;s++)
     {
      string sym = Symbols[s];
      double mid = MidPrice(sym);
      if(mid <= 0.0) continue;

      int b = BaseCur[s];
      int q = QuoteCur[s];
      if(b < 0 || q < 0) continue;

      R[b][q] = mid;
      R[q][b] = 1.0 / mid;
      OK[b][q] = true;
      OK[q][b] = true;
     }
  }

Обратите внимание: обратное ребро R[q][b] = 1.0 / mid заполняется сразу, без дополнительного маркет-дата запроса — граф у нас неориентированный по доступности данных, но ориентированный по направлению конвертации.

2.2 Обход графа: почему DFS, а не BFS

Задача — не найти кратчайший путь (это дало бы только прямую котировку), а перечислить все простые пути (без повторения вершин) от базовой валюты обратно к валюте котировки, длиной от 2 до InpMaxHops рёбер. Это классическая задача перечисления путей в графе, для которой DFS с бэктрекингом — естественный выбор: BFS пришлось бы хранить весь префикс пути в очереди, а DFS делает это неявно через стек рекурсии и массив VisitedCur.

//+------------------------------------------------------------------+
//| Рекурсивный обход валютного графа в глубину (DFS).               |
//| Перечисляет все простые пути (без повторения валют) от base      |
//| обратно к quote длиной от 2 до maxHops рёбер. Каждый найденный   |
//| путь добавляет свой implied-курс в sumWeightedLog с весом,       |
//| убывающим по длине пути (см. InpHopWeightDecay) и по ликвидности |
//| пройденных рёбер (см. раздел 10, EdgeLiquidityFactor).           |
//+------------------------------------------------------------------+
void DFSFairValue(const int base, const int quote, const int current, const int depth, const int maxHops,
                   const double logProduct, const double liqProduct,
                   double &R[][CUR_COUNT], bool &OK[][CUR_COUNT],
                   double &sumWeightedLog, double &sumWeight, int &pathCount)
  {
   //--- Пытаемся замкнуть путь здесь: current -> quote.
   if(depth >= 1 && OK[current][quote] && R[current][quote] > 0.0)
     {
      double v = logProduct + MathLog(R[current][quote]);
      if(MathIsValidNumber(v))
        {
         int edges = depth + 1;
         double closingLiq = EdgeLiquidityFactor(current, quote);
         //--- Геометрическое среднее ликвидности рёбер пути * затухание по длине.
         double liqGeoMean = MathPow(liqProduct * closingLiq, 1.0 / edges);
         double w  = MathPow(InpHopWeightDecay, edges-2) * liqGeoMean;
         sumWeightedLog += w * v;
         sumWeight      += w;
         pathCount++;
        }
     }

   //--- Нет места ещё на одно ребро перед замыкающим шагом — обрезаем ветку.
   if(depth + 1 >= maxHops) return;

   //--- Пробуем продолжить путь через каждую ещё не посещённую валюту.
   for(int k=0;k<CUR_COUNT;k++)
     {
      if(k == base || k == quote || k == current) continue;
      if(VisitedCur[k]) continue;
      if(!OK[current][k] || R[current][k] <= 0.0) continue;

      VisitedCur[k] = true;
      DFSFairValue(base, quote, k, depth+1, maxHops, logProduct + MathLog(R[current][k]),
                   liqProduct * EdgeLiquidityFactor(current, k),
                   R, OK, sumWeightedLog, sumWeight, pathCount);
      VisitedCur[k] = false;
     }
  }

Три инженерных решения здесь заслуживают отдельного внимания:

  1. Работа в логарифмах, а не в произведениях курсов. logProduct накапливает сумму логарифмов пройденных курсов вместо их произведения. Это стандартный приём для избежания потери точности double при цепочке из 5–6 умножений мелких чисел, и — что важнее для следующего шага — превращает взвешенное геометрическое среднее в обычное арифметическое среднее логарифмов, что считается на порядок дешевле и устойчивее.
  2. Экспоненциальное затухание веса по длине пути, домноженное на ликвидность рёбер. Базовый множитель w = InpHopWeightDecay^(edges-2) честно остаётся эвристикой (см. раздел 6 о том, почему это лишь приближение к диагонали правильной GLS-оценки), но теперь он комбинируется с геометрическим средним EdgeLiquidityFactor() по всем рёбрам пути — формула и обоснование в разделе 10. Путь через два узких, редко торгуемых кросса теперь получает меньший вес даже при той же длине, что и путь через ликвидные majors.
  3. VisitedCur как единственный guard от циклов. Поскольку валюта не может встретиться в простом пути дважды, достаточно булева массива на 8 элементов — никаких множеств или хэш-таблиц, что для MQL5 с его негибкой работой с динамическими структурами более чем оправдано.

2.3 От логарифмов обратно к цене

//+------------------------------------------------------------------+
//| Справедливая цена пары base/quote — взвешенное геометрическое    |
//| среднее по всем найденным DFS простым кросс-путям графа.         |
//| Возвращает 0.0, если путей меньше InpMinPathsRequired: значит    |
//| граф в этот момент разрежен и оценке доверять нельзя.            |
//+------------------------------------------------------------------+
double GraphFairValue(const int base, const int quote, double &R[][CUR_COUNT], bool &OK[][CUR_COUNT], int &paths)
  {
   paths = 0;
   double sumWeightedLog = 0.0;
   double sumWeight = 0.0;

   //--- Глубина ограничивается диапазоном [2, CUR_COUNT-1] — больше рёбер, чем
   //--- валют в графе минус одна, физически быть не может.
   int maxHops = (int)MathMin(MathMax(InpMaxHops, 2), CUR_COUNT - 1);

   ArrayInitialize(VisitedCur, false);
   VisitedCur[base] = true;

   DFSFairValue(base, quote, base, 0, maxHops, 0.0, 1.0, R, OK, sumWeightedLog, sumWeight, paths);

   VisitedCur[base] = false;

   //--- Недостаточно охвата графа путями — оценке не доверяем.
   if(paths < InpMinPathsRequired || sumWeight <= 0.0) return(0.0);

   //--- Среднее арифметическое в лог-пространстве -> геометрическое в цене.
   return(MathExp(sumWeightedLog / sumWeight));
  }

sumWeightedLog / sumWeight — это средневзвешенное арифметическое в лог-пространстве, а MathExp() от него — средневзвешенное геометрическое в исходном пространстве цен. Геометрическое среднее здесь методологически корректнее арифметического, поскольку валютные курсы и их обратные величины (USD/EUR против EUR/USD) должны быть согласованы мультипликативно, а не аддитивно: геометрическое среднее курса и среднее его обратной величины взаимно обратны, у арифметического среднего этого свойства нет.

Обратите внимание на защитный порог InpMinPathsRequired: это порог именно охвата графа, а не независимости свидетельств (см. раздел 6) — если граф временно распался (несколько символов не торгуются, например ночью по AUD/NZD-кроссам с низкой ликвидностью), и DFS нашёл всего 1–2 пути вместо ожидаемых полутора-двух десятков, справедливая цена не считается надёжной и функция возвращает 0. Это защита от торговли по шуму: лучше пропустить тик, чем входить по одному нерепрезентативному пути.

2.4 Сколько путей реально перечисляет DFS: оценка сложности

Прежде чем ставить InpMaxHops в 6 или 7, полезно понимать, во что это выливается вычислительно. В полном графе на n вершинах число простых путей ровно из k рёбер между двумя фиксированными вершинами равно числу упорядоченных выборок из оставшихся n-2 вершин по k-1 штук, то есть P(n-2, k-1) = (n-2)! / (n-2-(k-1))!. Для нашего графа n = 8, значит n-2 = 6:

Рёбер в пути (edges) Промежуточных валют Путей на одну пару Вес одного пути при decay=0.65
2 1 6 1.000
3 2 30 0.650
4 3 120 0.423
5 4 360 0.275
6 5 720 0.179
7 6 720 0.116

При InpMaxHops = 4 (то есть учитываются пути с edges = 2, 3 и 4) на одну пару перебирается 6 + 30 + 120 = 156 путей — отсюда и оценка не более нескольких сотен из раздела про инженерные детали. Если поставить InpMaxHops = 7 (максимум для 8-вершинного графа, задействующий все валюты в одном пути), число путей на пару вырастет до 1956, а суммарно по всем 28 парам на каждый тик — до нескольких десятков тысяч операций с логарифмами. Это всё ещё не проблема для современного VPS на M1, но уже ощутимо на тиковом таймфрейме при большом потоке котировок ночью по 28 символам сразу.

Важно понимать эту таблицу вместе с разделом 6: рост числа путей с 6 до 1956 не означает роста числа независимых наблюдений — оно всё ещё ограничено 7 базовыми log-курсами. Рост InpMaxHops увеличивает вычислительную нагрузку почти факториально, а полезную информацию — существенно медленнее, за счёт усреднения шума, а не за счёт новых степеней свободы.

3. От справедливой цены к сделке

Отклонение считается в пунктах инструмента:

//+------------------------------------------------------------------+
//| Отклонение рыночной mid-цены от справедливой цены графа,         |
//| выраженное в пунктах инструмента. Положительное значение —       |
//| рынок дороже справедливой цены, отрицательное — дешевле.         |
//+------------------------------------------------------------------+
double DeviationPts(const string sym, double market, double fair)
  {
   if(market <= 0.0 || fair <= 0.0) return(0.0);
   return((market - fair) / SymPoint(sym));
  }

Здесь стоит остановиться на важной поправке. Fair value считается по mid-цене, а вход в рынок всегда происходит через bid или ask — то есть часть номинального отклонения на mid попросту не пересекает спред и не может быть монетизирована. Для скальпера, где типичный порог входа сопоставим по масштабу со спредом, это не мелочь, а прямое искажение оценки качества сигнала. Правильная проверка — вычитать реальные издержки исполнения до сравнения с порогом входа, а не после:

//+------------------------------------------------------------------+
//| Отклонение, скорректированное на реальные издержки исполнения:   |
//| текущий спред прямой пары плюс буфер на проскальзывание.         |
//| Именно эта величина, а не сырое DeviationPts, сравнивается       |
//| с InpEntryDeviationPts — иначе часть сигналов по mid физически   |
//| не даёт положительного матожидания после исполнения.             |
//+------------------------------------------------------------------+
double AdjustedDeviationPts(const string sym, const double rawDeviationPts)
  {
   double spreadPts   = SymSpreadPts(sym);
   double bufferPts   = InpSlippageBufferPts;
   double costPts     = spreadPts / 2.0 + bufferPts;

   if(rawDeviationPts > 0.0)
      return(MathMax(0.0, rawDeviationPts - costPts));
   else
      return(MathMin(0.0, rawDeviationPts + costPts));
  }

Вход тогда проверяется по скорректированной величине:

//+------------------------------------------------------------------+
//| Фрагмент FindEntries(): решение о входе по знаку отклонения,     |
//| скорректированного на издержки исполнения.                       |
//+------------------------------------------------------------------+
double adjDev = AdjustedDeviationPts(s, dev);

if(adjDev >= InpEntryDeviationPts)
   OpenTrade(s, -1, dev, fair);   // рынок дороже справедливой цены даже после издержек -> продаём
else if(adjDev <= -InpEntryDeviationPts)
   OpenTrade(s, +1, dev, fair);   // рынок дешевле справедливой цены даже после издержек -> покупаем

а выход происходит не по фиксированному TP (хотя защитный TP тоже есть, InpTakeProfitPts), а по возврату отклонения к нулю — то есть по самому событию, ради которого сделка открывалась. Здесь тоже стоит учитывать режимный риск (см. раздел 9): если отклонение не возвращается к нулю за разумное время, это может быть не шум, а структурный сдвиг, и удерживать позицию до буквального нуля неограниченно долго — не лучшая стратегия.

//+------------------------------------------------------------------+
//| Фрагмент ManageClosures(): решение о закрытии по реверсии        |
//| отклонения к нулю, ограниченное сверху максимальным временем     |
//| удержания позиции — защита от режимных сдвигов (см. раздел 9).   |
//+------------------------------------------------------------------+
bool timedOut = (TimeCurrent() - PositionGetInteger(POSITION_TIME)) >= InpMaxHoldSeconds;

if(type == POSITION_TYPE_SELL && (dev <= InpExitDeviationPts || timedOut)) closeNow = true;
if(type == POSITION_TYPE_BUY  && (dev >= -InpExitDeviationPts || timedOut)) closeNow = true;

Это архитектурно важное решение: для каждой позиции теперь есть три уровня защиты. Первый — жёсткий SL/TP по пунктам, страховка от аномалий и разрыва связи с терминалом. Второй — выход по логике модели, то есть реверсия графа к согласованности. Третий — лимит по времени удержания, страховка от режимного сдвига, когда реверсии не происходит вовсе. При нормальной работе рынка закрытие происходит именно по второму механизму, раньше первого и третьего.

4. Инженерные детали, на которые стоит обратить внимание при портировании идеи

Пересчёт матрицы курсов и полный обход графа выполняется на каждом тике для каждой из 28 пар. При InpMaxHops = 4 число просматриваемых путей на пару — не более нескольких сотен (граф на 8 вершинах маленький), так что нагрузка на CPU у советника скромная, но при InpMaxHops = 6–7 (максимум для 8-вершинного графа) число простых путей растёт факториально, и на слабом VPS это может стать заметным на high-load-тиках.

ManageClosures() вызывается раньше FindEntries() в OnTick(). Это не случайность: закрытие уже открытых по реверсии позиций высвобождает лимит InpMaxOpenTrades перед попыткой открыть новые, что имеет значение при быстром рынке с несколькими сигналами одновременно.

InpMinSecondsBetween и HasOpenPosition() защищают от переторговки одного и того же инструмента на дребезге отклонения около порога — стандартная, но обязательная гигиена для любого mean-reversion советника с высокой частотой пересчёта сигнала.

Риск считается по формуле moneyPerLotAtSL = (slPts * point / tickSize) * tickValue — корректный переход от пунктов SL к денежному риску через tickSize/tickValue, что важно для кросс-пар, где point и tickValue не всегда интуитивно связаны (например, JPY-кроссы против CHF-кроссов).

5. Мультиколлинеарность портфеля: скрытый риск 28 пар из 8 валют

Есть особенность, которая напрямую вытекает из самой идеи графа и о которой легко забыть при настройке риск-менеджмента. Все 28 пар построены всего из 8 валют, а значит они не независимы: если, например, EUR резко ослаб против всей корзины, отклонение от справедливой цены одновременно возникнет сразу в нескольких парах с EUR — EURUSD, EURGBP, EURJPY, EURCHF, EURCAD, EURAUD, EURNZD могут дать сигнал SELL EUR практически в один и тот же тик, потому что все они опираются на одну и ту же аномалию в графе. Это тот же эффект ограниченной размерности (7 базовых степеней свободы на 28 пар), что разбирается математически в разделе 6, только на уровне портфеля сделок, а не путей DFS.

InpMaxOpenTrades ограничивает количество позиций, но не ограничивает экспозицию по конкретной валюте. Семь одновременных SELL-позиций по EUR-парам — это фактически одна большая ставка против евро с семикратным плечом по факту, даже если формально это семь разных сделок по семи разным инструментам и лимит в 8 открытых позиций ещё не исчерпан.

Для честного риск-контроля стоит добавить отдельный подсчёт нетто-экспозиции по каждой из 8 валют — не по числу открытых тикетов, а по объёму в лотах с учётом направления (BUY по XXXYYY увеличивает экспозицию на XXX и уменьшает на YYY, и наоборот). Простейший вариант — завести массив CurrencyExposure[CUR_COUNT], обновлять его при каждом открытии/закрытии позиции и не пускать новую сделку, если она толкает экспозицию по какой-то валюте выше заданного порога в лотах или в проценте от депозита. Это не отменяет полезность самой модели, но отделяет ситуацию, где граф действительно нашёл семь разных аномалий, от куда более рискованной ситуации, где рынок оказался прав и весь портфель поставил против одной валюты одновременно.

6. Формальная проверка независимости путей: ранг системы и эффективное число наблюдений

Раздел 1 утверждал без доказательства, что пути DFS коррелированы. Дадим этому строгую основу и практический инструмент, которым можно заменить наивный подсчёт paths как меры надёжности сигнала.

В отсутствие арбитража log R[i][j] = xᵢ − xⱼ, где x ∈ ℝ⁸ — вектор log-курсов валют к произвольному общему базису. Прибавление константы ко всем компонентам x не меняет ни одной пары xᵢ − xⱼ, поэтому реальная размерность системы — 7, а не 8. Любой DFS-путь любой длины — это просто телескопическая сумма вида (x_base − x_k1) + (x_k1 − x_k2) + ... + (x_kn − x_quote) = x_base − x_quote, то есть в идеальном (no-arbitrage) графе вообще все пути дают тождественно одно и то же число. В реальности пути расходятся только за счёт микроструктурного шума (несинхронные тики, спреды, задержки) — то есть весь разброс путей, на котором основана идея модели, живёт в остаточном 21-мерном пространстве шума (28 пар минус 7 базовых степеней свободы), а не в дополнительной рыночной информации.

Из этого следует практичная и дешёвая в вычислении метрика. Пусть на скользящем окне из N последних тиков накоплена ковариационная матрица Σ отклонений implied-логкурсов путей от их среднего. Эффективное число независимых наблюдений — стандартная величина из анализа главных компонент:

N_eff = (Σᵢ λᵢ)² / Σᵢ λᵢ²

Ключевое удобство для реализации в MQL5 без библиотек линейной алгебры: и числитель, и знаменатель этой формулы выражаются через саму матрицу Σ без явного нахождения собственных чисел, поскольку след Σ равен сумме собственных чисел, а след Σ² (равный сумме квадратов элементов Σ для симметричной матрицы) равен сумме квадратов собственных чисел:

//+------------------------------------------------------------------+
//| Эффективное число независимых наблюдений среди путей DFS.        |
//| pathLogs[k] — implied log-курсы последних K путей на скользящем  |
//| окне тиков (буфер обновляется в DFSFairValue вместо/вместе со    |
//| свёрткой в GraphFairValue). Возвращает N_eff в диапазоне [1, 7]  |
//| для честно согласованного графа — если N_eff заметно превышает   |
//| 7, это сигнал реального микроарбитража между котировками, а не   |
//| артефакт формулы.                                                |
//+------------------------------------------------------------------+
double EffectivePathCount(const double &pathLogs[][MAX_WINDOW], const int pathCount, const int window)
  {
   double mean[];
   ArrayResize(mean, pathCount);
   for(int i=0;i<pathCount;i++)
     {
      double s = 0.0;
      for(int t=0;t<window;t++) s += pathLogs[i][t];
      mean[i] = s / window;
     }

   double traceSigma  = 0.0;   // сумма дисперсий = сумма λᵢ
   double traceSigma2 = 0.0;   // сумма квадратов всех элементов Σ = сумма λᵢ²

   for(int i=0;i<pathCount;i++)
     {
      for(int j=0;j<pathCount;j++)
        {
         double cov = 0.0;
         for(int t=0;t<window;t++)
            cov += (pathLogs[i][t]-mean[i]) * (pathLogs[j][t]-mean[j]);
         cov /= (window - 1);

         if(i == j) traceSigma += cov;
         traceSigma2 += cov * cov;
        }
     }

   if(traceSigma2 <= 0.0) return(0.0);
   return((traceSigma * traceSigma) / traceSigma2);
  }

Практический смысл: если EffectivePathCount() для пары путей с общим ребром (как EUR→USD→JPY и EUR→GBP→JPY в примере из раздела 1, оба зависящих от текущего состояния JPY-блока) даёт значение, близкое к 1–2, а не к числу путей, это подтверждает математический факт из начала раздела: они почти не добавляют друг другу независимости. Имеет смысл логировать N_eff рядом с сырым paths и, при желании, использовать порог по N_eff, а не по paths, для InpMinPathsRequired — это честнее отражает то, на что действительно можно полагаться.

Дополнительно эта же линза объясняет, почему InpHopWeightDecay — это лишь грубое приближение к правильной оценке. Формально верная свёртка путей в оценку x — это обобщённый метод наименьших квадратов (GLS): x̂ = (AᵀΣ⁻¹A)⁻¹AᵀΣ⁻¹y, где A — матрица инцидентности путей (строка на путь, ±1 на использованных рёбрах), y — implied log-курсы, а Σ — та же ковариационная матрица шумов путей, что и выше. Текущая формула decay^(edges-2) — это диагональное приближение к Σ⁻¹ (короче путь → меньше шума → больший вес), которое полностью игнорирует то, что пути с общими рёбрами коррелированы (внедиагональные члены Σ). Заменить эвристику на честный GLS можно и на 8-вершинном графе без больших вычислительных затрат — это прямое развитие модели, а не смена архитектуры.

7. Как проверить корректность движка независимо от торговли

Прежде чем доверять сигналам этого DFS-движка на реальных котировках, полезно прогнать его на синтетических данных, где ответ известен заранее. Идея простая: если внутренне сконструировать матрицу курсов так, чтобы граф был полностью согласован (то есть выполнялось no-arbitrage условие — произведение курсов по любому циклу равно 1), то справедливая цена, посчитанная через любое количество путей, обязана совпасть с прямым курсом с точностью до погрешности double. Если это не так — ошибка в коде взвешивания или в самом DFS, а не в рынке.

//+------------------------------------------------------------------+
//| Диагностический self-test: строит согласованный граф без         |
//| арбитража на 8 синтетических валютах и проверяет, что            |
//| GraphFairValue возвращает те же значения, что и прямой курс.     |
//+------------------------------------------------------------------+
void SelfTestGraphEngine()
  {
   double R[CUR_COUNT][CUR_COUNT];
   bool   OK[CUR_COUNT][CUR_COUNT];

   //--- синтетический истинный курс каждой валюты к условному базису
   double fairToBasis[CUR_COUNT] = {1.00, 1.08, 1.27, 0.0067, 1.11, 0.74, 0.66, 0.61};

   ArrayInitialize(R, 0.0);
   ArrayInitialize(OK, false);

   for(int i=0;i<CUR_COUNT;i++)
      for(int j=0;j<CUR_COUNT;j++)
        {
         //--- no-arbitrage по построению: R[i][j] = fairToBasis[i] / fairToBasis[j]
         R[i][j]  = fairToBasis[i] / fairToBasis[j];
         OK[i][j] = true;
        }

   int paths = 0;
   double fair = GraphFairValue(0, 1, R, OK, paths);
   double direct = R[0][1];

   double errPts = MathAbs(fair - direct) / 0.00001;

   Print("SELFTEST direct=", DoubleToString(direct,6),
         " fair=", DoubleToString(fair,6),
         " paths=", paths,
         " errPts=", DoubleToString(errPts,3));

   if(errPts > 0.5)
      Print("SELFTEST FAILED: движок даёт расхождение на согласованном графе, проверьте DFS/веса");
   else
      Print("SELFTEST OK: справедливая цена совпадает с прямым курсом на no-arbitrage графе");
  }

Такой self-test стоит один раз прогнать в OnInit() под отладочным флагом при любом изменении DFS-логики или формулы весов: если после правки кода self-test перестал сходиться на заведомо согласованном графе, значит баг внесён именно правкой, а не рыночным шумом — это на порядок быстрее находит регрессии, чем попытка отлаживаться сразу на живых котировках.

8. Входные параметры и их смысл для настройки

Параметр Роль
InpMaxHops Глубина DFS. 3–4 — разумный баланс между полнотой охвата графа и вычислительной нагрузкой (см. таблицу сложности в разделе 2.4). Рост сверх этого даёт в основном усреднение шума, а не новую информацию (раздел 6)
InpHopWeightDecay Насколько сильно доверяем длинным путям. Ближе к 1.0 — длинные пути учитываются почти наравне с треугольниками; ближе к 0 — модель вырождается почти в классический треугольный арбитраж. Эвристическое приближение к диагонали правильной GLS-оценки (раздел 6)
InpMinPathsRequired Порог отказа от сигнала при разреженном графе (нерабочие символы, низкая ликвидность). Мера охвата, не независимости — см. раздел 6 про N_eff как более честную альтернативу
InpEntryDeviationPts / InpExitDeviationPts Пороги входа и выхода по mean-reversion, сравниваются с отклонением, скорректированным на издержки (см. AdjustedDeviationPts в разделе 3)
InpSlippageBufferPts Буфер сверх половины спреда, вычитаемый из отклонения перед сравнением с порогом входа — защита от торговли сигналами на mid, которые не переживают реальное исполнение
InpMaxHoldSeconds Максимальное время удержания позиции до принудительного закрытия вне зависимости от реверсии отклонения — защита от режимных сдвигов, когда отклонение не временное, а структурное (раздел 9)
InpMaxSpreadPts Обязательный фильтр — при большом спреде отклонение в пунктах перестаёт быть надёжной мерой

Бэктест системы проходит вполне бодро:


На реальных тиках с проскальзыванием 80 мс удалось достичь следующих результатов:


Эти цифры стоит воспринимать с поправкой на всё сказанное выше: они получены до внедрения AdjustedDeviationPts и лимита по времени удержания, поэтому часть эквити может объясняться сигналами на mid, не переживающими реальный спред, и удержанием позиций через режимные сдвиги, которые в итоге всё же развернулись. Пересчёт с учётом издержек — обязательный шаг перед тем, как доверять этим цифрам.

9. Честно про проблемы

Прежде чем делиться скриншотами бэктестов с идеальной кривой эквити — пять моментов, которые нужно проверить самому на своих данных, прежде чем доверять модели капитал:

  1. Мультивалютный бэктест в тестере стратегий MetaTrader 5 требует корректной синхронизации котировок по 28 символам одновременно. Разная история тиков/спредов по историческим данным брокера может создавать оптимистичное искажение результата по сравнению с реальной торговлей.
  2. Справедливая цена по mid и реальное исполнение по bid/ask — теперь разнесены явно (см. AdjustedDeviationPts в разделе 3), но стоит проверить конкретные значения InpSlippageBufferPts на своём брокере: на медленном исполнении или широких кроссах номинальный буфер может недооценивать реальные издержки, особенно на азиатской сессии.
  3. Веса InpHopWeightDecay — эвристика, а не выведенная из первых принципов величина (формальное обоснование и путь к GLS-замене — раздел 6). Оптимизацию этого параметра и порога InpMinPathsRequired нужно отдельно проверять на out-of-sample-отрезке истории, иначе высок риск подгонки под шум конкретного участка.
  4. Корреляция между путями и между сигналами (см. разделы 5 и 6) означает, что видимое число независимых сделок или путей в отчёте тестера завышает реальную диверсификацию: как несколько внешне разных сделок нередко являются одной и той же макро-ставкой на валюту, так и несколько внешне разных путей DFS нередко являются одной и той же проекцией общего 7-мерного набора базовых курсов.
  5. Mean-reversion логика уязвима к режимным сдвигам. Если одна валюта переоценивается рынком не временно, а структурно (смена ставок, геополитика, шок ликвидности), отклонение от справедливой цены не обязано быстро закрыться — стратегия может системно наращивать убыточную позицию, ожидая реверсии, которая произойдёт не скоро или не произойдёт вовсе. Это не ошибка алгоритма, а свойство самой mean-reversion идеи, и она принципиально не устраняется математикой графа. Этот риск управляется операционно, а не моделью: лимитом по времени удержания (InpMaxHoldSeconds, раздел 3) вместо ожидания идеального возврата к нулю, и/или более узким стопом относительно типичной амплитуды отклонения на конкретном инструменте. Часть сделок будет закрываться с убытком именно потому, что аномалия окажется не аномалией, а новым уровнем.

Пункты 1 и 2 — это вопросы калибровки и данных, пункт 3 частично устраняется переходом к GLS (раздел 6), пункт 4 — фундаментальное математическое свойство графа с 7 степенями свободы, а не недостаток реализации. Пункт 5 принципиально не устраняется никакой доработкой модели fair value — это системное свойство любой mean-reversion стратегии: она работает, пока структурные связи между инструментами стабильны, и требует операционных, а не статистических мер защиты, когда рынок меняет режим.

10. Куда расширять

Взвешивание по ликвидности ребра через отношение спреда к ATR. Уже встроено в текущую версию (раздел 2.2, EdgeLiquidityFactor()): интуитивно правильная нормализация — не абсолютный спред, а спред относительно типичной волатильности инструмента, поскольку 1 пункт спреда на паре с ATR(H1) в 5 пунктов критичен, а на паре с ATR(H1) в 80 пунктов — почти незаметен:

//+------------------------------------------------------------------+
//| Фактор ликвидности ребра графа в диапазоне (0, 1]. Единица —     |
//| спред пренебрежимо мал относительно ATR(H1); стремится к нулю    |
//| по мере роста относительного спреда. Используется как множитель  |
//| веса пути в DFSFairValue() наравне с InpHopWeightDecay.          |
//+------------------------------------------------------------------+
double EdgeLiquidityFactor(const int curFrom, const int curTo)
  {
   string sym = EdgeSymbol(curFrom, curTo);       // прямая или обратная котировка ребра
   if(sym == "") return(1.0);               // тривиальное ребро i==i

   double spreadPts = SymSpreadPts(sym);
   double atrPts    = AtrH1Pts(sym);
   if(atrPts <= 0.0) return(1.0);

   double relSpread = spreadPts / atrPts;
   //--- Ограниченный убывающий фактор: 1 при relSpread=0, стремится к 0 при росте relSpread.
   return(1.0 / (1.0 + relSpread));
  }

Это ровно тот вид нормализации, который просился в обсуждении: отношение среднего спреда к ATR, приведённое к единой безразмерной шкале (0, 1]. Дальнейшее уточнение — заменить AtrH1Pts на скользящую медиану вместо ATR, если распределение волатильности сильно скошено на конкретном инструменте.

N_eff и GLS вместо эвристики decay. Разобрано в разделе 6 — следующий логичный шаг: заменить финальную свёртку путей на честную GLS-оценку с ковариационной матрицей Σ, где диагональ приближённо задаётся декеем и ликвидностью, а внедиагональные члены — фактической корреляцией путей с общими рёбрами.

Экспозиция по валюте вместо лимита по числу сделок. Как обсуждалось в разделе 5, ограничение InpMaxOpenTrades стоит дополнить учётом нетто-объёма по каждой из 8 валют.

Кэширование DFS между тиками при неизменных курсах. Если за тик изменилась котировка только одного-двух символов, нет нужды пересчитывать все 28 пар заново — можно инвалидировать только те записи, где затронутая валюта участвует в пути, и кэшировать остальное. При большом числе символов и высокой частоте тиков это способно ощутимо снизить нагрузку без потери точности.

Заключение

GraphCycleFairValueScalper28 — редкий пример на MQL5.community, где рыночная идея (согласованность кросс-курсов) сделана аккуратно и "по науке", а не через набор индикаторов. Советник смотрит на 28 валютных пар как на один общий граф из 8 валют, перебирает разные пути между валютами и по ним считает "справедливую" цену пары.

Главная польза не в том, что "граф всегда прав" и не в том, что каждый новый путь — это отдельное подтверждение. Пути во многом зависят друг от друга, потому что вся система из 28 пар на самом деле описывается всего примерно 7 независимыми факторами (по сути — состоянием 8 валют относительно общего базиса). Поэтому большое число путей — это скорее "много вариантов пересчёта одного и того же", а не 20 независимых мнений рынка.

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

Перед реальной торговлей модель стоит прогнать на демо с полным набором из 28 символов. В процессе желательно:
- записывать, сколько путей реально находится для каждой пары в разное время суток;
- оценивать не только "сколько путей", но и сколько в них реально независимой информации (условный N_eff);
- следить за суммарной нагрузкой на каждую валюту (чтобы не получилось, что вы фактически поставили большой объём против одной валюты сразу в нескольких парах);
- считать сигнал с учётом спреда и комиссий, а не по "чистой" mid-цене.

И только после этих проверок решать, подходит ли стратегия для добавления в портфель.

Название файла Описание файла
GraphCycleFairValueScalper28.mq5 Мультивалютный советник справедливой цены на основе DFS-обхода валютного графа из 8 валют и 28 пар, с поправкой на издержки исполнения и ликвидность рёбер
Прикрепленные файлы |
Возможности Мастера MQL5, которые вам нужно знать (Часть 77): Использование индикаторов Gator и A/D (Accumulation/Distribution) Возможности Мастера MQL5, которые вам нужно знать (Часть 77): Использование индикаторов Gator и A/D (Accumulation/Distribution)
Осциллятор Gator Билла Вильямса и осциллятор накопления/распределения образуют еще одну пару индикаторов, которую можно совместно использовать в советнике на MQL5. Мы используем осциллятор Gator для подтверждения наличия тренда, а A/D – для его дополнительного подтверждения по объему. Как и прежде, для изучения этой пары индикаторов мы используем Мастер MQL5, чтобы создать и протестировать соответствующий советник.
Разработка самовосстанавливающегося советника в MQL5 (Часть 1): Архитектура постоянного хранения состояния сделки Разработка самовосстанавливающегося советника в MQL5 (Часть 1): Архитектура постоянного хранения состояния сделки
В этой статье показано, как построить базовую архитектуру постоянного хранения состояния для самовосстанавливающегося советника в MQL5 с использованием SQLite. Читатели узнают, как создать слой постоянного хранения состояния сделок, устойчивый к перезапускам терминала, выключениям и непредвиденным сбоям. В статье рассматривается интеграция SQLite в MetaTrader 5, управление жизненным циклом базы данных, структуры постоянно сохраняемого состояния сделки и восстановление рабочего состояния во время выполнения с использованием практических реализаций в MQL5.
Нейросети в трейдинге: От единственного прогноза к пространству рыночных сценариев (сценарное кросс-внимание) Нейросети в трейдинге: От единственного прогноза к пространству рыночных сценариев (сценарное кросс-внимание)
Статья вводит промежуточный слой между Forecast и торговой политикой. CNeuronScenarioCrossAttention по отдельности анализирует сценарии, не смешивая их траектории, включает оценку неопределённости и применяет априорные вероятности только после содержательной интерпретации. Для Актёра используется исторический контекст, Критик работает без памяти. На выходе — компактные контексты для принятия решения.
Алгоритм бактериальной эволюции Нумаоки для быстрой адаптации (NBE) Алгоритм бактериальной эволюции Нумаоки для быстрой адаптации (NBE)
Рассматриваем модель Нумаоки как агентную колонию с энергией, гибелью-делением и плазмидным переносом, ориентированную на быструю адаптацию. Переносим её в каркас C_AO: энергобаланс с популяционно-относительным приходом, хемотаксис-поиск, рулетка по энергии; приводим псевдокод и MQL5-реализацию. Проводим тестирование на Hilly, Forest и Megacity, показывая поведение и ограничения модели на статике для практического использования.