Notebooks/Trend & Momentum Confirmation
Signals·Combined Signals·Intermediate

Trend & Momentum Confirmation

Combine trend-following and momentum indicators into a dual-confirmation system that only signals when both agree.

trendmomentumconfirmation

Trend + Momentum Confirmation System

1. Imports and Setup

[1]
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots

2. System Overview

Trend + Momentum Confirmation gates momentum signals with a trend filter to avoid counter-trend trades.

LayerIndicatorRole
Trend filterEMA(50) slope or price vs EMA(200)Only allow longs above trend; shorts below
Momentum signalRSI divergence from 50Entry trigger

Signal logic:

  • Uptrend (close > EMA200) AND RSI crosses above 50 → Buy (+1)
  • Downtrend (close < EMA200) AND RSI crosses below 50 → Sell (−1)

Limitation: In sideways markets with price near EMA200, both conditions fire frequently, increasing noise.

The core of this notebook is the trend_momentum_confirmation_system function. This function takes OHLCV data as input and applies a trend filter (using an Exponential Moving Average - EMA) and a momentum signal (using the Relative Strength Index - RSI). It generates buy (+1) or sell (-1) signals based on whether the price is in an uptrend/downtrend and if the RSI crosses its midline (50) in the corresponding direction.

3. System Function Implementation

4. Data Generation

This section defines a utility function generate_data which creates synthetic OHLCV (Open, High, Low, Close, Volume) price data. This data is then used to demonstrate the trading system. The generate_data function simulates price movement using a geometric random walk, which provides a more dynamic and realistic dataset for testing than simple linear trends.

[2]
def generate_data(periods: int) -> pd.DataFrame:
    """
    Generate synthetic OHLCV price data using a geometric random walk.

    Parameters
    ----------
    periods : int
        Number of 1-minute bars to generate.

    Returns
    -------
    pd.DataFrame
        DataFrame with columns: open, high, low, close, volume, datetime.
    """
    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
    for i in range(periods):
        open_price  = last_close + np.random.normal(0, last_close * 0.0005)
        close_price = open_price + np.random.normal(0, last_close * 0.005)
        body_high   = max(open_price, close_price)
        body_low    = min(open_price, close_price)
        high_price  = max(body_high + abs(np.random.normal(0, last_close * 0.002)), open_price, close_price)
        low_price   = min(body_low  - abs(np.random.normal(0, last_close * 0.002)), open_price, close_price)
        if high_price < low_price:
            high_price, low_price = low_price, high_price
        price_data.append({
            "open":  max(1, int(open_price)),
            "high":  max(1, int(high_price)),
            "low":   max(1, int(low_price)),
            "close": max(1, int(close_price))
        })
        last_close = close_price
    df = pd.DataFrame(price_data, index=datetime_index)
    df.index.name = "datetime"
    df["volume"]   = np.random.uniform(100.0, 500.0, periods)
    df["datetime"] = df.index.to_series()
    return df.reset_index(drop=True)

