Signals·TA Strategies·Beginner

MA Crossover Strategy

The classic moving average crossover — SMA, EMA, and WMA variants with parameter sweep analysis and transaction cost sensitivity.

moving averagecrossovertrend

Strategy — Moving Average Crossover

A moving average crossover strategy generates a buy signal when a fast moving average crosses above a slow moving average, and a sell signal when the fast crosses below the slow. The logic is that when the recent average price rises above the longer-term average, momentum has shifted upward — and vice versa.


1. Dependency Installation

[1]
!pip install pandas numpy
Requirement already satisfied: pandas in /usr/local/lib/python3.12/dist-packages (2.2.2)
Requirement already satisfied: numpy in /usr/local/lib/python3.12/dist-packages (2.0.2)
Requirement already satisfied: python-dateutil>=2.8.2 in /usr/local/lib/python3.12/dist-packages (from pandas) (2.9.0.post0)
Requirement already satisfied: pytz>=2020.1 in /usr/local/lib/python3.12/dist-packages (from pandas) (2025.2)
Requirement already satisfied: tzdata>=2022.7 in /usr/local/lib/python3.12/dist-packages (from pandas) (2026.1)
Requirement already satisfied: six>=1.5 in /usr/local/lib/python3.12/dist-packages (from python-dateutil>=2.8.2->pandas) (1.17.0)

2. Library Imports

[2]
import warnings
warnings.filterwarnings("ignore")

import pandas as pd
import numpy as np

3. Dummy Dataset

[3]
raw_data = {
    "datetime": pd.date_range("2024-01-01", periods=30, freq="1min", tz="UTC"),
    "close": [42200,42150,42300,42250,42400,42350,42500,42450,42600,42550,
              42700,42650,42800,42750,42900,42850,43000,42950,43100,43050,
              43200,43150,43300,43250,43400,43350,43500,43450,43600,43550],
}

df = pd.DataFrame(raw_data)
df["datetime"] = pd.to_datetime(df["datetime"], utc=True)

5. Generate Synthetic OHLCV Data

[5]
def generate_data(periods: int) -> pd.DataFrame:
    """Generates a larger synthetic OHLCV dataset with more realistic price fluctuations."""
    start_date = pd.to_datetime("2024-01-01 00:00:00+00:00")
    datetime_index = pd.date_range(start_date, periods=periods, freq="1min", tz="UTC")

    price_data = []
    last_close = 42000 # Starting price
    volatility_scale = 0.005 # Controls the general magnitude of price changes
    wick_deviation_scale = 0.002 # Controls how much wicks extend beyond body

    for i in range(periods):
        # Open price drifts slightly from the previous close
        open_price = last_close + np.random.normal(0, last_close * volatility_scale * 0.1)

        # Simulate a price change to determine the closing price
        price_change = np.random.normal(0, last_close * volatility_scale)
        close_price = open_price + price_change

        # Determine the high and low of the candle body
        body_high = max(open_price, close_price)
        body_low = min(open_price, close_price)

        # Simulate wicks extending beyond the body
        # High wick should be above the body_high
        high_wick_extension = np.abs(np.random.normal(0, last_close * wick_deviation_scale))
        high_price = body_high + high_wick_extension

        # Low wick should be below the body_low
        low_wick_extension = np.abs(np.random.normal(0, last_close * wick_deviation_scale))
        low_price = body_low - low_wick_extension

        # Ensure OHLC integrity: High must be the absolute highest, Low the absolute lowest
        high_price = max(high_price, open_price, close_price)
        low_price = min(low_price, open_price, close_price)

        # Ensure High is never less than Low
        if high_price < low_price:
            high_price, low_price = low_price, high_price # Swap if somehow invalid

        # Ensure all values are positive integers
        open_price = max(1, int(open_price))
        high_price = max(1, int(high_price))
        low_price = max(1, int(low_price))
        close_price = max(1, int(close_price))

        price_data.append({
            "open": open_price,
            "high": high_price,
            "low": low_price,
            "close": close_price
        })
        last_close = close_price # Update last_close for the next iteration

    df_large = pd.DataFrame(price_data, index=datetime_index)
    df_large.index.name = "datetime"

    # Simulate volume with some fluctuation
    df_large["volume"] = np.random.uniform(100.0, 500.0, periods)

    return df_large

periods = 500 # Generate 500 data points
df_large = generate_data(periods)
df_large["datetime"] = df_large.index.to_series()
df_large = df_large.reset_index(drop=True)

