Signals·TA Strategy Implementations·Intermediate

Rate of Change Strategy

Build a rate-of-change momentum strategy that measures price velocity as percentage change over configurable lookback periods, generating entry signals when momentum exceeds threshold extremes in either bullish or bearish direction.

trading-signalstrading-strategies

Strategy — Rate of Change Momentum


1–2. Installation and Imports

[ ]
import warnings; warnings.filterwarnings("ignore")
import pandas as pd
import numpy as np
import plotly.graph_objects as go
from plotly.subplots import make_subplots

!pip install pandas numpy plotly
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: plotly in /usr/local/lib/python3.12/dist-packages (5.24.1)
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: tenacity>=6.2.0 in /usr/local/lib/python3.12/dist-packages (from plotly) (9.1.4)
Requirement already satisfied: packaging in /usr/local/lib/python3.12/dist-packages (from plotly) (26.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)

3. Strategy Overview

Rate of Change (ROC) measures the percentage difference between the current close and the close N candles ago:

ROC = (Close[t] − Close[t−N]) / Close[t−N] × 100

Signal logic:

  • ROC > +threshold → Buy (+1): price is higher than N periods ago by more than the threshold — upward momentum is present and significant.
  • ROC < −threshold → Sell (−1): price is lower than N periods ago by more than the threshold — downward momentum is confirmed.
  • |ROC| ≤ threshold → No signal (0): momentum is insufficient; the move may be noise rather than a genuine directional impulse.

Why it works: ROC is the most direct measure of momentum — it simply asks "how much has price moved over N periods?" without any smoothing or normalization. Persistent positive ROC indicates a sustained buying pressure; persistent negative ROC indicates sustained selling. The threshold filters out minor oscillations that do not represent actionable momentum.

Relationship to other momentum indicators:

  • MACD uses two EMAs to measure momentum indirectly through convergence/divergence.
  • RSI normalizes momentum to a 0–100 scale relative to historical gains and losses.
  • ROC is direct, unsmoothed momentum — the raw material that other momentum indicators transform.

4. Data Generation

[ ]
def generate_data(periods: int) -> pd.DataFrame:
    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 41976 42266 41950 42163 368.127168 2024-01-01 00:00:00+00:00
1 42184 42203 41911 42002 457.427928 2024-01-01 00:01:00+00:00
2 42019 42048 41654 41686 190.011867 2024-01-01 00:02:00+00:00
3 41703 41706 41277 41308 378.205699 2024-01-01 00:03:00+00:00
4 41297 41297 41099 41202 185.551343 2024-01-01 00:04:00+00:00

5. Strategy Function

[ ]
def rate_of_change_strategy(
    df:          pd.DataFrame,
    roc_period:  int   = 10,
    threshold:   float = 0.1,   # In percentage points
) -> pd.DataFrame:
    """
    Calculates the Rate of Change (ROC) and generates buy/sell signals.

    Args:
        df (pd.DataFrame): Input DataFrame with 'close' prices and 'datetime'.
        roc_period (int): The number of periods to look back for ROC calculation.
        threshold (float): The percentage threshold for generating buy/sell signals.

    Returns:
        pd.DataFrame: The original DataFrame with 'roc' and 'signal' columns added.
                      'signal' values are 1 for buy, -1 for sell, and 0 for no signal.
    """
    df = df.copy().sort_values("datetime", ignore_index=True)

    # Calculate Rate of Change (ROC)
    # ROC = ((Current Close - Close N periods ago) / Close N periods ago) * 100
    df["roc"] = (df["close"] - df["close"].shift(roc_period)) / df["close"].shift(roc_period) * 100

    # Generate trading signals based on ROC and threshold
    # +1 for buy (ROC > threshold), -1 for sell (ROC < -threshold), 0 for no signal
    df["signal"] = np.where(df["roc"] >  threshold,  1,
                   np.where(df["roc"] < -threshold, -1, 0))

    return df

df_signals = rate_of_change_strategy(df, roc_period=10, threshold=0.1)

print("--- Signal Distribution ---")
print(df_signals["signal"].value_counts())
print("\n--- ROC Statistics ---")
print(df_signals["roc"].describe().round(4))
--- Signal Distribution ---
signal
-1    267
 1    202
 0     31
Name: count, dtype: int64

--- ROC Statistics ---
count    490.0000
mean      -0.0777
std        1.4963
min       -3.8415
25%       -1.1336
50%       -0.2255
75%        1.0483
max        4.4184
Name: roc, dtype: float64

