The Momentum Strategy — Does the Trend Remain?

Highlight

  • Article Difficulty: ★★☆☆☆
  • Introduction to the Momentum.
  • Explain the market capitalization-based stock selection combined with the momentum strategy.
  • Utilize TQuant Lab to backtest the Momentum Strategy and observe its effectiveness in capturing the momentum factor.

Preface

Trading in the financial markets is a complex task, and even professional investors often find it challenging to outperform the market consistently. In Taiwan’s equity market, investors often face the dilemma of whether to ride rising trends or avoid purchasing at peak prices.  

This article presents a structured momentum strategy designed to identify and capture stocks with strong upward trends. Through detailed backtesting, we evaluate the strategy’s performance, providing a valuable reference for quantitative researchers and practitioners. 

Selecting Stocks and Calculating Momentum Across Periods

Stock Selection Criteria

In the stock selection process, we aim to avoid securities with low trading volume that are prone to sharp price fluctuations due to manipulation by large investors or institutional players. To mitigate such risks, we construct our stock universe by selecting the top 200 companies by market capitalization each year, ensuring that the chosen stocks possess high liquidity and sufficient market depth.. 

Momentum Calculation Across Timeframes 

After establishing the stock universe, we evaluate each individual stock by calculating its closing prices over the past month—a period that can be adjusted based on specific needs—to assess its medium- to long-term momentum. From this analysis, we select the top five stocks exhibiting the strongest momentum. This stock selection strategy helps capture prevailing trends among market leaders.

Trading Logic

On the first trading day of each month, we buy the top five momentum stocks identified from the previous period and sell those held in the prior portfolio that no longer meet the selection criteria. If a stock remains in the top five for two consecutive periods, it continues to be held without being sold. Each of the five positions is rebalanced using order_target_percent to allocate 20% of the portfolio’s value to each stock.

The strategy is designed to simulate an investment approach that does not require constant monitoring and is unaffected by intraday price volatility. Momentum rankings are calculated at the end of each month, and trades are executed one minute before the close on the first trading day of the following month. To reflect the real-world costs of trading, the strategy incorporates a slippage function to simulate price friction when placing orders. This approach is both simple and effective, making it suitable for investors who prefer a low-maintenance strategy. 

Import Packages

# 載入常用套件
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
)
import tejapi
import os
os.environ['TEJAPI_BASE'] = 'https://api.tej.com.tw'
os.environ['TEJAPI_KEY'] = 'your_key'


from zipline.sources.TEJ_Api_Data import get_universe
from zipline.data import bundles
from zipline.sources.TEJ_Api_Data import get_Benchmark_Return
from zipline.pipeline.mixins import SingleInputMixin
from zipline.pipeline.data import TWEquityPricing
from zipline.pipeline.factors import CustomFactor
from zipline.pipeline import Pipeline
from zipline.TQresearch.tej_pipeline import run_pipeline
from zipline import run_algorithm

Stock Pool and Ingest data

pool = get_universe(start = '2018-01-01',
                    end = '2025-04-10',
                    mkt_bd_e = ['TSE'],  
                    stktp_e = 'Common Stock'
)

start = '2018-06-01'
end = '2025-04-10'


os.environ['mdate'] = start + ' ' + end
os.environ['ticker'] = ' '.join(pool) + ' ' + 'IR0001'

!zipline ingest -b tquant

CustomFactor Function

With price and volume data in place, we can use the CustomFactor function to construct our desired momentum indicator. This function calculates the momentum of each stock by analyzing the change in closing prices over a specified time window (window_length) for each individual security.

from zipline.pipeline.filters import StaticAssets

class Momentum(CustomFactor):
    inputs = [TWEquityPricing.close]
   
    window_length = 20
    
    def compute(self, today, assets, out, close):
        
        return_rate = (((close[-1] - close[0]) / close[0]) * 100).round(5)
        out[:] = return_rate

In this code snippet, Momentum() inherits from CustomFactor and defines the logic for calculating momentum. The window_length parameter specifies the length of the lookback period (e.g., 20 days), and momentum is computed based on the change between the first and last closing prices within that period. This approach enables us to easily calculate the momentum of each stock over a defined time frame.

Pipeline

