Path Signatures for Lead-Lag Detection
Contents
- Introduction
- What the article delivers
- What a path signature is
- The math: levels, Levy area, Chen's identity
- Cross-correlation versus the signature
- Preprocessing the path
- The engine: CPathSignature
- The facade: CSigLeadLag
- The indicator: a Levy-area oscillator
- The Expert Advisor
- Verifying the engine
- Backtests
- Practical limitations
- How to use it in your own project
- Conclusion
Introduction
Two instruments rarely move at exactly the same instant. One often turns first and the other follows a few bars later. Traders have chased this "who moves first" question for a long time, usually with lagged cross-correlation: shift one series by k bars, correlate, and read off the k that gives the strongest number. That works, but it carries three assumptions that markets break. It needs you to guess the lag k in advance. It measures only a linear relationship. And it treats the lead as a single fixed number across the whole window, when in reality it drifts.
There is a different way to look at the same question. Instead of comparing two series at a shifted offset, treat their joint movement as a single curve in the plane and measure the signed area that curve sweeps out. That signed area is called the Levy area in rough-path theory. It is the second-level term of the path signature. The Levy area is a lag-free geometric statistic sensitive to ordering: it requires no lag parameter, makes no linearity assumption, and is local to the window. If you can read which series is ahead right now, and catch the instant that ordering flips, you have a signal that classical momentum and correlation both miss.
Path signatures are standard in machine learning for sequential data, but they are not available in the MQL5 codebase. This article adds them to MQL5. It implements a level-2 signature from scratch with an O(d^2) incremental update per step, includes required preprocessing, provides a small facade, and supplies both an indicator and an Expert Advisor. It is written for developers comfortable with classes, buffers, and the tester; the math is introduced from first principles and stays at the level the code requires.
What the article delivers
Before the theory, here is the finished result. The indicator builds a rolling two-channel path, signs it, and plots the Levy area in a separate window. A positive value means the first channel tends to lead the second over the window; a zero-line cross marks a flip in who moves first.

Fig. 1. The signature Levy-area oscillator on EURUSD H1: the line plots the rolling lead-lag reading and crosses its zero level when the leading channel flips.
The same reading drives a rule-based Expert Advisor whose logic is deliberately simple, so the reading, not a stack of filters, is what you are testing. Its tester result appears later in the article.
What a path signature is
Start with a path. Sample the closes of a symbol over a window and you have a sequence of numbers. Pair them with a second channel, a second symbol, the tick volume, or just a clock, and each bar becomes a point in the plane. Joining those points in order gives a curve. The path signature is a fixed set of numbers computed from that curve that describes its shape and, crucially, the order in which its moves happened.
The signature is organized in levels.
- Level 1 is the net change of each channel over the window. For a price channel this is just the window return.
- Level 2 is a set of iterated integrals, one for each ordered pair of channels. The antisymmetric part of level 2 is the Levy area, the signed area enclosed between the curve and the straight chord from its start to its end.
- Higher levels add finer shape detail. We stop at level 2, which is where the lead-lag information lives.
Two properties make the signature worth the effort. First, it captures order, not just magnitude: two paths that end at the same place but move their channels in a different sequence get different Levy areas, which cross-correlation at lag zero cannot distinguish. Second, the signature is invariant to how fast you traverse the path, so only the geometric shape matters, which is what you want when bar spacing is uneven.
The two paths in the figure make this concrete. Both start at the origin and end at the same corner, moving each channel up by one; only the order differs. Moving A first bows the curve below the diagonal for a positive signed area, moving B first flips the sign, and the two Levy areas are exactly plus and minus one half.

Fig. 2. Two right-angle paths from the same start to the same end; moving channel A first encloses a positive signed area, while moving B first flips the sign.
The output is also fixed-length. A two-channel path gives the same handful of numbers whether the window holds 40 bars or 400. That makes the signature a ready-made feature vector for a model later, but here we use its terms directly.
The math: levels, Levy area, Chen's identity
Write the path as a sequence of points X_0, X_1, up to X_N, each a vector with d channels. The increment of segment k is delta = X_k minus X_(k-1). Level 1 is the running sum of increments, which telescopes to the total displacement:
S1[i] = X_N[i] - X_0[i]
Level 2 is the double integral of one channel against another along the path:
S2[i][j] = integral integral over 0 < s < t of dX_i(s) dX_j(t) The Levy area of the channel pair (i, j) is the antisymmetric combination of the two level-2 terms:
Levy[i][j] = 0.5 * (S2[i][j] - S2[j][i]) Geometrically this is the signed area between the path and its chord: for a closed loop, positive for counter-clockwise circulation and negative for clockwise. That sign is the lead-lag reading. On a clean loop it tracks ordering exactly. On noisy market data, the path shape also contributes. Treat it as an ordering-sensitive statistic, not a definitive label. We return to this in the limitations.

Fig. 3. The Levy area is the signed area enclosed between a two-channel path and the straight chord joining its start and end.
A pair of shifted sine waves shows the reading at its cleanest. With X equal to sin(t) and Y equal to sin(t minus phi) over one period, X leads Y by the phase phi, the joint path traces a closed ellipse, and its enclosed signed area is pi times sin(phi), positive exactly when X leads Y. The figure shows the two waves and the loop they trace.