Explanation:

  • df["close"].shift(roc_period): Retrieves the close price from N candles ago, providing the reference point for the momentum measurement.
  • threshold: Expressed in percentage points. A threshold of 0.1% means the price must have moved at least 0.1% in either direction over N candles to generate a signal. This prevents signals on negligible moves caused by random noise.
  • ROC is a pure look-back comparison — no smoothing is applied, making it highly responsive but also potentially noisy on short periods.

6. Visualization

[ ]
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 + Signals", "Rate of Change (%)"],
    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=buy_signals["datetime"],  y=buy_signals["low"]  * 0.999,
    mode="markers", marker=dict(symbol="triangle-up",   size=10, color="green"), name="Buy (+1)"),
    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 (−1)"),
    row=1, col=1)

fig.add_trace(go.Scatter(
    x=df_signals["datetime"], y=df_signals["roc"],
    mode="lines", name="ROC (%)", line=dict(color="purple", width=1)), row=2, col=1)
fig.add_hline(y= 0.1,  line_dash="dash", line_color="green", row=2, col=1, annotation_text="+threshold")
fig.add_hline(y=-0.1,  line_dash="dash", line_color="red",   row=2, col=1, annotation_text="−threshold")
fig.add_hline(y= 0,    line_dash="dot",  line_color="gray",  row=2, col=1)

fig.update_layout(
    title_text="Rate of Change Momentum Strategy",
    xaxis_rangeslider_visible=False,
    height=700, yaxis=dict(autorange=True),
    xaxis2_title="Datetime", yaxis2_title="ROC (%)",
)
fig.show()

Conclusion

This notebook successfully implemented and visualized a Rate of Change (ROC) Momentum strategy. The core idea is to identify significant price momentum by comparing the current closing price to a closing price from N periods ago.

Key Takeaways:

  • Direct Momentum Measurement: The ROC indicator provides a straightforward and direct measure of how much an asset's price has changed over a specified period. Unlike other oscillators, it is not smoothed or normalized, making it highly responsive to price movements.
  • Threshold-Based Signaling: By applying positive and negative thresholds, the strategy filters out minor price fluctuations, generating clear buy (+1) and sell (-1) signals only when momentum is significant enough to warrant action. This helps in distinguishing genuine directional moves from market noise.
  • Visualization Clarity: The interactive plots effectively demonstrate how ROC values correspond to price action and signal generation. Buy signals typically appear when ROC crosses above the positive threshold, coinciding with upward price movement, while sell signals occur when ROC dips below the negative threshold during downward trends.

Strengths of ROC Momentum:

  • Simplicity and Intuitiveness: Easy to understand and implement.
  • Responsiveness: Captures momentum shifts quickly due to its direct calculation.
  • Flexibility: The roc_period and threshold parameters can be adjusted to suit different market conditions and trading styles.

Limitations and Further Considerations:

  • Parameter Sensitivity: The performance of the strategy is highly dependent on the chosen roc_period and threshold. Optimal values may vary significantly across different assets and timeframes.
  • Synthetic Data: The current analysis is based on generated synthetic data. Real-world market data exhibits more complex behaviors, including gaps, sudden spikes, and varying volatility, which might affect strategy performance.
  • Lack of Context: The strategy as implemented does not incorporate other market factors, fundamental analysis, or broader market trends.
  • No Risk Management: The current implementation lacks essential trading components such as stop-loss orders, take-profit levels, or position sizing, which are crucial for real-world application.
  • No Backtesting: The notebook focuses on signal generation and visualization, but does not include a comprehensive backtest to evaluate the strategy's historical profitability, drawdowns, or other performance metrics.

Next Steps:

To build upon this foundation, future work could include:

  1. Real-World Data Testing: Apply the strategy to actual historical market data for various assets (e.g., stocks, cryptocurrencies, forex).
  2. Parameter Optimization: Conduct a systematic study to find optimal roc_period and threshold values using backtesting techniques.
  3. Risk Management Integration: Add features like stop-loss, take-profit, and position sizing to simulate realistic trading scenarios.
  4. Performance Evaluation: Develop a robust backtesting framework to calculate key metrics such as profit/loss, Sharpe ratio, maximum drawdown, and win rate.
  5. Combination with Other Indicators: Explore combining ROC momentum with other technical indicators (e.g., moving averages, volume, volatility measures) to enhance signal confirmation and reduce false positives.
  6. Adapting to Volatility: Implement adaptive thresholds or periods that dynamically adjust based on market volatility.