jongkwan.dev
Development · Essay №052

Momentum Trading Strategies

Building a composite momentum signal from EMA crossovers, RSI, and MACD, with parameter sets tuned for the Korean market

Jongkwan Lee2026년 2월 21일17 min read
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.

IndicatorRoleWhat it measures
EMA crossoverTrend directionMoving average crossings
RSIOverbought/oversold stateSpeed and size of price moves
MACDTrend strength and reversalsMoving 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

toml
# pyproject.toml
[project.dependencies]
ta = ">=0.11"       # technical analysis library
pandas = ">=2.0"
numpy = ">=1.24"
python
import pandas as pd
import numpy as np
from ta.trend import EMAIndicator, MACD as MACDIndicator
from ta.momentum import RSIIndicator

Indicator 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).

PatternConditionMeaning
Golden crossShort MA crosses above long MATurning bullish - buy signal
Death crossShort MA crosses below long MATurning 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

PairShortLongCharacter
Fast cross5 days20 daysSensitive, many false signals
Standard cross20 days60 daysMedium-term trend
Classic cross50 days200 daysLong-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

python
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 df

Crossover detection hinges on how the relative position changed between the previous bar and the current one.

  • Golden cross: fast <= slow on the previous bar and fast > slow on the current bar
  • Death cross: fast >= slow on the previous bar and fast < slow on 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

RS=average gainaverage loss(N-day window)RS = \frac{\text{average gain}}{\text{average loss}} \quad (N\text{-day window})

RSI=1001001+RSRSI = 100 - \frac{100}{1 + RS}

A high RSI means buying pressure has been strong; a low RSI means selling pressure has been strong.

Reading the signal

RSI rangeStateAction
RSI>70RSI > 70OverboughtConsider selling
RSI<30RSI < 30OversoldConsider buying
30RSI7030 \leq RSI \leq 70NeutralDefer 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

ConditionRSI windowOverboughtOversoldNote
Trending market (clear up or down)148020Ride the trend
Range-bound (sideways)147030Standard setting
Short-term trading77030Reacts quickly
KOSDAQ small/mid cap97525Volatility 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.

TypePriceRSIMeaning
Bullish divergenceMakes a lower lowMakes a higher lowDowntrend weakening - rebound possible
Bearish divergenceMakes a higher highMakes a lower highUptrend weakening - decline possible

Python implementation

python
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 df

MACD (Moving Average Convergence Divergence)

The three components

MACD is made up of three parts.

ComponentComputationDefault
MACD lineEMA(fast) - EMA(slow)EMA(12) - EMA(26)
Signal lineEMA(MACD, signal)EMA(MACD, 9)
HistogramMACD - 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

PatternConditionMeaning
MACD golden crossMACD crosses above SignalBuy signal
MACD death crossMACD crosses below SignalSell signal
Zero-line breakMACD crosses above 0Uptrend confirmed
Expanding histogramPositive bars growingTrend 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

MarketfastslowsignalNote
KOSPI large caps12269Standard setting
KOSDAQ8215Faster response
Daily bars, short term6135Swing trading

KOSDAQ names are volatile, so (8, 21, 5) replaces the standard (12, 26, 9) to make the indicator respond faster.

Python implementation

python
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 df

The 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 > slowMA death cross, or fast < slow
RSI < 70 (not overbought)RSI > 30 (not oversold)
MACD golden cross, or histogram turning positiveMACD 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

python
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 df

Example signals

momentum_scoreCompositionVerdict
+3+3EMA bullish + RSI bullish + MACD bullishStrong buy
+1+1Indicators mixedNo signal (min_confirmations=2)
2-2EMA bearish + MACD bearishSell
3-3All indicators bearishStrong 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.

python
"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.

python
"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.

python
"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

ItemKOSPI standardKOSDAQ aggressiveConservative
EMA short/long20/6010/3050/200
RSI window14914
RSI overbought/oversold70/3075/2580/20
MACD (fast/slow/signal)12/26/98/21/512/26/9
Minimum confirmations223
Signal frequencyMediumHighLow
False signal rateMediumHighLow

The full parameter dictionary

python
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:

python
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.

python
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: 3%-3\%

max allowed loss=100M×1%=1,000,000 KRW\text{max allowed loss} = 100\text{M} \times 1\% = 1{,}000{,}000\text{ KRW}

loss per share=50,000×3%=1,500 KRW\text{loss per share} = 50{,}000 \times 3\% = 1{,}500\text{ KRW}

shares=1,000,0001,500=666 shares\text{shares} = \frac{1{,}000{,}000}{1{,}500} = 666\text{ shares}

position cap=100M×10%50,000=200 shares\text{position cap} = \frac{100\text{M} \times 10\%}{50{,}000} = 200\text{ shares}

final=min(666,  200)=200 shares\text{final} = \min(666,\; 200) = 200\text{ shares}


Stop loss and take profit

Risk parameters

python
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%
}
ItemValueDescription
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 holding20 trading daysForce exit after roughly one month
Max weight10%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:

ItemRate
Buy commission0.015%
Sell commission0.015%
Securities transaction tax (KOSPI, as of 2026, including the rural development tax)0.20%
Total round triproughly 0.23%

Slippage (tick size)

Tick size in the Korean market varies with the price level.

Price rangeTick size
Below 1,000 KRW1 KRW
Below 5,000 KRW5 KRW
Below 10,000 KRW10 KRW
Below 50,000 KRW50 KRW
50,000 KRW and above100 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

ItemDescription
Look-ahead biasNever compute indicators from future data (check the shift calls)
Survivorship biasBacktest on data that includes delisted names
Minimum dataA 200-day moving average needs at least 250 days of data
Volume constraintCap 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

  1. The EMA crossover establishes trend direction
  2. RSI confirms the overbought/oversold state and blocks entries at extremes
  3. The MACD histogram gauges trend strength and the timing of reversals
  4. Requiring two or more indicators to agree filters out false signals
  5. Separate KOSPI and KOSDAQ parameter sets match the character of each market
  6. Fixed fractional position sizing caps single-trade risk at 1%
  7. 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.