The Pipeline() function offers users an efficient way to process quantitative indicators and price-volume data across multiple securities. In this case, we use it to:

  • Import daily closing prices
  • Integrate the user-defined CustomFactor function for momentum calculation

This setup streamlines the data preparation process, enabling scalable and systematic analysis across a broad universe of stocks.

start = '2019-01-01'
end = '2025-04-10'
start_dt, end_dt = pd.Timestamp(start, tz='utc'), pd.Timestamp(end, tz='utc')
bundle = bundles.load('tquant')
benchmark_asset = bundle.asset_finder.lookup_symbol('IR0001', as_of_date=None)

def make_pipeline():
    
    mom = Momentum()
    
    curr_price = TWEquityPricing.close.latest
    
    return Pipeline(
        columns={
            'curr_price': curr_price,
            'Momentum': mom
        },
        
        screen=~StaticAssets([benchmark_asset])
    )

my_pipeline = run_pipeline(make_pipeline(), start_dt, end_dt)
dates = my_pipeline.index.get_level_values(0).unique()
my_pipeline
momentum

Initialize

The initialize() function is used to define the daily trading environment before the start of the backtest. In this example, we configure the following:

  • Set a fixed bid-ask spread for slippage modeling, without applying any volume constraints
  • Define transaction costs
  • Use the return of the weighted stock index (IR0001) as the performance benchmark
  • Integrate the previously constructed Pipeline for momentum calculation into the trading algorithm
  • Initialize context.last_rebalance_day to record the date of the last portfolio rebalance
  • At the beginning of each year, reselect the top 200 stocks by market capitalization before applying the momentum filter

This setup ensures that the trading strategy operates under realistic market conditions and dynamically updates its universe and portfolio allocations based on well-defined criteria.

from zipline.finance import slippage, commission
from zipline.api import *
from zipline.api import set_slippage, set_commission, set_benchmark, attach_pipeline, order, order_target, symbol, pipeline_output, record
def initialize(context):
    context.holding_stock    = False
    context.target           = None
    context.targets = []
    context.last_rebalance_day = None

    context.stock_buy_price  = 0
    context.rebalance_period    = 20 



    set_slippage(slippage.FixedBasisPointsSlippage(basis_points=50.0, volume_limit=1))
    set_commission(commission.PerShare(cost=0.001425 + 0.003/2))
    attach_pipeline(make_pipeline(), 'mystrats')
    set_benchmark(symbol('IR0001'))

    
    context.top200_by_year = {}
    for Y in years:
        
        sessions = calendar.sessions_in_range(
            pd.Timestamp(f"{Y-1}-01-01", tz=tz),
            pd.Timestamp(f"{Y-1}-12-31", tz=tz),
        )
        last_session = sessions[-1]  

        dt_str = last_session.strftime('%Y-%m-%d')
        cap = TejToolAPI.get_history_data(
            start   = dt_str,
            end     = dt_str,
            ticker  = pool,
            columns = ['Market_Cap_Dollars']
        )
        #print(f"Year: {Y}, Date: {dt_str}")
        coids = (
            cap.sort_values('Market_Cap_Dollars', ascending=False)
               .head(200)['coid']
               .tolist()
        )
        
        context.top200_by_year[Y] = [symbol(c) for c in coids]

Handle_data

The handle_data() function plays a crucial role in constructing the trading strategy, as it is called on each trading day during the backtest. Its primary responsibilities include defining the trading logic, executing orders, and recording trade-related information.

As mentioned at the beginning of the article, this strategy only requires checking whether the current date marks the beginning or end of the month, and making buy or sell decisions based on the momentum rankings at that time.

Strategy Summary:

  • Entry Condition: Enter positions on the first trading day of each month by selecting the top five stocks with the highest momentum (adjustable based on specific needs).
  • Exit and Holding Conditions: Also on the first trading day of each month, sell stocks that no longer appear in the current top five momentum rankings and continue holding those that remain selected. Additionally, the strategy can incorporate custom stop-loss or take-profit rules for early exits.
def handle_data(context, data):
    
    out_dir = pipeline_output('mystrats')
    current = context.get_datetime().normalize()
    y, m, d = current.year, current.month, current.day

    
    mdays     = dates[(dates.year == y) & (dates.month == m)]
    first_day = mdays.min().day

    
    if d == first_day:
        
        universe = context.top200_by_year.get(y, [])
        
        today_df = out_dir.loc[out_dir.index.isin(universe)]
        if today_df.empty:
            return

        
        top5 = today_df['Momentum'].nlargest(5).index.tolist()

        
        prev    = context.targets
        to_drop = set(prev) - set(top5)
        for asset in to_drop:
            order_target_percent(asset, 0)
            print(f"Drop {asset}")

        
        weight = 1.0 / len(top5)
        for asset in top5:
            order_target_percent(asset, weight)
            print(f"Rebalance {asset} to {weight:.0%}")

        
        context.targets = top5

def analyze(context, perf):
    pass

If you wish to customize the rebalancing frequency, you can replace the original handle_data() function with the version below. Additionally, modify context.rebalance_period in the initialize() function to specify the desired number of days between each rebalance.

def handle_data(context, data):
    
    out_dir = pipeline_output('mystrats')
    current = context.get_datetime().normalize()
    
    
    if context.last_rebalance_day is None:
        context.last_rebalance_day = current

    
    days_since = (current - context.last_rebalance_day).days
    if days_since < context.rebalance_period:
        return

    context.last_rebalance_day = current

    y        = current.year
    universe = context.top200_by_year.get(y, [])
    #  直接用單層 index 篩選
    today_df = out_dir.loc[out_dir.index.isin(universe)]
    if today_df.empty:
        return

    top5 = today_df['Momentum'].nlargest(5).index.tolist()

    prev    = context.targets
    to_drop = set(prev) - set(top5)
    for asset in to_drop:
        order_target_percent(asset, 0)
        print(f"Drop {asset}")

    weight = 1.0 / len(top5)
    for asset in top5:
        order_target_percent(asset, weight)
        print(f"Rebalance {asset} to {weight:.0%}")

    context.targets = top5

Backtesting the Momentum Strategy

momentum

Below is a summary of the core backtest logic: 

  • Stock Universe: Top 200 companies by market capitalization at the time. 
  • Data Source: TEJ market data, including stock prices and volumes. 
  • Entry/Exit Points: First and last trading days of each month. 
  • Performance Evaluation: Pyfolio package used to generate a comprehensive performance report. 

Performance Overview 

  • Annualized Return: 28.26% 
  • Volatility: 37.25% 
  • Sharpe Ratio: 0.86
  • Sortino Ratio: 1.24

Although the strategy presents significant profit potential, it also entails considerable risk. The strategy successfully captured major opportunities, such as the 2021 rally in Yang Ming Marine Transport, which contributed substantially to the overall performance.  

 

momentum

Conclusion

This strategy presents a fascinating question: should you “get on board”? Readers likely have their own answers to this. Even though the stock universe has been limited to the top 200 companies by market capitalization—an effort to reduce the risk of price manipulation—the stocks selected as the top five momentum performers are often those that have experienced sharp gains in the recent past. Such stocks may be nearing the end of their rally, or they may continue to ride their upward momentum.

The key to achieving significant profits with this strategy lies in taking on higher risk while successfully capturing stocks with strong upward momentum. For instance, in 2021, the strategy benefited from the explosive rally of Yang Ming Marine Transport Corporation. In 2024, despite a drawdown of over 10%, the stock rebounded strongly, yielding considerable returns. However, during the tariff war of 2025, its performance lagged the broader market, highlighting the inherent volatility of such trades.

For readers interested in further exploration, the strategy can be customized to reflect different market conditions. By adjusting the code to include stop-loss and take-profit mechanisms, you can test how such modifications affect performance. The core logic of the strategy is already in place—what remains is the flexibility to adapt it to suit various investment scenarios and validate its robustness under different conditions.

Taiwan stock market data, TEJ collect it all.”

For researchers and investors looking to refine their trading strategies, access to high-quality data is paramount. TEJ’s comprehensive databases provide essential insights and historical performance data, facilitating the development and backtesting of such quantitative strategies. By leveraging these resources, users can better navigate the complexities of market dynamics and optimize their trading decisions. 

Extended Reading

Back
Procesing