df = generate_data(500)
display(df.head())
open high low close volume datetime
0 41995 42108 41921 42064 315.911928 2024-01-01 00:00:00+00:00
1 42081 42136 42021 42031 278.422847 2024-01-01 00:01:00+00:00
2 42047 42231 41960 42155 378.475784 2024-01-01 00:02:00+00:00
3 42118 42309 42082 42260 307.179675 2024-01-01 00:03:00+00:00
4 42266 42498 42169 42437 431.640902 2024-01-01 00:04:00+00:00
[3]
def trend_momentum_confirmation_system(
    df: pd.DataFrame,
    trend_period: int = 200,
    momentum_period: int = 14,
    rsi_mid: float = 50.0,
) -> pd.DataFrame:
    """
    Filter momentum (RSI) signals using a long-term trend (EMA) filter.

    Core logic
    ----------
    1. Compute EMA(trend_period) as the trend regime filter.
    2. Compute RSI(momentum_period) as the momentum trigger.
    3. Emit Buy (+1) when close > trend EMA AND RSI crosses above rsi_mid.
    4. Emit Sell (-1) when close < trend EMA AND RSI crosses below rsi_mid.

    Parameters
    ----------
    df : pd.DataFrame   OHLCV DataFrame.
    trend_period : int  EMA period for trend classification.
    momentum_period : int  RSI period.
    rsi_mid : float     RSI midline threshold for crossover detection.

    Returns
    -------
    pd.DataFrame with columns: ema_trend, rsi, trend_bias, signal.
    """
    df = df.copy().sort_values("datetime", ignore_index=True)

    # ── Trend filter ─────────────────────────────────────────────────────────
    df["ema_trend"]  = df["close"].ewm(span=trend_period, adjust=False).mean()
    df["trend_bias"] = np.where(df["close"] > df["ema_trend"], 1, -1)  # +1 up, -1 down

    # ── RSI ──────────────────────────────────────────────────────────────────
    delta = df["close"].diff()
    gain  = delta.clip(lower=0).rolling(momentum_period).mean()
    loss  = (-delta.clip(upper=0)).rolling(momentum_period).mean()
    df["rsi"] = 100 - 100 / (1 + gain / loss.replace(0, np.nan))

    # ── RSI midline crossover ─────────────────────────────────────────────────
    rsi_above_mid = (df["rsi"] > rsi_mid).astype(int)
    rsi_cross_up  = (rsi_above_mid.diff() == 1)   # crossed above midline
    rsi_cross_dn  = (rsi_above_mid.diff() == -1)  # crossed below midline

    # ── Confirmed signals (trend + momentum) ─────────────────────────────────
    df["signal"] = 0
    df.loc[(df["trend_bias"] == 1)  & rsi_cross_up, "signal"] =  1
    df.loc[(df["trend_bias"] == -1) & rsi_cross_dn, "signal"] = -1

    return df

df_signals = trend_momentum_confirmation_system(df)
print(df_signals["signal"].value_counts())
signal
 0    474
-1     18
 1      8
Name: count, dtype: int64

5. Function Output Interpretation

  • trend_bias: Derived from the price-vs-EMA200 relationship; acts as a gate that selects only momentum signals aligned with the dominant trend.
  • RSI midline crossover: Detects momentum inflection points rather than overbought/oversold extremes, generating more signals in trending conditions.

6. Visualization of Signals

This visualization plots the generated OHLCV price data along with the calculated EMA trend line and the buy/sell signals. The lower panel displays the RSI values and its midline at 50. This allows for a clear visual inspection of when and why signals are generated based on the interaction between price, trend, and momentum indicators.

[4]
buy_signals  = df_signals[df_signals["signal"] ==  1]
sell_signals = df_signals[df_signals["signal"] == -1]
fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
    subplot_titles=["Price + EMA Trend + Signals", "RSI"],
    row_heights=[0.65, 0.35])
fig.add_trace(go.Candlestick(x=df_signals["datetime"],
    open=df_signals["open"], high=df_signals["high"],
    low=df_signals["low"], close=df_signals["close"], name="Price"), row=1, col=1)
fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals["ema_trend"],
    mode="lines", name=f"EMA Trend", line=dict(color="blue", width=1.5)), row=1, col=1)
fig.add_trace(go.Scatter(x=buy_signals["datetime"],  y=buy_signals["low"]  * 0.999,
    mode="markers", marker=dict(symbol="triangle-up",   size=10, color="green"), name="Buy"),  row=1, col=1)
fig.add_trace(go.Scatter(x=sell_signals["datetime"], y=sell_signals["high"] * 1.001,
    mode="markers", marker=dict(symbol="triangle-down", size=10, color="red"),   name="Sell"), row=1, col=1)
fig.add_trace(go.Scatter(x=df_signals["datetime"], y=df_signals["rsi"],
    mode="lines", name="RSI", line=dict(color="purple", width=1)), row=2, col=1)
fig.add_hline(y=50, line_dash="dot", line_color="gray", row=2, col=1)
fig.update_layout(title_text="Trend + Momentum Confirmation",
    xaxis_rangeslider_visible=False, height=700, xaxis2_title="Datetime")
fig.show()