Fig. 4. Two sine waves where X leads Y by a fixed phase (left) and the closed loop their joint path traces (right), whose enclosed signed area is the Levy area.
Computing level 2 by evaluating a double integral for every window would be wasteful. Chen's identity gives an incremental update instead. When you extend a path by one straight segment with increment delta, the running signature updates like this:
S2_new[i][j] = S2[i][j] + S1[i] * delta[j] + 0.5 * delta[i] * delta[j]
S1_new[i] = S1[i] + delta[i] The order matters: the level-2 update uses the old level 1, so S2 must be updated before S1. Each segment costs d^2 multiply-adds for level 2 and d for level 1, so signing an N-point d-channel window is O(N * d^2). For the two-channel paths in this article that is four multiply-adds per bar.
It helps to run the update by hand once, on the left path above with points (0, 0), (1, 0), (1, 1). Starting from zero, the first increment (1, 0) sets S2[0][0] to 0.5 and S1 to (1, 0); the second increment (0, 1) adds S1[0] times 1 to S2[0][1] and leaves S2[1][0] at zero. The Levy area is 0.5 times (1 minus 0), so 0.5, the positive half we read off the picture, from the same three lines the code runs.
There is a built-in correctness check hiding in the algebra. The symmetric part of level 2 is fixed by level 1 through the shuffle relation:
S2[i][j] + S2[j][i] = S1[i] * S1[j]
Any correct level-2 signature satisfies this exactly, which gives an implementation-independent way to test the engine. We use it later.
Cross-correlation versus the signature
It helps to place the signature next to the tool it replaces. Lagged cross-correlation answers the same lead-lag question, so the honest comparison is what each one assumes and what each one costs.
| Aspect | Lagged cross-correlation | Signature Levy area |
|---|---|---|
| Choosing the lag | You must scan a range of k and pick one | No lag parameter at all |
| Relationship | Linear only | Nonlinear, model-free |
| Time variation | One number per window, assumes stationarity | Local to the window, reads a drifting lead |
| Output | A scalar at the chosen lag | Part of a fixed-length feature set that extends to full path shape |
Consider a concrete case. Two correlated symbols normally move together, but during a fast move one leads by a bar or two, and that lead is not constant. Cross-correlation would slide a window, test several lags, and still return one linear number that assumes the lead held steady. The Levy area reads the same window in one pass: a single signed value whose magnitude grows with how strongly one channel leads and whose sign says which is ahead, recomputed fresh on every window so it tracks the lead as it drifts.
None of this makes cross-correlation obsolete: it is easier to interpret and validate, and stays the right tool when the relationship is linear and steady. The signature is a complementary nonlinear statistic, not a wholesale replacement.
A small synthetic case makes the difference concrete, using the same shifted sine pair from Fig. 4, where X leads Y by the phase phi. Every entry below is closed form: the zero-lag correlation is cos(phi), the correlation peaks at 1 once you scan over lags, and the Levy area is pi times sin(phi).
| Phase phi (X leads Y) | Zero-lag correlation | Best lagged correlation (peak over lag) | Levy area |
|---|---|---|---|
| 0 deg | 1.00 | 1.00 | 0.00 |
| +30 deg | 0.87 | 1.00 | +1.57 |
| +90 deg | 0.00 | 1.00 | +3.14 |
| -30 deg | 0.87 | 1.00 | -1.57 |
| -90 deg | 0.00 | 1.00 | -3.14 |
Read down the columns. The zero-lag correlation cannot see direction: it returns 0.87 for both a plus and minus 30 degree lead, and 0.00 for both plus and minus 90, so recovering direction needs a lag scan. The Levy area separates the cases by sign in a single pass, plus 1.57 against minus 1.57, with no lag to scan.
Lead-lag itself is not new; what is new on MQL5 is the method, a lag-free nonlinear per-window measure from rough-path theory that extends to richer path features, with the Levy area as its first useful term.
Preprocessing the path
Raw closes are not ready to be signed. Two preparation steps matter, and a third is optional.
The first preparation step is time augmentation. A single price channel has a degenerate level 2, since with one channel there is no pair to form an area. Prepending a monotone time channel, a normalized clock from 0 to 1, gives a two-channel path whose Levy area against time measures the asymmetry or convexity of the move. The helper writes that clock into channel 0 and copies the original channels after it.
//+------------------------------------------------------------------+ //| AugmentTime - prepend a normalized time channel to a flat path. | //| | //| in[] : N rows x dim columns (row-major) | //| out[] : N rows x (dim+1) columns, channel 0 = k/(N-1) in [0,1], | //| remaining channels copied from in[]. | //+------------------------------------------------------------------+ void AugmentTime(const double &in[], int N, int dim, double &out[]) { int outDim = dim + 1; ArrayResize(out, N * outDim); for(int k = 0; k < N; k++) { out[k * outDim + 0] = (N > 1 ? (double)k / (double)(N - 1) : 0.0); for(int c = 0; c < dim; c++) out[k * outDim + 1 + c] = in[k * dim + c]; } }
The second step is normalization. Level-2 terms scale like the square of the path length, so an unnormalized window makes the Levy area drift with volatility rather than structure. Scaling each channel to the unit interval over its own window range keeps the reading comparable from window to window; the helper below does this in place on the flat row-major buffer.
//+------------------------------------------------------------------+ //| NormalizeWindow - scale each channel of a flat path to [0,1] | //|using its own min/max across the window. In place. A flat channel | //| (max == min) is set to zeros so it contributes no spurious area. | //+------------------------------------------------------------------+ void NormalizeWindow(double &points[], int N, int dim) { for(int c = 0; c < dim; c++) { double lo = points[c]; double hi = points[c]; for(int k = 1; k < N; k++) { double v = points[k * dim + c]; if(v < lo) lo = v; if(v > hi) hi = v; } double range = hi - lo; if(range > 0.0) { for(int k = 0; k < N; k++) points[k * dim + c] = (points[k * dim + c] - lo) / range; } else { for(int k = 0; k < N; k++) points[k * dim + c] = 0.0; } } }
Min-max scaling is the simplest choice, but it is outlier-sensitive: in a rolling window the scale jumps whenever an old extreme drops out, nudging the Levy area even when the structure has not changed. More robust options, a z-score, a median-and-MAD robust z-score, or volatility scaling, drop in where NormalizeWindow sits without touching the engine. Min-max is kept here for transparency; for production, compare it against a robust alternative on your own data.
The optional third step is the Flint-Hambly-Lyons lead-lag transform, which turns two aligned series into a staircase path whose Levy area is a lag-free estimator of the lead-lag relationship. At each step the lead channel A jumps while the lag channel B is held, then B catches up while A is held, so every right-angle corner contributes signed area in the same direction and the ordering is baked into the geometry before the signature runs. In the figure the dashed line is the original pairing; the red staircase is what actually gets signed.

