Strategies

Moving-average crossover

A study of the dual simple-moving-average cross, filled on the next observation. Educational only, not a signal or a forecast.

  • Trend
  • Introductory
  • Daily
  • Python
  • C#
  • MQL5 illustrative

Educational study of a published rule. It is not a signal, a recommendation, or a forecast. The series is the ECB daily US dollar/euro reference rate from 2 January 2020 to 31 December 2024, one rate per TARGET business day, copied into open, high, low and close. Source: ECB statistics. The study charges a spread of 0.01 percent of the rate, a fraction of 0.0001, once, against the trader. Commission is zero. Swap and slippage are not modelled. The script does not print a return. Past performance does not predict future results.

Idea

Two simple averages of the same series. The fast one uses 10 observations and the slow one uses 30. When the fast average crosses above the slow average, the study wants to be long. When it crosses below, the study wants to be short. Until the first cross, it is flat.

A simple average at observation i is the mean of the period rates ending at i. It does not use i + 1. The cross also needs the previous pair of averages, so the first decision is the first observation where both averages exist on that bar and on the bar before it. With these lengths, that is observation index 30 in a zero-based series.

The averages are a smoothing. They do not measure a range, a volume, or an interest differential. The rule is a trend rule in the narrow sense that it stays with the side of the last cross.

Where the rule comes from

Brock, Lakonishok and LeBaron (1992) set out a moving-average rule of this shape, and a trading-range break, and tested both on the Dow Jones Industrial Average from 1897 to 1986. This page uses the shape of the moving-average rule as a worked example. It does not reproduce their test, it does not use their parameter grid, and the series here is a euro reference rate rather than that index. Their paper is the citation for the rule, not a result this site is repeating.

Ten and thirty are round lengths chosen so the code is easy to read. They were not searched against the sample.

How a decision becomes a fill

The signal is known only once that observation’s rate is in. Trading at that same rate, on the strength of an average that includes it, assumes you can deal at a price you have already used. These studies do not. The fill is the next observation’s rate, then moved against the trader by the spread fraction.

The sample makes that limitation obvious. The ECB file has one number per day. Open, high, low and close in the code are that same number, so there is no intraday path and no traded high or low. At a rate of 1.10, a spread fraction of 0.0001 moves the fill by 0.00011. That figure is the assumption in the constructor, not a quote from a broker. A position held overnight on a retail account would also meet a swap. Swap is not in the study. Neither is slippage.

Python records the signal, then asks backtesting.py 0.6 to place a market order with trade_on_close left false, spread set to 0.0001, commission set to 0, and margin set to 1. The library’s own trade count is a count of closed round trips. It is not the same number as the study’s count of position changes, and neither number is a return. The script does not read the return column. C# applies the same signal rule and the same spread fraction in the shared report, and the two signal digests are required to match.

The causal check rebuilds the signal on prefixes of the file. If observation i changed when later rows were removed, the run would stop.

Weaknesses

An average cross is late by construction. The slow average still contains rates from before the turn, so the cross arrives after the rate has already moved. The exit is the opposite cross, which can give back a large part of the open move. There is no stop in this rule. Putting one in, or switching to an exponential average, produces a different system. This page does not do that quietly.

The same lengths on a one-minute chart are not the same rule in economic terms. The spread is a much larger share of a small bar. This study is daily because the file is daily. It does not claim the lengths transfer.

Fitting is easy and is not done here. Changing 10 and 30 because of this particular file would be a search on the only sample. Five years of one reference rate is a short window, there is no second window held back, and a reference rate is not a dealing price. A cross that shows up in the signal count is evidence that the code took the branch. It is not evidence about a live account.

Look-ahead usually arrives as a centred average, a window that includes the current high when the current close is the fill, or an indicator seeded with the whole series in a way that index i can see index i + 1. The average in this file is a trailing sum. The prefix check is there so that a later edit cannot break that without failing the run.

On a demo account

Write the lengths down before you open a chart: 10, 30, simple average, close of a completed bar, opposite cross as the only exit. Use a demo account and the smallest size that account allows. The MQL5 sketch refuses anything other than a demo account, and it was not compiled here.

Act on a closed bar. The bar that is still forming is not an input. Keep a log of the signal time, the rate you assumed, and the demo fill, including the spread and any swap the platform charged. Those two costs are the gap between this study and a demo ticket.

