Momentum Trading Strategies
Building a composite momentum signal from EMA crossovers, RSI, and MACD, with parameter sets tuned for the Korean market
Contents
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 |
Settings for the Korean market
The Korean market is more volatile than the US market, so shortening the fast MA slightly works better.
- 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.
| Buy conditions (all must hold) | Sell conditions (all must hold) |
|---|---|
| MA golden cross, or fast > slow | MA death cross, or fast < slow |
| RSI < 70 (not overbought) | RSI > 30 (not oversold) |
| MACD golden cross, or histogram turning positive | MACD death cross, or histogram turning negative |
Each indicator's direction is scored as +1 (bullish) or -1 (bearish), and the scores are summed into a momentum_score. A score at or above min_confirmations triggers a buy; a score at or below -min_confirmations triggers a sell.
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)
# current direction of each indicator
df["ma_direction"] = np.where(
df["ema_fast"] > df["ema_slow"], 1, -1
)
df["rsi_direction"] = np.where(
df["rsi"] < 50, -1, 1
) # RSI 50 as the pivot
df["macd_direction"] = np.where(
df["macd_hist"] > 0, 1, -1
)
# sum the directions
df["momentum_score"] = (
df["ma_direction"]
+ df["rsi_direction"]
+ df["macd_direction"]
)
# final signal
df["signal"] = 0
df.loc[
df["momentum_score"] >= min_confirmations, "signal"
] = 1 # buy
df.loc[
df["momentum_score"] <= -min_confirmations, "signal"
] = -1 # sell
return dfExample signals
| momentum_score | Composition | Verdict |
|---|---|---|
| EMA bullish + RSI bullish + MACD bullish | Strong buy | |
| Indicators mixed | No signal (min_confirmations=2) | |
| EMA bearish + MACD bearish | Sell | |
| All indicators bearish | Strong sell |
Korean market parameter sets
Three presets cover the market conditions this strategy targets.
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 |
With a 1:2 risk-reward ratio, the expected value turns positive at a win rate above 34% once trading costs are deducted. Momentum strategies are generally reported to win 40-55% of the time, so 1:2 is a reasonable choice. The actual figure depends on the instrument, period, and parameters, so confirm it with a backtest.
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
Cost structure in the Korean market:
| Item | Rate |
|---|---|
| Buy commission | 0.015% |
| Sell commission | 0.015% |
| Securities transaction tax (KOSPI, as of 2026, including the rural development tax) | 0.20% |
| Total round trip | roughly 0.23% |
Slippage (tick size)
Tick size in the Korean market varies with the price level.
| Price range | Tick size |
|---|---|
| Below 1,000 KRW | 1 KRW |
| Below 5,000 KRW | 5 KRW |
| Below 10,000 KRW | 10 KRW |
| Below 50,000 KRW | 50 KRW |
| 50,000 KRW and above | 100 KRW |
Slippage is the gap between the expected price and the actual fill on a market order, and a backtest must account for it.
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 |
| Minimum data | A 200-day moving average needs at least 250 days of data |
| Volume constraint | Cap orders at 1% of average daily volume to avoid market impact |
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 core of this strategy is filtering out the false signals of a single indicator by summing the scores of three. The EMA crossover gives trend direction, RSI gives the overheated state, and the MACD histogram gives trend strength. A position opens only when momentum_score clears the threshold.
Because the Korean market is volatile, KOSPI and KOSDAQ get different parameter sets to tune signal frequency against false signals. After entry, fixed fractional sizing plus stop loss, take profit, and trailing stop keep the loss on any one trade near 1% of the budget. Before trading live, validate the parameters with a backtest that accounts for trading costs, slippage, and look-ahead bias.