print("--- Generated OHLCV Data ---")
display(df_large.head())
--- Generated OHLCV Data ---
open high low close volume datetime
0 42002 42176 41906 42058 277.351316 2024-01-01 00:00:00+00:00
1 42081 42105 41771 41802 134.905233 2024-01-01 00:01:00+00:00
2 41791 41883 41717 41804 346.562688 2024-01-01 00:02:00+00:00
3 41798 41841 41442 41546 228.239627 2024-01-01 00:03:00+00:00
4 41556 41761 41519 41739 439.591074 2024-01-01 00:04:00+00:00

6. Calculate SMA and EMA

[6]
df_indicators_large = df_large.copy()

df_indicators_large['sma_10'] = df_indicators_large['close'].rolling(window=10).mean()
df_indicators_large['ema_10'] = df_indicators_large['close'].ewm(span=10, adjust=False).mean()

print("--- OHLCV Data with SMA and EMA ---")
display(df_indicators_large.head(20))
--- OHLCV Data with SMA and EMA ---
open high low close volume datetime sma_10 ema_10
0 42002 42176 41906 42058 277.351316 2024-01-01 00:00:00+00:00 NaN 42058.000000
1 42081 42105 41771 41802 134.905233 2024-01-01 00:01:00+00:00 NaN 42011.454545
2 41791 41883 41717 41804 346.562688 2024-01-01 00:02:00+00:00 NaN 41973.735537
3 41798 41841 41442 41546 228.239627 2024-01-01 00:03:00+00:00 NaN 41895.965440
4 41556 41761 41519 41739 439.591074 2024-01-01 00:04:00+00:00 NaN 41867.426269
5 41727 41801 41491 41544 294.089409 2024-01-01 00:05:00+00:00 NaN 41808.621493
6 41557 41651 41417 41418 449.278058 2024-01-01 00:06:00+00:00 NaN 41737.599403
7 41442 41498 41275 41418 155.774768 2024-01-01 00:07:00+00:00 NaN 41679.490421
8 41396 41630 41255 41596 433.368532 2024-01-01 00:08:00+00:00 NaN 41664.310344
9 41589 41636 41541 41623 487.263698 2024-01-01 00:09:00+00:00 41654.8 41656.799372
10 41604 41651 41186 41262 261.534547 2024-01-01 00:10:00+00:00 41575.2 41585.017668
11 41290 41526 41246 41468 428.204618 2024-01-01 00:11:00+00:00 41541.8 41563.741729
12 41486 41682 41388 41565 130.942371 2024-01-01 00:12:00+00:00 41517.9 41563.970505
13 41561 41729 41547 41718 269.999606 2024-01-01 00:13:00+00:00 41535.1 41591.975868
14 41691 41726 41531 41658 410.846491 2024-01-01 00:14:00+00:00 41527.0 41603.980256
15 41688 41788 41298 41390 227.010811 2024-01-01 00:15:00+00:00 41511.6 41565.074755
16 41393 41481 41213 41221 418.065182 2024-01-01 00:16:00+00:00 41491.9 41502.515708
17 41231 41321 41053 41064 473.629663 2024-01-01 00:17:00+00:00 41456.5 41422.785580
18 41044 41106 40947 41105 240.114814 2024-01-01 00:18:00+00:00 41407.4 41365.006383
19 41090 41170 40967 41091 123.924873 2024-01-01 00:19:00+00:00 41354.2 41315.187041

4. Strategy Function

Moving Average Crossover Strategy

This strategy is based on two moving averages: a 'fast' one (short-term) and a 'slow' one (long-term).

  • Buy Signal: When the fast moving average crosses above the slow moving average, it suggests that the price is trending upwards and momentum is building. This is a potential signal to buy.
  • Sell Signal: Conversely, when the fast moving average crosses below the slow moving average, it indicates that the price is trending downwards and momentum is weakening. This is a potential signal to sell.

The ma_crossover_strategy function calculates these moving averages, identifies when these crossovers occur, and then labels them as 'BUY' or 'SELL' entries.

[8]
def ma_crossover_strategy(
    df_data:     pd.DataFrame,
    fast_window: int = 5,
    slow_window: int = 15,
) -> pd.DataFrame:
    df_data = df_data.copy().sort_values("datetime", ignore_index=True)

    df_data["fast_ma"] = df_data["close"].rolling(fast_window).mean()
    df_data["slow_ma"] = df_data["close"].rolling(slow_window).mean()

    # Signal: +1 when fast is above slow (bullish), -1 when below (bearish)
    df_data["signal"] = np.where(df_data["fast_ma"] > df_data["slow_ma"], 1, -1)

    # Crossover event: signal changes from previous row
    df_data["crossover"] = df_data["signal"].diff().ne(0) & df_data["signal"].notna()

    # Entry type at each crossover
    df_data["entry"] = np.where(
        df_data["crossover"] & (df_data["signal"] == 1),  "BUY",
        np.where(
        df_data["crossover"] & (df_data["signal"] == -1), "SELL", None)
    )

    return df_data[["datetime", "close", "fast_ma", "slow_ma", "signal", "crossover", "entry"]]

