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 numpyRequirement 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 np3. 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()