Fig. 5. The Flint-Hambly-Lyons lead-lag staircase: the lead channel steps first and the lag channel follows, making the ordering explicit before the signature runs.
//+------------------------------------------------------------------+ //| LeadLag2 - Flint-Hambly-Lyons lead-lag transform of two aligned | //| series into a flat 2-channel staircase path. | //| | //| From (A_0,B_0) the path advances A first, then B, at every step: | //| (A_0,B_0) -> (A_1,B_0) -> (A_1,B_1) -> (A_2,B_1) -> (A_2,B_2) | //| producing M = 2N-1 rows. Channel 0 (A) is the LEAD, channel 1 | //| (B) the LAG. The Levy area of this path is a lag-free estimator | //| of the lead-lag relationship between A and B. | //+------------------------------------------------------------------+ void LeadLag2(const double &chA[], const double &chB[], int N, double &out[]) { int M = (N > 0 ? 2 * N - 1 : 0); ArrayResize(out, M * 2); if(N <= 0) return; //--- start point out[0] = chA[0]; out[1] = chB[0]; int row = 1; for(int k = 1; k < N; k++) { //--- lead (A) jumps, lag (B) held out[row * 2 + 0] = chA[k]; out[row * 2 + 1] = chB[k - 1]; row++; //--- lag (B) catches up, lead (A) held out[row * 2 + 0] = chA[k]; out[row * 2 + 1] = chB[k]; row++; } }
All three helpers live in PathSigPrep.mqh. The engine that consumes their output is next.
The engine: CPathSignature
The core class holds the running level-1 and level-2 accumulators and folds one segment at a time. Level 1 is a vector of size d; level 2 is a d-by-d matrix stored flat in row-major order. The whole class is small, and the heart of it is AddIncrement, a direct transcription of Chen's identity.
//+------------------------------------------------------------------+ //| CPathSignature - level-2 truncated signature over a path | //| | //| Quick usage: | //| CPathSignature sig; | //| sig.Init(2); | //| sig.Reset(); | //| double d[2]; | //| for each segment: fill d[] with the increment, AddIncrement(d)| //| double lead = sig.LevyArea(0, 1); | //+------------------------------------------------------------------+ class CPathSignature { private: int m_dim; // number of channels d double m_s1[]; // level-1 terms, size d double m_s2[]; // level-2 terms, flattened d*d (row-major) public: CPathSignature() : m_dim(0) {} ~CPathSignature() {} //--- allocate for a d-channel path (also resets the accumulators) void Init(int dim) { m_dim = (dim < 1 ? 1 : dim); ArrayResize(m_s1, m_dim); ArrayResize(m_s2, m_dim * m_dim); Reset(); } //--- zero the running signature (keeps the allocation) void Reset() { ArrayInitialize(m_s1, 0.0); ArrayInitialize(m_s2, 0.0); } //--- fold one straight segment (increment d, size m_dim) into the // running signature. Chen's identity for a linear segment: // S2_new[i][j] = S2[i][j] + S1[i]*d[j] + 0.5*d[i]*d[j] // S1_new[i] = S1[i] + d[i] // S2 MUST be updated before S1 (it uses the OLD S1). void AddIncrement(const double &d[]) { for(int i = 0; i < m_dim; i++) { double s1i = m_s1[i]; double di = d[i]; int row = i * m_dim; for(int j = 0; j < m_dim; j++) m_s2[row + j] += s1i * d[j] + 0.5 * di * d[j]; } for(int i = 0; i < m_dim; i++) m_s1[i] += d[i]; } //--- accessors int Dim() const { return m_dim; } double Level1(int i) const { return m_s1[i]; } double Level2(int i, int j) const { return m_s2[i * m_dim + j]; } //--- Levy area of the (i,j) channel pair. Positive means channel i // tends to move ahead of channel j over this window (i leads j). double LevyArea(int i, int j) const { return 0.5 * (m_s2[i * m_dim + j] - m_s2[j * m_dim + i]); } //--- shuffle residual: |S2[i][j] + S2[j][i] - S1[i]*S1[j]|, which is // exactly zero for a true level-2 signature. Used by the tests as // an implementation-independent self-consistency check. double ShuffleResidual(int i, int j) const { double sym = m_s2[i * m_dim + j] + m_s2[j * m_dim + i]; return MathAbs(sym - m_s1[i] * m_s1[j]); } };
Alongside the core update, the class exposes accessors and a ShuffleResidual method that returns the shuffle-relation violation for the tests. Notice that AddIncrement caches the old level 1 before updating level 2, exactly as Chen's identity requires, then advances level 1.
Signing a whole window is a thin wrapper. It resets the accumulator, walks the points, and folds each increment.
//+------------------------------------------------------------------+ //| ComputeWindow - run the full signature of a windowed path. | //| | //| points[] is a flat row-major buffer of N rows x dim columns: | //| the value of channel c at step k is points[k*dim + c]. | //| On return, sig holds the signature of the whole window. | //+------------------------------------------------------------------+ void ComputeWindow(CPathSignature &sig, const double &points[], int N, int dim) { sig.Init(dim); if(N < 2) return; double delta[]; ArrayResize(delta, dim); for(int k = 1; k < N; k++) { int cur = k * dim; int prev = (k - 1) * dim; for(int c = 0; c < dim; c++) delta[c] = points[cur + c] - points[prev + c]; sig.AddIncrement(delta); } }
This is the entire numerical core. Everything else is packaging.
The facade: CSigLeadLag
The indicator and the EA do not want to think about buffers and preprocessing order. The facade CSigLeadLag in PathSig.mqh hides that. You choose a mode, choose whether to normalize, hand it two aligned series, and read the Levy area. The three modes cover the practical cases.
- SIG_MODE_PAIR: channel 0 is series A, channel 1 is series B, the raw two-symbol or price-and-volume case.
- SIG_MODE_LEADLAG: the same two series, but embedded through the lead-lag staircase before signing.
- SIG_MODE_TIMEVALUE: a single series against a monotone clock, for path asymmetry.
Important: a positive reading does not mean the same thing in all three modes, so do not read it as "who leads whom" in every case. Always read the sign against the mode you selected, per the table below.
| Mode | What a positive sign means |
|---|---|
| SIG_MODE_PAIR | Orientation of the raw joint path of the two channels |
| SIG_MODE_LEADLAG | Channel A leads channel B |
| SIG_MODE_TIMEVALUE | Positive asymmetry or convexity of a single series, not a lead-lag |
The class stores the signature engine, the mode and normalize flags, and the reusable scratch buffers, and exposes the readings the indicator and EA consume. The GetFeatures method packs the five numbers used downstream into one array.
//+------------------------------------------------------------------+ //| CSigLeadLag - high-level 2-channel signature reader. | //| | //| Quick usage (indicator / EA): | //| CSigLeadLag sig; | //| sig.SetMode(SIG_MODE_PAIR); | //| sig.SetNormalize(true); | //| if(sig.ComputePair(a, b, N)) | //| double lead = sig.LevyArea(); // >0 : A leads B | //+------------------------------------------------------------------+ class CSigLeadLag { private: CPathSignature m_sig; ENUM_SIG_MODE m_mode; bool m_normalize; bool m_ready; //--- scratch buffers (reused across windows to avoid re-allocation) double m_bufA[]; double m_bufB[]; double m_path[]; public: CSigLeadLag() : m_mode(SIG_MODE_PAIR), m_normalize(true), m_ready(false) {} ~CSigLeadLag() {} //--- configuration void SetMode(ENUM_SIG_MODE m) { m_mode = m; } void SetNormalize(bool on) { m_normalize = on; } //--- compute the signature of two aligned series A[0..N-1], B[0..N-1]. // For SIG_MODE_TIMEVALUE only A is used (B may be an empty array). bool ComputePair(const double &A[], const double &B[], int N); //--- compute from a single scalar series (time-augmented path). bool ComputeSingle(const double &series[], int N) { double dummy[]; return ComputePair(series, dummy, N); } //--- readings (valid after a successful Compute*) bool IsReady() const { return m_ready; } double LevyArea() const { return m_sig.LevyArea(0, 1); } double Level1(int i) const { return m_sig.Level1(i); } double Level2(int i,int j)const { return m_sig.Level2(i, j); } //--- feature vector for the EA / future ML: // [ S1[0], S1[1], S2[0][1], S2[1][0], LevyArea(0,1) ] void GetFeatures(double &f[]) const { ArrayResize(f, 5); f[0] = m_sig.Level1(0); f[1] = m_sig.Level1(1); f[2] = m_sig.Level2(0, 1); f[3] = m_sig.Level2(1, 0); f[4] = m_sig.LevyArea(0, 1); } };
The facade keeps scratch buffers as members and reuses them across windows, so a rolling call in OnCalculate does not reallocate on every bar. The compute method builds the path per the chosen mode and signs it.
//+------------------------------------------------------------------+ //| Build the path per the configured mode and sign it. | //+------------------------------------------------------------------+ bool CSigLeadLag::ComputePair(const double &A[], const double &B[], int N) { m_ready = false; if(N < 2) return false; if(m_mode == SIG_MODE_TIMEVALUE) { //--- single series -> [time, value] ArrayResize(m_bufA, N); for(int k = 0; k < N; k++) m_bufA[k] = A[k]; if(m_normalize) { //--- normalize the value channel alone; time stays monotone 0..1 double lo = m_bufA[0], hi = m_bufA[0]; for(int k = 1; k < N; k++) { if(m_bufA[k] < lo) lo = m_bufA[k]; if(m_bufA[k] > hi) hi = m_bufA[k]; } double range = hi - lo; if(range > 0.0) for(int k = 0; k < N; k++) m_bufA[k] = (m_bufA[k] - lo) / range; else for(int k = 0; k < N; k++) m_bufA[k] = 0.0; } BuildTimeValuePath(m_bufA, N, m_path); ComputeWindow(m_sig, m_path, N, 2); m_ready = true; return true; } //--- two-channel modes need both series if(ArraySize(B) < N) return false; ArrayResize(m_bufA, N); ArrayResize(m_bufB, N); for(int k = 0; k < N; k++) { m_bufA[k] = A[k]; m_bufB[k] = B[k]; } if(m_normalize) { //--- pack as a pair, normalize both channels, unpack BuildPairPath(m_bufA, m_bufB, N, m_path); NormalizeWindow(m_path, N, 2); for(int k = 0; k < N; k++) { m_bufA[k] = m_path[k * 2 + 0]; m_bufB[k] = m_path[k * 2 + 1]; } } if(m_mode == SIG_MODE_LEADLAG) { LeadLag2(m_bufA, m_bufB, N, m_path); ComputeWindow(m_sig, m_path, 2 * N - 1, 2); } else // SIG_MODE_PAIR { BuildPairPath(m_bufA, m_bufB, N, m_path); ComputeWindow(m_sig, m_path, N, 2); } m_ready = true; return true; }
The method branches by mode. In time-value mode it scales the series and then prepends the clock. In two-channel mode it normalizes the pair and then either builds the lead-lag staircase or signs the raw pair. After a successful compute the reader calls LevyArea, Level1, or GetFeatures, which packs the five numbers S1[0], S1[1], S2[0][1], S2[1][0], and the Levy area into a vector for the EA and any later model.
The step order is deliberate: normalization runs first on the raw pair, because building the staircase first and normalizing afterward would rescale the manufactured corners and distort the ordering the transform exists to expose.
The indicator: a Levy-area oscillator
The indicator draws one line in a separate window: the Levy area of the rolling two-channel path, with a dotted zero level. It offers the three source choices as an input, so the same code plots price against volume, this chart against a second symbol, or time against price.
The inputs expose the window length, the source, and the two switches that change the reading: whether to embed through the lead-lag staircase and whether to normalize. The rest control recompute frequency and history depth.
//--- inputs: engine input int InpWindow = 60; // rolling window length input ENUM_LL_SOURCE InpSource = LL_PRICE_VOLUME;// channel source input string InpSymbolB = ""; // channel1 symbol (TWO_SYMBOLS mode) input bool InpUseLeadLag= false; // use lead-lag staircase embedding input bool InpNormalize = true; // normalize channels to [0,1] //--- inputs: performance / display input int InpStep = 3; // recompute every N bars (>=1) input int InpHistoryBars = 1500; // bars of history on first load (0 = all)
OnInit configures the facade once from the inputs and resolves the second symbol against this broker's naming when the two-symbol source is selected.
//+------------------------------------------------------------------+ //| OnInit | //+------------------------------------------------------------------+ int OnInit() { SetIndexBuffer(0, BufLevy, INDICATOR_DATA); SetIndexBuffer(1, BufSignal, INDICATOR_CALCULATIONS); PlotIndexSetInteger(0, PLOT_DRAW_BEGIN, InpWindow); PlotIndexSetDouble (0, PLOT_EMPTY_VALUE, EMPTY_VALUE); g_sig.SetNormalize(InpNormalize); g_sig.SetMode(InpUseLeadLag ? SIG_MODE_LEADLAG : (InpSource == LL_TIME_PRICE ? SIG_MODE_TIMEVALUE : SIG_MODE_PAIR)); if(InpSource == LL_TWO_SYMBOLS) { g_symB = ResolveSymbol(InpSymbolB); if(g_symB == "") { Print("[PSIG] InpSymbolB '", InpSymbolB, "' not found - configure a valid symbol."); return INIT_FAILED; } Print("[PSIG] channel1 symbol resolved to ", g_symB); } string src = (InpSource == LL_TWO_SYMBOLS ? "A vs " + g_symB : InpSource == LL_PRICE_VOLUME ? "price vs volume" : "time vs price"); IndicatorSetString(INDICATOR_SHORTNAME, StringFormat("Sig Levy(W=%d, %s%s)", InpWindow, src, InpUseLeadLag ? ", LL" : "")); IndicatorSetInteger(INDICATOR_DIGITS, 5); return INIT_SUCCEEDED; }
Computing a full signature on every tick is wasteful, so the indicator recomputes only on a step grid and holds the line flat between points. The window fill is where the source modes differ, and the two-symbol case has an alignment trap worth showing.
//+------------------------------------------------------------------+ //| Fill the two window channels for the bar range [base .. base+W-1]| //+------------------------------------------------------------------+ bool FillChannels(int base, int W, const datetime &time[], const double &close[], const long &tick_volume[], double &A[], double &B[]) { ArrayResize(A, W); ArrayResize(B, W); for(int k = 0; k < W; k++) A[k] = close[base + k]; if(InpSource == LL_TIME_PRICE) return true; // single series: B unused if(InpSource == LL_PRICE_VOLUME) { for(int k = 0; k < W; k++) B[k] = (double)tick_volume[base + k]; return true; } //--- TWO_SYMBOLS: align channel1 to this chart's bar times for(int k = 0; k < W; k++) { int sh = iBarShift(g_symB, PERIOD_CURRENT, time[base + k], false); if(sh < 0) return false; double c = iClose(g_symB, PERIOD_CURRENT, sh); if(c <= 0.0) return false; B[k] = c; } return true; }
The price and volume channels come straight off this chart, so they are index-aligned by construction. A second symbol is not: its bar at index k is not the same moment as this chart's bar at index k, because the two symbols can miss bars at different times. The two-symbol branch therefore looks up the second symbol by time, taking the bar whose timestamp matches this chart's bar, using iBarShift and iClose. Skipping that alignment is the classic multi-symbol bug, and it silently corrupts the Levy area rather than throwing an error.
The second symbol must also resolve against this broker's naming, which may carry a suffix such as an "m". The indicator tries the input name directly, then matches any symbol that starts with it, so "EURUSD" finds "EURUSDm" without hardcoding the suffix.
The Expert Advisor
The Expert Advisor turns the reading into trades with a deliberately plain rule, so that what you are testing is the signal and not a stack of filters. On each new bar it builds the rolling window from closed bars, signs it, and forms the feature vector. The entry rule uses two of those features: the Levy area, for the lead-lag direction, and the level-1 price term, for agreement with the net move.
The inputs split into the engine group, which mirrors the indicator, and a risk group: the entry threshold on the Levy area, the fixed lot, and the stop and target in points.
//--- inputs: engine input int InpWindow = 60; // rolling window length input ENUM_LL_SOURCE InpSource = LL_PRICE_VOLUME; // channel source input string InpSymbolB = ""; // channel1 symbol (TWO_SYMBOLS) input bool InpUseLeadLag = false; // lead-lag staircase embedding input bool InpNormalize = true; // normalize channels to [0,1] //--- inputs: signal / risk input double InpThreshold = 0.02; // |Levy| entry threshold input double InpLots = 0.10; // fixed lot size input int InpSL_Points = 400; // stop loss (points, 0 = none) input int InpTP_Points = 600; // take profit (points, 0 = none) input long InpMagic = 920145; // magic number
The window is filled from closed bars only. CopyClose pulls bars 1 to W, skipping the forming bar at index 0, and the arrays are flipped to oldest-first so the path runs forward in time. In price-volume mode the second channel is the tick volume of the same bars; in two-symbol mode it is the second symbol's close aligned by bar time.
//+------------------------------------------------------------------+ //| Build the two window channels (closed bars 1..InpWindow) | //+------------------------------------------------------------------+ bool BuildWindow(double &A[], double &B[]) { int W = InpWindow; double close[]; //--- closed bars only: shift 1..W (index 0 is the forming bar) if(CopyClose(_Symbol, PERIOD_CURRENT, 1, W, close) != W) return false; ArraySetAsSeries(close, false); // oldest -> newest ArrayResize(A, W); ArrayResize(B, W); for(int k = 0; k < W; k++) A[k] = close[k]; if(InpSource == LL_PRICE_VOLUME) { long vol[]; if(CopyTickVolume(_Symbol, PERIOD_CURRENT, 1, W, vol) != W) return false; ArraySetAsSeries(vol, false); for(int k = 0; k < W; k++) B[k] = (double)vol[k]; return true; } //--- TWO_SYMBOLS: align by bar time datetime tm[]; if(CopyTime(_Symbol, PERIOD_CURRENT, 1, W, tm) != W) return false; ArraySetAsSeries(tm, false); for(int k = 0; k < W; k++) { int sh = iBarShift(g_symB, PERIOD_CURRENT, tm[k], false); if(sh < 0) return false; double c = iClose(g_symB, PERIOD_CURRENT, sh); if(c <= 0.0) return false; B[k] = c; } return true; }
OnTick ties the pieces together. On each new bar it builds the window, signs it, and reads the Levy area together with the price term. It then applies the cross rule and places the order, holding one position at a time and leaving the exit to the fixed stop and target.
//+------------------------------------------------------------------+ //| OnTick | //+------------------------------------------------------------------+ void OnTick() { if(!IsNewBar()) return; if(Bars(_Symbol, PERIOD_CURRENT) < InpWindow + 2) return; double A[], B[]; if(!BuildWindow(A, B)) return; if(!g_sig.ComputePair(A, B, InpWindow)) return; double levy = g_sig.LevyArea(); double s1p = g_sig.Level1(0); // net price-channel move if(!g_havePrev) { g_prevLevy = levy; g_havePrev = true; return; } double T = InpThreshold; bool crossUp = (g_prevLevy <= T) && (levy > T); bool crossDn = (g_prevLevy >= -T) && (levy < -T); g_prevLevy = levy; if(HasPosition()) return; // one position at a time; SL/TP exit double sl = 0.0, tp = 0.0; double pt = SymbolInfoDouble(_Symbol, SYMBOL_POINT); if(crossUp && s1p > 0.0) { double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK); if(InpSL_Points > 0) sl = ask - InpSL_Points * pt; if(InpTP_Points > 0) tp = ask + InpTP_Points * pt; g_trade.Buy(InpLots, _Symbol, ask, sl, tp, "sigLL long"); } else if(crossDn && s1p < 0.0) { double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID); if(InpSL_Points > 0) sl = bid + InpSL_Points * pt; if(InpTP_Points > 0) tp = bid - InpTP_Points * pt; g_trade.Sell(InpLots, _Symbol, bid, sl, tp, "sigLL short"); } }
The rule reads plainly: buy when the Levy area crosses up through the positive threshold with the net price move up, and sell on the mirror case. The window uses closed bars only, since the forming bar would repaint within the bar, and the first computed bar only primes the previous value before any trade.
The rule reads two features because the Levy area alone says who leads, not where price is going: pairing the threshold cross with the sign of the level-1 price term keeps the EA from acting when the lead flips but price drifts the other way.
The five-number feature vector is the hook for more: the same GetFeatures output that feeds this hand-written rule can feed a logistic model or an ONNX network later, with this rule as the baseline to beat. Nothing about the engine changes; only the decision layer does.
Verifying the engine
A signature engine is only useful if its numbers are right, and the nice thing about signatures is that several cases have exact closed-form answers. The test script PathSig_Test_GroundTruth.mq5 checks the engine against them and prints a pass or fail line for each.
- Straight line: a path with no enclosed area has Levy area exactly zero, and its level-2 term equals half the product of the two displacements.
- Shuffle identity: on an arbitrary random path, S2[i][j] + S2[j][i] must equal S1[i] * S1[j] to machine precision.
- Reparametrization: the same corners resampled with 25 collinear points per segment must give the identical signature.
- Unit circle: a counter-clockwise unit circle encloses area pi, so its Levy area converges to pi.
- Lead-lag sines: for X = sin(t) and Y = sin(t minus phi) over one period, the Levy area equals pi * sin(phi), and its sign is positive because X leads Y.
- Lead-lag staircase: a hand-computed three-point example gives Levy area exactly 0.5.
Run the script on any chart and read the tally in the Experts tab: every case passes, and the final line reports zero failures. You do not have to take that on trust, since the script is attached and the checks are the closed-form values above.
The same synthetic paths are also exported to a CSV, and a short Python script recomputes their signatures independently and compares them against the compiled output. The MQL5 output matches the independent Python implementation to numerical precision (about 1e-11), and the analytic cases land on pi and pi * sin(phi). The engine is exact to the precision that matters.
Backtests
Two runs follow. The first is the single-symbol price-versus-volume path; the second is a genuine two-symbol case. Both are shown exactly as they came out, with no parameter search behind either.
Price versus volume on H1
The Expert Advisor was run on EURUSD H1 using the price-versus-volume path with default inputs.
| Setting | Value |
|---|---|
| Symbol / timeframe | EURUSD, H1 |
| Window | 60 bars |
| Source | Price versus tick volume |
| Levy threshold | 0.02 |
| Period / modelling | 2026.01.01 to 2026.07.01, history quality 100% |
The balance graph from the run is shown below.

