Momentum Trading Strategies
Implements a composite EMA, RSI, and MACD signal with illustrative parameters for Korean equities.
A composite momentum strategy that sums the RSI, MACD, and EMA crossover signals and enters only when at least two agree, implemented with Korean market parameters.
What a momentum strategy does
A momentum strategy measures the direction and speed of price movement, buying into uptrends and selling into downtrends. It exploits the market's tendency for recently strong assets to keep rising over the short term.
This strategy combines three technical indicators: RSI, MACD, and a moving average crossover. The combination produces trading signals that are more robust to noise than any single indicator.
| Indicator | Role | What it measures |
|---|---|---|
| EMA crossover | Trend direction | Moving average crossings |
| RSI | Overbought/oversold state | Speed and size of price moves |
| MACD | Trend strength and reversals | Moving average convergence/divergence |
A single indicator produces false signals constantly. The core rule is to enter only when two or more indicators point the same way.
Dependencies
# pyproject.toml
[project.dependencies]
ta = ">=0.11" # technical analysis library
pandas = ">=2.0"
numpy = ">=1.24"import pandas as pd
import numpy as np
from ta.trend import EMAIndicator, MACD as MACDIndicator
from ta.momentum import RSIIndicatorIndicator math is delegated to the ta library. EMAIndicator computes the exponential moving average, RSIIndicator computes RSI, and MACDIndicator computes the MACD line and its signal line.
EMA golden cross and death cross
What a moving average crossover is
The moving average crossover is the most basic trend-following signal. A short moving average crossing above a long one is a golden cross (buy); crossing below is a death cross (sell).
| Pattern | Condition | Meaning |
|---|---|---|
| Golden cross | Short MA crosses above long MA | Turning bullish - buy signal |
| Death cross | Short MA crosses below long MA | Turning bearish - sell signal |
SMA vs EMA
A Simple Moving Average (SMA) weights every data point equally. An Exponential Moving Average (EMA), by contrast, weights recent data more heavily. In the Korean market, where volatility runs high, an EMA reacts to trend changes faster.
Common MA pairs
| Pair | Short | Long | Character |
|---|---|---|---|
| Fast cross | 5 days | 20 days | Sensitive, many false signals |
| Standard cross | 20 days | 60 days | Medium-term trend |
| Classic cross | 50 days | 200 days | Long-term trend, high reliability |
Illustrative Korean market settings
The periods below are starting values that demonstrate the strategy. They do not establish an advantage for either market because results depend on the instrument and test window.
- KOSPI large caps: 20-day/60-day EMA (moderate volatility)
- KOSDAQ small and mid caps: 10-day/30-day EMA (high volatility)
Python implementation
def ma_crossover_signals(
df: pd.DataFrame,
fast: int = 20,
slow: int = 60,
) -> pd.DataFrame:
"""Generate moving average crossover signals.
Args:
df: OHLCV DataFrame (columns: open, high, low, close, volume)
fast: fast moving average window
slow: slow moving average window
Returns:
DataFrame with a signal column added (1=buy, -1=sell, 0=neutral)
"""
df = df.copy()
# compute EMAs
df["ema_fast"] = EMAIndicator(
close=df["close"], window=fast
).ema_indicator()
df["ema_slow"] = EMAIndicator(
close=df["close"], window=slow
).ema_indicator()
# detect crossovers
df["ma_cross"] = 0
# golden cross: fast crosses above slow
df.loc[
(df["ema_fast"] > df["ema_slow"])
& (df["ema_fast"].shift(1) <= df["ema_slow"].shift(1)),
"ma_cross",
] = 1
# death cross: fast crosses below slow
df.loc[
(df["ema_fast"] < df["ema_slow"])
& (df["ema_fast"].shift(1) >= df["ema_slow"].shift(1)),
"ma_cross",
] = -1
return dfCrossover detection hinges on how the relative position changed between the previous bar and the current one.
- Golden cross:
fast <= slowon the previous bar andfast > slowon the current bar - Death cross:
fast >= slowon the previous bar andfast < slowon the current bar
RSI (Relative Strength Index)
What RSI measures
RSI is an oscillator that measures the speed and size of price moves and returns a value between 0 and 100. It is computed from the ratio of average gains to average losses over a given window.
Formula
A high RSI means buying pressure has been strong; a low RSI means selling pressure has been strong.
Reading the signal
| RSI range | State | Action |
|---|---|---|
| Overbought | Consider selling | |
| Oversold | Consider buying | |
| Neutral | Defer to other indicators |
The subtlety is that you do not trade the moment RSI enters the overbought or oversold zone. You trade the moment it leaves that zone.
- Buy: when RSI crosses back up out of the oversold zone (below 30)
- Sell: when RSI crosses back down out of the overbought zone (above 70)
Korean market parameters
| Condition | RSI window | Overbought | Oversold | Note |
|---|---|---|---|---|
| Trending market (clear up or down) | 14 | 80 | 20 | Ride the trend |
| Range-bound (sideways) | 14 | 70 | 30 | Standard setting |
| Short-term trading | 7 | 70 | 30 | Reacts quickly |
| KOSDAQ small/mid cap | 9 | 75 | 25 | Volatility adjustment |
In a trending market, widening the overbought/oversold thresholds to 80/20 lets you stay with the trend longer. In a range-bound market, the standard 70/30 works better.
RSI divergence
Divergence is the phenomenon where price and RSI move in opposite directions, and it is a strong leading indicator of a trend reversal.
| Type | Price | RSI | Meaning |
|---|---|---|---|
| Bullish divergence | Makes a lower low | Makes a higher low | Downtrend weakening - rebound possible |
| Bearish divergence | Makes a higher high | Makes a lower high | Uptrend weakening - decline possible |
Python implementation
def rsi_signals(
df: pd.DataFrame,
window: int = 14,
overbought: float = 70,
oversold: float = 30,
) -> pd.DataFrame:
"""Generate RSI-based trading signals.
Args:
df: OHLCV DataFrame
window: RSI window
overbought: overbought threshold
oversold: oversold threshold
Returns:
DataFrame with rsi and rsi_signal columns added
"""
df = df.copy()
df["rsi"] = RSIIndicator(
close=df["close"], window=window
).rsi()
df["rsi_signal"] = 0
# leaving oversold -> buy (RSI crosses up through oversold)
df.loc[
(df["rsi"] > oversold)
& (df["rsi"].shift(1) <= oversold),
"rsi_signal",
] = 1
# leaving overbought -> sell (RSI crosses down through overbought)
df.loc[
(df["rsi"] < overbought)
& (df["rsi"].shift(1) >= overbought),
"rsi_signal",
] = -1
return dfMACD (Moving Average Convergence Divergence)
The three components
MACD is made up of three parts.
| Component | Computation | Default |
|---|---|---|
| MACD line | EMA(fast) - EMA(slow) | EMA(12) - EMA(26) |
| Signal line | EMA(MACD, signal) | EMA(MACD, 9) |
| Histogram | MACD - Signal | - |
The MACD line is the difference between two EMAs, and the signal line is a moving average of the MACD line. The histogram visualizes the gap between the two.
Reading the signal
| Pattern | Condition | Meaning |
|---|---|---|
| MACD golden cross | MACD crosses above Signal | Buy signal |
| MACD death cross | MACD crosses below Signal | Sell signal |
| Zero-line break | MACD crosses above 0 | Uptrend confirmed |
| Expanding histogram | Positive bars growing | Trend strengthening |
Reading the histogram
The histogram reflects changes in trend strength more sensitively than anything else in the indicator.
- Positive bars growing: MACD pulling away from Signal - uptrend strengthening
- Positive bars shrinking: MACD converging on Signal - uptrend weakening (death cross approaching)
- Negative bars growing: downtrend strengthening
- Negative bars shrinking: downtrend weakening (golden cross approaching)
Korean market parameters
| Market | fast | slow | signal | Note |
|---|---|---|---|---|
| KOSPI large caps | 12 | 26 | 9 | Standard setting |
| KOSDAQ | 8 | 21 | 5 | Faster response |
| Daily bars, short term | 6 | 13 | 5 | Swing trading |
KOSDAQ names are volatile, so (8, 21, 5) replaces the standard (12, 26, 9) to make the indicator respond faster.
Python implementation
def macd_signals(
df: pd.DataFrame,
fast: int = 12,
slow: int = 26,
signal: int = 9,
) -> pd.DataFrame:
"""Generate MACD crossover signals.
Args:
df: OHLCV DataFrame
fast: fast EMA window
slow: slow EMA window
signal: signal EMA window
Returns:
DataFrame with macd, macd_signal_line, macd_hist, macd_signal added
"""
df = df.copy()
macd = MACDIndicator(
close=df["close"],
window_fast=fast,
window_slow=slow,
window_sign=signal,
)
df["macd"] = macd.macd()
df["macd_signal_line"] = macd.macd_signal()
df["macd_hist"] = macd.macd_diff()
df["macd_signal"] = 0
# MACD golden cross
df.loc[
(df["macd"] > df["macd_signal_line"])
& (df["macd"].shift(1) <= df["macd_signal_line"].shift(1)),
"macd_signal",
] = 1
# MACD death cross
df.loc[
(df["macd"] < df["macd_signal_line"])
& (df["macd"].shift(1) >= df["macd_signal_line"].shift(1)),
"macd_signal",
] = -1
return dfThe composite momentum signal
Strategy logic
To work around the limits of any single indicator, the strategy enters only when two or more indicators point the same way.
| Bullish vote condition | Bearish vote condition |
|---|---|
| MA golden cross, or fast > slow | MA death cross, or fast < slow |
| 50 < RSI < overbought threshold | oversold threshold < RSI < 50 |
| MACD golden cross, or histogram turning positive | MACD death cross, or histogram turning negative |
Each indicator casts a bullish, bearish, or neutral vote. A direction triggers only when its vote count reaches min_confirmations; momentum_score remains a diagnostic difference between the two counts.
Python implementation
def combined_momentum_signal(
df: pd.DataFrame,
ma_fast: int = 20,
ma_slow: int = 60,
rsi_window: int = 14,
rsi_overbought: float = 70,
rsi_oversold: float = 30,
macd_fast: int = 12,
macd_slow: int = 26,
macd_signal: int = 9,
min_confirmations: int = 2,
) -> pd.DataFrame:
"""Generate the composite momentum signal.
A signal fires only when at least min_confirmations indicators agree.
"""
df = ma_crossover_signals(df, ma_fast, ma_slow)
df = rsi_signals(df, rsi_window, rsi_overbought, rsi_oversold)
df = macd_signals(df, macd_fast, macd_slow, macd_signal)
# bullish=1, bearish=-1, unavailable or extreme=0
df["ma_vote"] = np.select(
[df["ema_fast"] > df["ema_slow"], df["ema_fast"] < df["ema_slow"]],
[1, -1],
default=0,
)
df["rsi_vote"] = np.select(
[
(df["rsi"] > 50) & (df["rsi"] < rsi_overbought),
(df["rsi"] < 50) & (df["rsi"] > rsi_oversold),
],
[1, -1],
default=0,
)
df["macd_vote"] = np.select(
[df["macd_hist"] > 0, df["macd_hist"] < 0],
[1, -1],
default=0,
)
vote_columns = ["ma_vote", "rsi_vote", "macd_vote"]
df["bullish_confirmations"] = (df[vote_columns] == 1).sum(axis=1)
df["bearish_confirmations"] = (df[vote_columns] == -1).sum(axis=1)
df["momentum_score"] = (
df["bullish_confirmations"] - df["bearish_confirmations"]
)
df["signal"] = 0
df.loc[
df["bullish_confirmations"] >= min_confirmations, "signal"
] = 1
df.loc[
df["bearish_confirmations"] >= min_confirmations, "signal"
] = -1
return dfExample signals
| Bullish votes | Bearish votes | momentum_score | Result with min_confirmations=2 |
|---|---|---|---|
| 3 | 0 | +3 | Buy |
| 2 | 1 | +1 | Buy |
| 1 | 1 | 0 | No signal |
| 1 | 2 | -1 | Sell |
| 0 | 3 | -3 | Sell |
Illustrative Korean market parameters
These three presets are executable examples rather than validated optima. Fix the universe and period, then use walk-forward validation before deployment.
KOSPI standard (kospi_standard)
Suited to steady trading in large caps.
"kospi_standard": {
"ma_fast": 20, "ma_slow": 60,
"rsi_window": 14, "rsi_overbought": 70, "rsi_oversold": 30,
"macd_fast": 12, "macd_slow": 26, "macd_signal": 9,
"min_confirmations": 2,
}KOSDAQ aggressive (kosdaq_aggressive)
Reacts quickly to volatile small and mid caps.
"kosdaq_aggressive": {
"ma_fast": 10, "ma_slow": 30,
"rsi_window": 9, "rsi_overbought": 75, "rsi_oversold": 25,
"macd_fast": 8, "macd_slow": 21, "macd_signal": 5,
"min_confirmations": 2,
}Conservative (conservative)
Follows long-term trends and enters only when all three indicators agree.
"conservative": {
"ma_fast": 50, "ma_slow": 200,
"rsi_window": 14, "rsi_overbought": 80, "rsi_oversold": 20,
"macd_fast": 12, "macd_slow": 26, "macd_signal": 9,
"min_confirmations": 3, # only when all three agree
}Parameter comparison
| Item | KOSPI standard | KOSDAQ aggressive | Conservative |
|---|---|---|---|
| EMA short/long | 20/60 | 10/30 | 50/200 |
| RSI window | 14 | 9 | 14 |
| RSI overbought/oversold | 70/30 | 75/25 | 80/20 |
| MACD (fast/slow/signal) | 12/26/9 | 8/21/5 | 12/26/9 |
| Minimum confirmations | 2 | 2 | 3 |
| Signal frequency | Medium | High | Low |
| False signal rate | Medium | High | Low |
The full parameter dictionary
MOMENTUM_PARAMS = {
"kospi_standard": {
"ma_fast": 20, "ma_slow": 60,
"rsi_window": 14, "rsi_overbought": 70, "rsi_oversold": 30,
"macd_fast": 12, "macd_slow": 26, "macd_signal": 9,
"min_confirmations": 2,
},
"kosdaq_aggressive": {
"ma_fast": 10, "ma_slow": 30,
"rsi_window": 9, "rsi_overbought": 75, "rsi_oversold": 25,
"macd_fast": 8, "macd_slow": 21, "macd_signal": 5,
"min_confirmations": 2,
},
"conservative": {
"ma_fast": 50, "ma_slow": 200,
"rsi_window": 14, "rsi_overbought": 80, "rsi_oversold": 20,
"macd_fast": 12, "macd_slow": 26, "macd_signal": 9,
"min_confirmations": 3,
},
}Usage:
params = MOMENTUM_PARAMS["kospi_standard"]
result = combined_momentum_signal(df, **params)Position sizing
Fixed fractional sizing
The maximum loss on a single trade is capped at a fixed percentage of the total budget. The distance to the stop loss then determines the share count.
def calculate_position_size(
budget: float,
price: float,
stop_loss_pct: float,
risk_per_trade_pct: float = 1.0,
) -> int:
"""Fixed fractional position sizing.
Args:
budget: total investable amount
price: current share price
stop_loss_pct: stop loss percentage (e.g. -3.0)
risk_per_trade_pct: max loss per trade as a share of budget (default 1%)
Returns:
number of shares to buy (integer)
"""
max_loss_amount = budget * (risk_per_trade_pct / 100)
loss_per_share = price * abs(stop_loss_pct / 100)
if loss_per_share == 0:
return 0
shares = int(max_loss_amount / loss_per_share)
# cap any single name at 10% of the budget
max_shares = int(budget * 0.10 / price)
return min(shares, max_shares)Worked example:
Budget: 100 million KRW, price: 50,000 KRW, stop loss:
Stop loss and take profit
Risk parameters
RISK_PARAMS = {
"stop_loss_pct": -3.0, # stop loss: -3%
"take_profit_pct": 6.0, # take profit: +6% (R:R = 1:2)
"trailing_stop_pct": -2.0, # trailing stop: -2% from the high
"max_holding_days": 20, # max holding period: 20 trading days
"max_position_pct": 10.0, # max weight per name: 10%
}| Item | Value | Description |
|---|---|---|
| Stop loss | -3% | Force exit when price falls 3% below entry |
| Take profit | +6% | Realize gains when price rises 6% above entry |
| Trailing stop | -2% | Exit when price falls 2% from the highest price while held |
| Max holding | 20 trading days | Force exit after roughly one month |
| Max weight | 10% | Invest at most 10% of total assets in one name |
A 1:2 loss-to-profit ratio has a break-even win rate of about 33.3% before costs. Fees and slippage raise the required rate. The stop and target values are examples that a backtest and risk limit must replace.
Trailing stop
A trailing stop raises the exit level as the price rises. It preserves gains if the price drops sharply while the position is profitable.
Backtesting considerations
Always backtest on historical data before trading live. Omitting the following produces results that are far too optimistic.
Trading costs
These assumptions show where a backtest accepts costs. Replace them with the broker, instrument, and trading-date values used by the test.
| Item | Rate |
|---|---|
| Buy commission | 0.015% |
| Sell commission | 0.015% |
| Illustrative sell-side tax | 0.20% |
| Illustrative round trip | roughly 0.23% |
Slippage (tick size)
KRX common shares on the KOSPI and KOSDAQ use price-dependent ticks. The table follows the equity price bands in the KRX market rules.
| Price range | Tick size |
|---|---|
| Below 2,000 KRW | 1 KRW |
| 2,000 to below 5,000 KRW | 5 KRW |
| 5,000 to below 20,000 KRW | 10 KRW |
| 20,000 to below 50,000 KRW | 50 KRW |
| 50,000 to below 200,000 KRW | 100 KRW |
| 200,000 to below 500,000 KRW | 500 KRW |
| 500,000 KRW and above | 1,000 KRW |
Slippage is the gap between the expected and actual fill, so a backtest must account for it. The KRX trading-system rules provide the detailed market criteria.
Pitfalls
| Item | Description |
|---|---|
| Look-ahead bias | Never compute indicators from future data (check the shift calls) |
| Survivorship bias | Backtest on data that includes delisted names |
| Warm-up data | A 200-day moving average requires at least 200 earlier observations. |
| Volume constraint | Set an order cap as a share of average daily volume and test its sensitivity. |
Look-ahead bias is the most common backtesting mistake. Using an indicator computed from the current bar's close to make a trading decision on that same bar means using future information. Apply a delay of at least
shift(1).
Strategy at a glance
Key points
- The EMA crossover establishes trend direction
- RSI confirms the overbought/oversold state and blocks entries at extremes
- The MACD histogram gauges trend strength and the timing of reversals
- Requiring two or more indicators to agree filters out false signals
- Separate KOSPI and KOSDAQ parameter sets match the character of each market
- Fixed fractional position sizing caps single-trade risk at 1%
- Stop loss (-3%), take profit (+6%), and a trailing stop (-2%) manage risk after entry
Summary
The strategy signals only when at least two of EMA, RSI, and MACD vote in the same direction. Separate counts correctly classify two bullish votes and one bearish vote as a buy. An extreme RSI is neutral, which blocks a fresh entry based on an overheated reading.
The market-specific periods and risk limits are starting values for validation. Before live use, update costs and tick sizes and run a walk-forward backtest without look-ahead bias.