df_signals_large = ma_crossover_strategy(df_large, fast_window=10, slow_window=30)

print("--- MA Crossover Signals (for large dataset) ---")
display(df_signals_large.head())
print("\nEntries only:")
display(df_signals_large[df_signals_large["entry"].notna()])
--- MA Crossover Signals (for large dataset) ---
datetime close fast_ma slow_ma signal crossover entry
0 2024-01-01 00:00:00+00:00 42058 NaN NaN -1 True SELL
1 2024-01-01 00:01:00+00:00 41802 NaN NaN -1 False None
2 2024-01-01 00:02:00+00:00 41804 NaN NaN -1 False None
3 2024-01-01 00:03:00+00:00 41546 NaN NaN -1 False None
4 2024-01-01 00:04:00+00:00 41739 NaN NaN -1 False None

Entries only:
datetime close fast_ma slow_ma signal crossover entry
0 2024-01-01 00:00:00+00:00 42058 NaN NaN -1 True SELL
43 2024-01-01 00:43:00+00:00 41070 41094.8 41081.666667 1 True BUY
47 2024-01-01 00:47:00+00:00 40703 40969.3 40997.000000 -1 True SELL
74 2024-01-01 01:14:00+00:00 40775 40121.1 40109.600000 1 True BUY
186 2024-01-01 03:06:00+00:00 45451 46241.5 46343.033333 -1 True SELL
205 2024-01-01 03:25:00+00:00 46431 45995.4 45969.700000 1 True BUY
234 2024-01-01 03:54:00+00:00 47379 46851.1 46851.633333 -1 True SELL
236 2024-01-01 03:56:00+00:00 47753 46935.6 46924.633333 1 True BUY
286 2024-01-01 04:46:00+00:00 48490 48863.4 48868.066667 -1 True SELL
340 2024-01-01 05:40:00+00:00 46774 46321.2 46308.766667 1 True BUY
373 2024-01-01 06:13:00+00:00 46531 47067.3 47144.666667 -1 True SELL
398 2024-01-01 06:38:00+00:00 46664 46438.2 46386.100000 1 True BUY
436 2024-01-01 07:16:00+00:00 46683 47537.7 47602.466667 -1 True SELL

8. Combined Visualization: Candlestick, SMAs, EMAs, and Crossover Signals

[11]
import plotly.graph_objects as go

# Merge df_indicators_large with df_signals_large to get signals in one DataFrame
df_combined_plot = pd.merge(df_indicators_large, df_signals_large[['datetime', 'entry']], on='datetime', how='left')

fig = go.FigureWidget(data=[
    go.Candlestick(
        x=df_combined_plot["datetime"],
        open=df_combined_plot['open'],
        high=df_combined_plot['high'],
        low=df_combined_plot['low'],
        close=df_combined_plot['close'],
        name='Price'
    )
])

# Add SMA_10
fig.add_trace(go.Scatter(
    x=df_combined_plot["datetime"],
    y=df_combined_plot['sma_10'],
    mode='lines',
    name='SMA 10',
    line=dict(color='blue', width=1)
))

# Add EMA_10
fig.add_trace(go.Scatter(
    x=df_combined_plot["datetime"],
    y=df_combined_plot['ema_10'],
    mode='lines',
    name='EMA 10',
    line=dict(color='orange', width=1)
))

# Add Buy Signals
buy_signals = df_combined_plot[df_combined_plot['entry'] == 'BUY']
fig.add_trace(go.Scatter(
    x=buy_signals['datetime'],
    y=buy_signals['low'] * 0.99, # Plot below the low of the candle
    mode='markers',
    marker=dict(symbol='triangle-up', size=10, color='green'),
    name='Buy Signal'
))

# Add Sell Signals
sell_signals = df_combined_plot[df_combined_plot['entry'] == 'SELL']
fig.add_trace(go.Scatter(
    x=sell_signals['datetime'],
    y=sell_signals['high'] * 1.01, # Plot above the high of the candle
    mode='markers',
    marker=dict(symbol='triangle-down', size=10, color='red'),
    name='Sell Signal'
))

fig.update_layout(
    title_text='Candlestick Chart with SMA, EMA, and Crossover Signals',
    xaxis_rangeslider_visible=False,
    xaxis_title='Date',
    yaxis_title='Price',
    height=700,
    yaxis=dict(autorange=True),
    showlegend=True, # Ensure legend is visible
    legend=dict(x=1.02, y=1, xanchor='left', yanchor='top') # Position legend outside the plot
)

fig.show()