Fig. 6. The balance curve of the signature lead-lag Expert Advisor in the Strategy Tester on EURUSD H1.
| Metric | Value |
|---|---|
| Net profit | 166.40 |
| Profit factor | 1.13 |
| Sharpe ratio | 0.64 |
| Total trades | 56 |
| Profitable trades | 24 (42.86%) |
| Maximal balance drawdown | 221.80 (2.19%) |
| Average win / average loss | 59.68 / -39.56 |
The result is modest but positive: a small edge from the plainest possible rule. The source is tick volume, which in FX is a proxy for activity rather than true traded volume, so the cleaner demonstration of the lead-lag idea is two genuinely related symbols, the second run.
A cross-symbol run: EURUSD versus GBPUSD
EURUSD and GBPUSD are two correlated majors that share the dollar leg, which is exactly the setting the lead-lag reading is built for: one of the pair routinely nudges before the other, and that lead drifts through the day. The same Expert Advisor drives this run with the source set to two symbols and the second symbol set to GBPUSD. Nothing about the engine changes; only the channel it reads against does.
| Setting | Value |
|---|---|
| Symbol / timeframe | EURUSD, H1 |
| Second symbol | GBPUSD |
| Window | 60 bars |
| Source | Two symbols (price versus GBPUSD close) |
| Levy threshold | 0.02 |
| Period / modelling | 2026.01.01 to 2026.06.28, every tick based on real ticks |
The balance and equity curve from the run is shown below.

Fig. 7. Balance and equity curve of the signature lead-lag Expert Advisor trading EURUSD against GBPUSD on H1, over the first half of 2026.
| Metric | Value |
|---|---|
| Net profit | 475.60 |
| Profit factor | 1.44 |
| Sharpe ratio | 2.55 |
| Total trades | 52 |
| Profitable trades | 26 (50.00%) |
| Maximal balance drawdown | 245.00 (2.32%) |
| Average win / average loss | 59.67 / -41.38 |
The two-symbol run is stronger than the price-volume baseline: a higher profit factor and a smoother curve, with wins and losses roughly even but the average win larger than the average loss. As before, it is a single unoptimized pass on one pair.
Take both runs as reproducible starting points. The lead-lag Levy area is the part that carries across symbols and timeframes; the threshold rule around it is what you replace, calibrating it to your own pairs and periods.
Practical limitations
The tool is a research framework first and a trading system second. A few limits should be stated plainly:
- Results are illustrative, not evidence of a persistent edge.
- Levy thresholds are instrument- and window-dependent, so calibrate them per market.
- Min-max normalization is simple but outlier-sensitive; a robust z-score or volatility scaling is steadier.
- Window length trades responsiveness for stability.
- Multi-symbol alignment by nearest bar is a practical approximation, not an exact match.
- Tick volume is a proxy for activity, not true traded volume.
- Near-zero readings may need a dead-zone or light smoothing before trading the crosses.
- The window is signed from scratch each step; a true rolling update is the next efficiency step.
- The Levy area is best used as a feature, not a standalone strategy.
How to use it in your own project
To put this to work, a sensible order of operations:
- Pick the source mode: two symbols for cross-market lead-lag, price versus volume for single-symbol activity, or time versus price for path asymmetry.
- Start with a window of 40 to 100 bars, normalization on.
- Attach the indicator first and learn what a meaningful move looks like on your instrument.
- Calibrate the threshold from a rolling percentile or z-score, not the 0.02 default.
- Only then test the EA, treating its rule as a baseline to improve.
Conclusion
We built the path signature in MQL5 from first principles: a level-2 truncated signature with an incremental Chen update, the preprocessing a market path needs, and a facade that turns two series into a single lead-lag reading. On top of it sit an oscillator that plots the Levy area and an Expert Advisor that trades a simple threshold rule.
The signature is not a single indicator; it is a small feature machine. The Levy area is the first term worth reading, and the same engine gives a fixed-length vector a model can learn from, which is the natural direction to take this next.
The files attached to this article are also available on Algo Forge. To reproduce everything, unpack MQL5.zip into your terminal data folder so each file lands in its own subfolder. Compile and run PathSig_Test_GroundTruth.mq5 first on any chart, and confirm the Experts tab reports every check passing before you attach the indicator or the Expert Advisor. The full file list is below.
| # | Filename | Type | Description |
|---|---|---|---|
| 1 | PathSignature.mqh | Include | Level-2 signature engine (CPathSignature) and ComputeWindow |
| 2 | PathSigPrep.mqh | Include | Time augmentation, normalization, and the lead-lag transform |
| 3 | PathSig.mqh | Include | Library entry point and the CSigLeadLag facade |
| 4 | SignatureLevyArea.mq5 | Indicator | Rolling Levy-area oscillator with three source modes |
| 5 | SignatureLeadLagEA.mq5 | Expert | Rule-based lead-lag trader driven by the Levy area |
| 6 | PathSig_Test_GroundTruth.mq5 | Script | Analytic ground-truth tests and CSV export |
| 7 | psig_crosscheck.py | Python | Independent signature cross-check against the MQL5 export |
| 8 | MQL5.zip | Archive | Archive with all project files in their subfolders; unpack it into the terminal data directory and every file lands in its required location |
Warning: All rights to these materials are reserved by MetaQuotes Ltd. Copying or reprinting of these materials in whole or in part is prohibited.
This article was written by a user of the site and reflects their personal views. MetaQuotes Ltd is not responsible for the accuracy of the information presented, nor for any consequences resulting from the use of the solutions, strategies or recommendations described.
Generating a Per-Symbol Trade Analytics PDF Report from MQL5
Measuring broker execution quality in MQL5: Why your live account doesn't match the backtest
Neural Networks in Trading: A Cross-Domain Time Series Forecasting Framework (Conclusion)
MCMC Sampling Methods: The Slice Sampling Algorithm
- Free trading apps
- Over 8,000 signals for copying
- Economic news for exploring financial markets
You agree to website policy and terms of use