Do not change the lengths because the last few demo fills made or lost money. A handful of fills cannot identify a better length, and searching them on the same prices is how a small sample gets fitted. A demo result does not predict a live result, and neither predicts the next one.

Implementations

The decision uses only data up to that observation. The fill is the next observation. Python and C# are run against the ECB file and must produce the same signal digest. The MQL5 file is illustrative: it was not compiled or run in MetaTrader, and a chart has high and low prices that this sample does not. The code is released under the MIT licence. The ECB file is not.

Python

#!/usr/bin/env python3
# Copyright (c) 2026 fxtradingguide.com
# SPDX-License-Identifier: MIT

"""Moving-average crossover study.

Go long when the fast simple average crosses above the slow one, and short
on the opposite cross. The decision uses that observation's close. The fill
is the next observation. Nothing here is a forecast or a recommendation.
"""

from __future__ import annotations

import sys
from pathlib import Path

import pandas as pd
from backtesting import Backtest

ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(ROOT / "strategies" / "shared" / "python"))

from fxstudy.data import assert_sample, load_observations
from fxstudy.library import SignalStrategy
from fxstudy.report import (
    MARGIN,
    SPREAD_FRACTION,
    STARTING_CASH,
    assert_causal,
    write_report,
)

STUDY = "moving-average-crossover"
FAST = 10
SLOW = 30


def sma(values: list[float], period: int) -> list[float | None]:
    """Simple moving average. Index i uses values[i - period + 1 : i + 1] only."""
    if period <= 0:
        raise SystemExit("SMA period must be positive")
    out: list[float | None] = [None] * len(values)
    window = 0.0
    for index, price in enumerate(values):
        window += price
        if index >= period:
            window -= values[index - period]
        if index >= period - 1:
            out[index] = window / period
    return out


def signals(rates: list[float], fast: int = FAST, slow: int = SLOW) -> list[int | None]:
    """Desired position after each close: 1 long, -1 short, 0 flat, None while warming up."""
    if fast >= slow:
        raise SystemExit("the fast average must be shorter than the slow average")
    fast_average = sma(rates, fast)
    slow_average = sma(rates, slow)
    position = 0
    desired: list[int | None] = []
    for index in range(len(rates)):
        previous = index - 1
        ready = (
            previous >= 0
            and fast_average[index] is not None
            and slow_average[index] is not None
            and fast_average[previous] is not None
            and slow_average[previous] is not None
        )
        if not ready:
            desired.append(None)
            continue
        crossed_up = fast_average[previous] <= slow_average[previous] and fast_average[index] > slow_average[index]
        crossed_down = fast_average[previous] >= slow_average[previous] and fast_average[index] < slow_average[index]
        if crossed_up:
            position = 1
        elif crossed_down:
            position = -1
        desired.append(position)
    return desired


def _assert_sma() -> None:
    averages = sma([1, 2, 3, 4, 5], 3)
    if averages != [None, None, 2, 3, 4]:
        raise SystemExit(f"SMA fixture failed: {averages}")


def library_trade_count(dates: list[str], rates: list[float], desired: list[int | None]) -> int:
    """Run the signal through backtesting.py. The return column is not read."""
    frame = pd.DataFrame(
        {
            "Open": rates,
            "High": rates,
            "Low": rates,
            "Close": rates,
            "Signal": [float("nan") if value is None else float(value) for value in desired],
        },
        index=pd.to_datetime(dates),
    )
    backtest = Backtest(
        frame,
        SignalStrategy,
        cash=STARTING_CASH,
        spread=SPREAD_FRACTION,
        commission=0.0,
        margin=MARGIN,
        trade_on_close=False,
        hedging=False,
        exclusive_orders=True,
    )
    stats = backtest.run()
    return int(stats["# Trades"])


def main() -> None:
    _assert_sma()
    observations = load_observations()
    assert_sample(observations)
    rates = [item.rate for item in observations]
    assert_causal(len(rates), lambda end: signals(rates[:end]))
    desired = signals(rates)
    trades = library_trade_count([item.date for item in observations], rates, desired)
    write_report(STUDY, "python", observations, desired, str(trades))


if __name__ == "__main__":
    main()

This file on GitHub

C#

// Copyright (c) 2026 fxtradingguide.com
// SPDX-License-Identifier: MIT

using FxTradingGuide.Studies;

namespace FxTradingGuide.Strategies.MovingAverageCrossover;

/// <summary>
/// Go long when the fast simple average crosses above the slow one, and short
/// on the opposite cross. The decision uses that observation's close. The fill,
/// in <see cref="StudyReport"/>, is the next observation.
/// </summary>
public static class MovingAverageCrossover
{
    public const int Fast = 10;
    public const int Slow = 30;

    public static double?[] Sma(IReadOnlyList<double> values, int period)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(period);
        var result = new double?[values.Count];
        double window = 0;
        for (var index = 0; index < values.Count; index++)
        {
            window += values[index];
            if (index >= period)
            {
                window -= values[index - period];
            }

            if (index >= period - 1)
            {
                result[index] = window / period;
            }
        }

        return result;
    }

    public static int?[] Signals(IReadOnlyList<double> rates, int fast = Fast, int slow = Slow)
    {
        if (fast >= slow)
        {
            throw new ArgumentOutOfRangeException(nameof(fast), "the fast average must be shorter than the slow average");
        }

        var fastAverage = Sma(rates, fast);
        var slowAverage = Sma(rates, slow);
        var position = 0;
        var desired = new int?[rates.Count];
        for (var index = 0; index < rates.Count; index++)
        {
            var previous = index - 1;
            var ready = previous >= 0
                && fastAverage[index] is not null
                && slowAverage[index] is not null
                && fastAverage[previous] is not null
                && slowAverage[previous] is not null;
            if (!ready)
            {
                desired[index] = null;
                continue;
            }

            var crossedUp = fastAverage[previous] <= slowAverage[previous] && fastAverage[index] > slowAverage[index];
            var crossedDown = fastAverage[previous] >= slowAverage[previous] && fastAverage[index] < slowAverage[index];
            if (crossedUp)
            {
                position = 1;
            }
            else if (crossedDown)
            {
                position = -1;
            }

            desired[index] = position;
        }

        return desired;
    }

    public static void AssertFixture()
    {
        var averages = Sma(new[] { 1d, 2d, 3d, 4d, 5d }, 3);
        if (averages[0] is not null || averages[1] is not null
            || averages[2] != 2d || averages[3] != 3d || averages[4] != 4d)
        {
            throw new InvalidOperationException("SMA fixture failed");
        }
    }
}

public static class Program
{
    public static void Main()
    {
        MovingAverageCrossover.AssertFixture();
        var observations = SampleSeries.Load();
        var rates = observations.Select(item => item.Rate).ToArray();
        StudyReport.AssertCausal(rates.Length, end =>
        {
            var slice = new double[end];
            Array.Copy(rates, slice, end);
            return MovingAverageCrossover.Signals(slice);
        });
        StudyReport.Write("moving-average-crossover", observations, MovingAverageCrossover.Signals(rates));
    }
}

This file on GitHub

MQL5 — Illustrative. This file was not compiled or run in MetaTrader. It uses the chart's bars, which the ECB sample does not have.

//+------------------------------------------------------------------+
//| MaCrossover.mq5                                                  |
//| Illustrative only. This file was not compiled or run.           |
//| Copyright (c) 2026 fxtradingguide.com                            |
//| SPDX-License-Identifier: MIT                                     |
//| https://fxtradingguide.com/strategies/moving-average-crossover/  |
//+------------------------------------------------------------------+
#property copyright "Copyright (c) 2026 fxtradingguide.com"
#property link      "https://fxtradingguide.com/strategies/moving-average-crossover/"
#property version   "1.00"
#property description "Educational study of a simple-average cross. Not a signal."
#property description "Orders are refused unless the account is a demo account."

#include <Trade/Trade.mqh>

input int    FastPeriod = 10;    // fast average, closed bars
input int    SlowPeriod = 30;    // slow average, closed bars
input int    Lookback   = 400;   // closed bars used to rebuild the position
input double Lots       = 0.01;  // demo size, not a risk model

CTrade trade;
int    fastHandle = INVALID_HANDLE;
int    slowHandle = INVALID_HANDLE;
datetime lastClosedBar = 0;

int OnInit()
{
   if(AccountInfoInteger(ACCOUNT_TRADE_MODE) != ACCOUNT_TRADE_MODE_DEMO)
   {
      Print("Refusing to run: this study sends orders only on a demo account.");
      return INIT_FAILED;
   }
   if(FastPeriod <= 0 || SlowPeriod <= FastPeriod)
   {
      Print("The fast average must be a shorter positive period than the slow one.");
      return INIT_PARAMETERS_INCORRECT;
   }
   if(Lots <= 0.0)
      return INIT_PARAMETERS_INCORRECT;

   fastHandle = iMA(_Symbol, PERIOD_CURRENT, FastPeriod, 0, MODE_SMA, PRICE_CLOSE);
   slowHandle = iMA(_Symbol, PERIOD_CURRENT, SlowPeriod, 0, MODE_SMA, PRICE_CLOSE);
   if(fastHandle == INVALID_HANDLE || slowHandle == INVALID_HANDLE)
   {
      Print("iMA failed. Error ", GetLastError());
      return INIT_FAILED;
   }
   return INIT_SUCCEEDED;
}

void OnDeinit(const int reason)
{
   if(fastHandle != INVALID_HANDLE)
      IndicatorRelease(fastHandle);
   if(slowHandle != INVALID_HANDLE)
      IndicatorRelease(slowHandle);
}

void OnTick()
{
   datetime closed = iTime(_Symbol, PERIOD_CURRENT, 1);
   if(closed == 0 || closed == lastClosedBar)
      return;
   lastClosedBar = closed;

   // Rebuild from closed bars so a reload does not keep a private position.
   int target = 0;
   if(!DesiredPosition(target))
      return;
   AlignDemoPosition(target);
}

bool DesiredPosition(int &target)
{
   int available = BarsCalculated(fastHandle);
   int slowReady = BarsCalculated(slowHandle);
   int count = MathMin(Lookback, MathMin(available, slowReady) - 1);
   if(count < SlowPeriod + 2)
      return false;

   double fast[];
   double slow[];
   ArraySetAsSeries(fast, false);
   ArraySetAsSeries(slow, false);
   // Shift 1 is the last closed bar. With a non-series array, index 0 is the
   // oldest copied bar and the last index is that closed bar.
   if(CopyBuffer(fastHandle, 0, 1, count, fast) != count)
      return false;
   if(CopyBuffer(slowHandle, 0, 1, count, slow) != count)
      return false;

   int position = 0;
   for(int i = 1; i < count; i++)
   {
      if(fast[i - 1] <= slow[i - 1] && fast[i] > slow[i])
         position = 1;
      else if(fast[i - 1] >= slow[i - 1] && fast[i] < slow[i])
         position = -1;
   }
   target = position;
   return true;
}

int OpenDirection()
{
   if(!PositionSelect(_Symbol))
      return 0;
   long type = PositionGetInteger(POSITION_TYPE);
   if(type == POSITION_TYPE_BUY)
      return 1;
   if(type == POSITION_TYPE_SELL)
      return -1;
   return 0;
}

void AlignDemoPosition(const int target)
{
   // Netting account assumed. A hedging account can hold both sides; this
   // study does not try to. Closing and opening in one tick can also fail
   // if the close is not yet confirmed. This is a sketch, not a terminal test.
   int current = OpenDirection();
   if(current == target)
      return;
   if(current != 0 && !trade.PositionClose(_Symbol))
   {
      Print("Demo close was not accepted: ", trade.ResultRetcodeDescription());
      return;
   }
   if(target == 1)
      trade.Buy(Lots);
   else if(target == -1)
      trade.Sell(Lots);
}

This file on GitHub

The order adapter and the shared report live next to the rule, so the page above is the decision and the links below are the fill.

Glossary

These are the directory’s definitions for words this study uses. They are not part of the rule, and they are not a recommendation.

Software catalogue

The Python file imports backtesting.py. MetaTrader 5 itself is in the platform directory below. The catalogue also lists the MetaTrader 5 Python package and the MQL5 Code Base. This study does not import that package, and the MQL5 file is not a Code Base entry.

Published broker costs

The spread in this study is one assumed fraction of the reference rate, applied on every fill. Swap is not modelled, and slippage is not measured. Firms publish their own spread, commission and financing figures, and those figures are not this assumption. The broker comparison sets the published figures side by side, with the source and the last-checked date. It does not score or rank firms, and it is not a recommendation.

Related guides

Quoting units such as pips, lots and leverage are explained in How FX trading works. What MetaTrader 4, MetaTrader 5, cTrader and TradingView are each for is in the platform guide. Neither page is a recommendation, and neither changes this study.

Sources and last review

Last reviewed 10 November 2026.

The code on this page is released under the MIT licence. The ECB series is not covered by that licence. Source: ECB statistics.