Валютный граф и справедливая цена 28 пар через DFS по кросс-путям
Валютный граф вместо интуиции: справедливая цена 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; } }
Три инженерных решения здесь заслуживают отдельного внимания:
- Работа в логарифмах, а не в произведениях курсов. logProduct накапливает сумму логарифмов пройденных курсов вместо их произведения. Это стандартный приём для избежания потери точности double при цепочке из 5–6 умножений мелких чисел, и — что важнее для следующего шага — превращает взвешенное геометрическое среднее в обычное арифметическое среднее логарифмов, что считается на порядок дешевле и устойчивее.
- Экспоненциальное затухание веса по длине пути, домноженное на ликвидность рёбер. Базовый множитель w = InpHopWeightDecay^(edges-2) честно остаётся эвристикой (см. раздел 6 о том, почему это лишь приближение к диагонали правильной GLS-оценки), но теперь он комбинируется с геометрическим средним EdgeLiquidityFactor() по всем рёбрам пути — формула и обоснование в разделе 10. Путь через два узких, редко торгуемых кросса теперь получает меньший вес даже при той же длине, что и путь через ликвидные majors.
- 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. Честно про проблемы
Прежде чем делиться скриншотами бэктестов с идеальной кривой эквити — пять моментов, которые нужно проверить самому на своих данных, прежде чем доверять модели капитал:
- Мультивалютный бэктест в тестере стратегий MetaTrader 5 требует корректной синхронизации котировок по 28 символам одновременно. Разная история тиков/спредов по историческим данным брокера может создавать оптимистичное искажение результата по сравнению с реальной торговлей.
- Справедливая цена по mid и реальное исполнение по bid/ask — теперь разнесены явно (см. AdjustedDeviationPts в разделе 3), но стоит проверить конкретные значения InpSlippageBufferPts на своём брокере: на медленном исполнении или широких кроссах номинальный буфер может недооценивать реальные издержки, особенно на азиатской сессии.
- Веса InpHopWeightDecay — эвристика, а не выведенная из первых принципов величина (формальное обоснование и путь к GLS-замене — раздел 6). Оптимизацию этого параметра и порога InpMinPathsRequired нужно отдельно проверять на out-of-sample-отрезке истории, иначе высок риск подгонки под шум конкретного участка.
- Корреляция между путями и между сигналами (см. разделы 5 и 6) означает, что видимое число независимых сделок или путей в отчёте тестера завышает реальную диверсификацию: как несколько внешне разных сделок нередко являются одной и той же макро-ставкой на валюту, так и несколько внешне разных путей DFS нередко являются одной и той же проекцией общего 7-мерного набора базовых курсов.
- 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 пар, с поправкой на издержки исполнения и ликвидность рёбер |
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Возможности Мастера MQL5, которые вам нужно знать (Часть 77): Использование индикаторов Gator и A/D (Accumulation/Distribution)
Разработка самовосстанавливающегося советника в MQL5 (Часть 1): Архитектура постоянного хранения состояния сделки
Нейросети в трейдинге: От единственного прогноза к пространству рыночных сценариев (сценарное кросс-внимание)
Алгоритм бактериальной эволюции Нумаоки для быстрой адаптации (NBE)
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования