將產業輪動化為策略:應用產業順序建立量化回測模型

前言

在產業輪動的脈絡中,我們觀察到一項耐人尋味的現象:航運指數的轉強,似乎經常領先於半導體的上漲。這樣的關聯性若能被驗證並轉化為可操作的邏輯,便可能成為投資決策中的寶貴線索。

本篇將以前文「從數據觀察產業輪動:解構航運與半導體的領先與落後關係」的實證分析為基礎,進一步探討如何將「航運領先、半導體跟隨」的輪動假說,轉化為具體的資產配置策略。我們將結合景氣燈號、產業表現指標與時序條件,建構一套回測框架,模擬歷史期間的實際操作情境。透過策略績效的評估,我們希望回答一個核心問題:若航運真的能預告半導體行情,我們該如何跟上這波接力賽?

產業 RSI 數值視覺化

我們可以觀察到上一篇文章的分析結果,研究時間為2012-2020年初,航運產業以及半導體產業的 RSI 數值確實呈現明顯的輪動關係,接下來則是需要去關心我們如何利用這種輪動關係,設計出一套可靠的投資策略。為了避免前世偏誤,我們使用2020以後的市場資料當作策略回測時研究的對象,這會讓我們的策略分析更加可靠。

策略邏輯說明:(搭配景氣信號燈)

從文章「從景氣燈號到資產輪動:一套避開熊市的量化策略」當中可以得知,通過景氣信號燈的信號可以有效避開熊市的波動,只在牛市持有股票部位,熊市持有短天期國債等避險資產,並且回測出不錯績效結果。搭配上述觀察的產業輪動現象「航運 → 半導體」,我們可以在牛市階段觀察到產業輪動的信號時,將原本持有的 0050 ETF 短期置換成產業輪動的相關股票(半導體類股),賺取資金輪動的報酬之後再換回 0050 ETF ,期望這樣的操作可以優化上述文章的績效結果。策略的比較基準為台股加權報酬率指數 (IR0001),後續簡稱大盤。

實際操作方式為在景氣信號燈處於上升階段時,如果觀察到航運類指數的 RSI 數值大於65(我們認為當時該產業處於相對高點,產業循環開始),則買入持有半導體相關類股(選擇有代表性的大公司作為標的),直到半導體指數的 RSI 數值相對於買入時上升 15(產業循環結束),則出場賣出半導體類股並買回 0050 ETF。若產業循環未結束時市場進入熊市則平倉掉所有股票部位,買入短天期債券(這部分設計與景氣週期文章相同)。

在本策略中,針對「半導體產業」的資產配置,我選取了十檔具代表性的台灣半導體相關個股,涵蓋從上游晶圓代工到中游IC設計、下游封裝測試與記憶體等完整供應鏈。具體包括:

晶圓代工:台積電(2330)與聯電(2303)為晶圓代工雙雄,分別代表先進製程與成熟製程的核心廠商;世界先進(5347)則專攻8吋晶圓,聚焦利基市場。
IC設計:聯發科(2454)為全球主要手機與通訊晶片設計商;聯詠(3034)與天鈺(4961)則專精於顯示器驅動IC,屬於面板供應鏈重要角色。
封裝與測試:日月光投控(3711)、力成(6239)與欣銓(3264)為主要封裝與測試廠商,涵蓋後段製程與測試服務。
記憶體:南亞科(2408)為台灣DRAM大廠,與全球記憶體價格與供需循環關聯性高。

這些個股共同構成台灣半導體產業的關鍵結構,亦具備足夠流動性與市值,適合用於實證回測與資金配置的模擬。

程式碼展示

下載景氣信號燈分數、0050ETF以及美國短天期美債資料,並合併成一個dataframe

import tejapi
import pandas as pd
import numpy as np
tejapi.ApiConfig.api_key = "your key"
tejapi.ApiConfig.api_base = "https://api.tej.com.tw"
# ========================================================
# 下載景氣信號燈的分數資料
data = tejapi.get('GLOBAL/ANMAR', mdate={'gte':'2000-01-01', 'lte':'2025-04-09'}, coid = 'EA1101')
# ========================================================
# 下載 0050 ETF 以及 00865B ETF 的調整後價格資料
data2 = tejapi.get('TWN/AAPRCDA', coid = ['0050'], mdate={'gte':'2000-01-01', 'lte':'2025-04-09'})
df_price = data2[['mdate','close_d', 'avgclsd']].copy()
data3 = tejapi.get('TWN/AAPRCDA', coid = ['00865B'], mdate={'gte':'2000-01-01', 'lte':'2025-04-09'})
df_bond = data3[['mdate','close_d', 'avgclsd']].copy()


# ========================================================
data['mdate'] = pd.to_datetime(data['mdate'])
data['val_shifted'] = data['val'].shift(1)
df_price['mdate'] = pd.to_datetime(df_price['mdate'])
df_bond['mdate'] = pd.to_datetime(df_bond['mdate'])
data = data.set_index('mdate', drop=False)
df_price = df_price.set_index('mdate', drop=False)
df_bond = df_bond.set_index('mdate', drop=False)


df_P_daily = data.resample('D').ffill()
df = df_price.join(df_P_daily, how = 'left', rsuffix='_P')
df = df.join(df_bond, how = 'left', rsuffix='_bond')
df['mdate'] = df['mdate'].dt.strftime('%Y-%m-%d')
df['mdate'] = pd.to_datetime(df['mdate'])
# ========================================================
# 將兩筆資料視覺化,觀察其過去情況
import matplotlib.pyplot as plt
fig, axes = plt.subplots(nrows=3, ncols=1, figsize=(20, 10), sharex=True)
plt.style.use('ggplot')
axes[1].plot(df['mdate'], df['avgclsd'], label = '0050 Price')
axes[1].set_title(f'0050 History Price')
axes[1].legend()

axes[0].plot(df['mdate'], df['val_shifted'], label = 'SCORE')
axes[0].axhline(y = 38, label = 'Red Light Bound', color = 'red', linestyle = '--')
axes[0].axhline(y= 16, label = 'Blue Light Bound', color = 'blue', linestyle = '--')
axes[0].set_title(f'SCORE')
axes[0].legend()

axes[2].plot(df['mdate'], df['avgclsd_bond'], label = 'Short_term_bond Price')
axes[2].set_title(f'00865B Short_term_bond History Price')
axes[2].legend()
plt.tight_layout()
plt.show()
產業輪動
景氣信號燈、0050ETF以及美國短天期公債ETF視覺化結果

下載產業指數資料並進行 RSI 計算

codes = [
   "IX0001", "IX0002", "IX0003", "IX0006", "IX0010", "IX0011", "IX0012",
   "IX0016", "IX0017", "IX0018", "IX0019", "IX0020", "IX0021", "IX0022",
   "IX0023", "IX0024", "IX0025", "IX0026", "IX0027", "IX0028", "IX0029",
   "IX0030", "IX0031", "IX0032", "IX0033", "IX0034", "IX0035", "IX0036",
   "IX0037", "IX0038", "IX0039", "IX0040"
]


names = [
   "加權指數", "台灣50指數", "台灣中型指數", "台灣高股息指數", "水泥工業類指數",
   "食品工業類指數", "塑膠工業類指數", "紡織纖維類指數", "電機機械類指數",
   "電器電纜類指數", "化學生技醫療類指數", "化學工業指數", "生技醫療指數",
   "玻璃陶瓷類指數", "造紙工業類指數", "鋼鐵工業類指數", "橡膠類指數",
   "汽車工業類指數", "電子類指數", "半導體業指數", "電腦及週邊設備業指數",
   "光電業指數", "通信網路業指數", "電子零組件業指數", "電子通路業指數",
   "資訊服務業指數", "其他電子業指數", "建材營造類指數", "航運業類指數",
   "觀光事業類指數", "金融保險類指數", "貿易百貨類指數"
]
import pandas as pd
import numpy as np
import tejapi
import os
import matplotlib.pyplot as plt
plt.rcParams['font.family'] = 'Arial'


tej_key = "your key"
tejapi.ApiConfig.api_key = tej_key
os.environ['TEJAPI_BASE'] = "https://api.tej.com.tw"
os.environ['TEJAPI_KEY'] = tej_key


start_dt = pd.Timestamp('2006-01-01', tz = 'UTC')
end_dt = pd.Timestamp('2025-04-23', tz = "UTC")

import TejToolAPI

co = ['coid','Industry', 'mkt', 'vol', 'open_d', 'high_d', 'low_d', 'close_d', 'roi', 'shares', 'per', 'pbr_tej','mktcap']
data = TejToolAPI.get_history_data(start = start_dt,
                                  end = end_dt,
                                  ticker = codes,
                                  columns = co,
                                  transfer_to_chinese = True)
data_use = data.pivot(index='日期', columns='股票代碼', values='收盤價')
def compute_rsi(series, period = 60):
   delta = series.diff()

   gain = delta.where(delta > 0, 0.0)
   loss = -delta.where(delta < 0, 0.0)

   avg_gain = gain.rolling(window=period).mean()
   avg_loss = loss.rolling(window=period).mean()


   rs = avg_gain / avg_loss
   rsi = 100 - (100 / (1 + rs))


   return rsi


df_ind = data_use.iloc[:, 1:].apply(compute_rsi)
df_ind['mdate'] = df_ind.index

回測程式碼展示

import os
import tejapi
plt.rcParams['font.family'] = 'Arial'
tej_key = 'your key'
os.environ['TEJAPI_BASE'] = "https://api.tej.com.tw"
os.environ['TEJAPI_KEY'] = tej_key


from zipline.data.run_ingest import simple_ingest
from zipline.api import set_slippage, set_commission, set_benchmark,  symbol,  record
from zipline.api import order_target_percent, order_percent, order
from zipline.api import set_long_only, set_max_leverage
from zipline.finance import commission, slippage
from zipline import run_algorithm

semiconductor_stocks = [
   '2330',  # 台積電:晶圓代工龍頭
   '2303',  # 聯電:成熟製程晶圓代工
   '2408',  # 南亞科:DRAM 記憶體
   '3711',  # 日月光投控:封裝與測試
   '3034',  # 聯詠:顯示器 IC 設計
   '2454',  # 聯發科:手機與通訊 IC 設計大廠
   '5347',  # 世界先進:8 吋晶圓代工
   '6239',  # 力成:封裝測試服務
   '3264',  # 欣銓:測試服務為主
   '4961'   # 天鈺:顯示驅動 IC
]
pool = ['0050', 'IR0001', '00865B'] + semiconductor_stocks
start_date = '2009-01-01'
end_date = '2025-04-30'
start_ingest = start_date.replace('-', '')
end_ingest = end_date.replace('-', '')


simple_ingest(name = 'tquant' , tickers = pool , start_date = start_ingest , end_date = end_ingest)


def initialize(context, pool = pool):
  set_slippage(slippage.TW_Slippage(spread = 1 , volume_limit = 1))
  set_commission(commission.Custom_TW_Commission(min_trade_cost=20, discount=1.0, tax = 0.003))
  set_benchmark(symbol('IR0001'))
  context.i = 0
  context.pool  = pool
  context.state = False
  context.score = None
  context.hedge_state = None
  context.buy_date = []
  context.sell_date = []
  context.a = 0
  context.b = 0
  context.bond = symbol('00865B')
  context.stock = symbol('0050')
  context.semi = None
  context.boat = None
  context.cycle2 = False
  context.aa = 0
  context.bb = 0
  context.cycle_start_date = []
  context.cycle_end_date = []

def handle_data(context, data, score_data = df, ind_data = df_ind):
  backtest_date = data.current_dt.date()
  today_data = score_data[score_data['mdate'] == pd.to_datetime(backtest_date)]
  context.last_score = context.score  # 記錄舊的 score


  if not today_data.empty:
     context.score = today_data['val_shifted'].iloc[-1]
  else:
     # 若無資料,就沿用舊的 score
     context.score = context.last_score

  today_data_2 = ind_data[ind_data['mdate'] == pd.to_datetime(backtest_date)]
  context.semi = today_data_2["IX0028"].iloc[-1]
  #context.bio = today_data_2['IX0021'].iloc[-1]
  context.boat = today_data_2['IX0037'].iloc[-1]
  record(score = context.score)

  if context.state == True:
     # ==================================================================
     if context.boat >= 65 and context.cycle2 == False :
        print(f'{backtest_date} : Cycle 2 Start')
        context.cycle_start_date.append(pd.to_datetime(backtest_date))
        context.cycle2 = True
        order_target_percent(symbol('0050'), 0)
        for i in semiconductor_stocks:
           order_target_percent(symbol(i), 1.0 / len(semiconductor_stocks))
        context.a = context.semi


     if context.cycle2 == True and context.semi >= context.a + 15:
        print(f'{backtest_date} : Cycle 2 End')
        context.cycle_end_date.append(pd.to_datetime(backtest_date))
        context.cycle2 = False
        for i in semiconductor_stocks:
           order_target_percent(symbol(i), 0)
        order_target_percent(symbol('0050'), 1.0)
     # ==================================================================

  if context.hedge_state == True and context.cycle2 == True:
     print(f'Bull Market Ending')
     #context.end_date.append(pd.to_datetime(backtest_date))
     for i in semiconductor_stocks:
        order_target_percent(symbol(i), 0)
     order_target_percent(symbol('0050'), 0)
     context.cycle2 = False
  # ==================================================================

  if context.score <= 16 and context.state == False:
     order_target_percent(context.stock, 1.0)
     print(f"Date: {backtest_date}, Score: {context.score}, 買進 0050")
     context.buy_date.append(pd.to_datetime(backtest_date))
     context.state = True


     if context.hedge_state == True:
        order_target_percent(context.bond, 0)
        print(f"Date: {backtest_date}, Score: {context.score},賣出債券")
        context.hedge_state = False

  if context.score >= 38 and context.state == True:
     order_target_percent(context.stock, 0)
     print(f"Date: {backtest_date}, Score: {context.score}, 賣出 0050")
     context.sell_date.append(pd.to_datetime(backtest_date))
     context.state = False


     if context.hedge_state == False :
        order_target_percent(context.bond, 1.0)
        print(f"Date: {backtest_date}, Score: {context.score},買入債券避險")
        context.hedge_state = True

  if context.score > 16 and context.score < 38 and context.aa == 0:
     context.aa = 1
     print('進入景氣循環')
     if context.state == False:
        order_target_percent(context.stock, 1.0)
        print(f"Date: {backtest_date}, Score: {context.score}, 買進 0050 ETF")
        context.buy_date.append(pd.to_datetime(backtest_date))
        context.state = True


 # 因為 00685B 從 2019-11-25 才開始被交易
  if pd.to_datetime(backtest_date) >= pd.to_datetime('2019-11-25') and context.bb == 0:
    context.bb = 1
    context.hedge_state = False


  record(Leverage = context.account.leverage)

df_ind_plot = df_ind[df_ind['mdate'] >= pd.to_datetime('2020-01-01')]
plt.rcParams['font.family'] = 'DejaVu Sans'

def analyze(context, perf):
 plt.style.use('ggplot')
 fig, axes = plt.subplots(nrows=4, ncols=1, figsize=(18, 15), sharex=True)
 axes[0].plot(perf.index, perf['algorithm_period_return'], label = 'strategy')
 axes[0].plot(perf.index, perf['benchmark_period_return'], label = 'benchmark')

 for idx, i in enumerate(context.buy_date):
   if idx == 0:
     axes[0].axvline(x = i, color = 'red', label = 'Bull Market Start', linestyle = '--', alpha = 0.5)
   axes[0].axvline(x = i, color = 'red', linestyle = '--', alpha = 0.5)

 for idx, i in enumerate(context.sell_date):
   if idx == 0:
     axes[0].axvline(x = i, color = 'black', label = 'Bear Market Start', linestyle = '--', alpha = 0.5)
   axes[0].axvline(x = i, color = 'black', linestyle = '--', alpha = 0.5)

 for idx, i in enumerate(context.cycle_start_date):
   if idx == 0:
     axes[0].axvline(x = i, linestyle = '--', color = '#F39C12', label = 'Cycle Start')
   else:
     axes[0].axvline(x = i, linestyle = '--', color = '#F39C12')

 for idx, i in enumerate(context.cycle_end_date):
   if idx == 0:
     axes[0].axvline(x = i, linestyle = '--', color = '#6C3483', label = 'Cycle End')
   else:
     axes[0].axvline(x = i, linestyle = '--', color = '#6C3483')

 axes[0].set_title(f'Industry Rotation Algorithm_period_return')
 axes[0].legend()

 axes[1].bar(perf.index, perf['score'], label='score')
 axes[1].set_title('Business cycle index')
 axes[1].legend()

 axes[2].plot(perf.index, perf['Leverage'], label = 'Leverage')
 axes[2].set_title('Leverage')
 axes[2].legend()

 axes[3].plot(df_ind_plot.index, df_ind_plot['IX0028'], label = 'Semi index RSI')
 axes[3].plot(df_ind_plot.index, df_ind_plot['IX0037'], label = 'Ship index RSI')
 axes[3].set_title('Industry RSI')
 axes[3].legend()
 plt.tight_layout()
 plt.show()

results = run_algorithm(
           start = pd.Timestamp('2020-01-01', tz = 'utc'),
           end = pd.Timestamp('2025-04-10', tz = 'utc'),
           initialize = initialize,
           handle_data = handle_data,
           analyze = analyze,
           bundle = 'tquant',
           capital_base = 1e5)

策略績效圖表&分析

產業輪動

從第一張圖的策略績效比較來看,在整段牛市期間共偵測到 5 次「航運 → 半導體」的產業輪動信號(橘色線顯示的時間點)。其中,前 3 次出現在 2020 年,第 4, 5 次則發生於較後期。整體來說,這幾次輪動信號中,有 4 次成功捕捉到半導體類股的強勁上漲趨勢,分別為第 2 至 5 次。其中第2, 3次進場效果最為顯著,使得策略的累積報酬率大幅超越 台股大盤(Benchmark),達成我們預期透過輪動機制提升超額報酬的目標。第 4, 5 次雖也成功搭上半導體上漲波段,但由於當時整體台股行情主要由半導體所驅動,因此策略相較於大盤的超額報酬上漲力度有限。

第二張圖呈現回測期間的景氣指數(score),對應每次景氣輪動信號的背景經濟環境。可以發現,每次策略進場時機大多落在景氣由谷底回升或進入擴張的初期階段,符合經濟循環與資金輪動的邏輯。

第三張圖則顯示策略於整段回測期間的槓桿使用情況。除了兩個時間點因牛熊轉換而發生的換倉操作使槓桿瞬間升高至 2.0,其餘大多數期間均維持在 1.0 左右,顯示整體策略並未依賴過度槓桿來強化績效,槓桿使用風險控制得宜。

產業輪動

圖中顯示的是產業輪動策略的回撤圖以及深水圖,從圖中可以判斷大部分時期的回撤落在-10%以內,這是非常優秀的回撤結果,顯示出策略長期穩定獲利的能力。但是在2020年初有一小段時間的回撤為 -27% 左右,此時策略的持有標的為 0050 ETF 因此可以視為是市場的系統性風險所致。實際上當時的下跌段也是因為疫情的突然爆發而產生的,屬於黑天鵝事件,因此我們可以不用過於在意此時的下跌段。

多策略比較

產業輪動

上圖呈現四種策略在回測期間的績效與風險比較:

  • 紅色線(Ind):為本研究設計的「產業輪動策略」,依據航運與半導體之間的資訊傳遞關係進行動態調整;
  • 藍色線(All Stock):為牛市期間單純買入半導體族群個股,並於熊市訊號出現後退出市場的策略;
  • 紫色線(ALL ETF):為牛市期間單純持有 0050 ETF 的策略;
  • 灰色線(Benchmark):作為投資基準線,代表大盤報酬走勢。

從第一張圖的累積報酬表現可看出,藍色線策略雖然報酬率最高,但伴隨顯著波動風險。在第三張圖的波動度比較中也能觀察到,其大部分時間的波動率均高於其他策略。

相比之下,本研究提出的產業輪動策略(紅色線)表現穩健且具備良好的風險控制能力。其報酬表現雖略低於全股票策略,但明顯高於純 ETF 策略,且波動度位於兩者之間,達成風險與報酬之間的平衡。整體而言,產業輪動策略未出現因頻繁調倉導致績效下滑的情況,反而在資金流向判斷與進出時機上展現出實質優勢,具有實務應用潛力。

策略比較表格與分析

績效指標/策略產業輪動策略牛市半導體策略牛市0050策略Benchmark
年化報酬率28.584%33.23%21.24%12.047%
累積報酬率257.52%327.48%165.40%77.97%
年化波動度16.179%17.21%14.84%18.52%
夏普值1.641.751.370.71
卡瑪比率1.040.980.770.45
最大回撤期間-27.60%-34.07%-27.60%-26.74%
Alpha0.230.280.160
Beta0.390.420.390.93
註:卡瑪比率計算方式為年化波動度除以期間最大回撤,用以衡量「報酬率對虧損」的比值,概念類似於風暴比,此指標的數值越高越好。

本文所提出的產業輪動策略,在多項績效指標中展現出良好的風險報酬平衡。年化報酬率達 28.58%,雖略低於牛市期間全買半導體的策略(33.23%),但明顯高於僅持有 0050 ETF 的策略(21.24%)與大盤基準(12.05%)。累積報酬率亦達到 257.52%,展現強勁的長期成長能力。在風險方面,產業輪動策略的最大回撤為 -27.60%,控制水準與 ETF 策略相當,顯著優於半導體策略的 -34.07%。整體來看,該策略雖不以極端高報酬為目標,卻有效兼顧風險控制與報酬穩定性。

進一步觀察風險調整後的績效指標,產業輪動策略的夏普值為 1.64,卡瑪比率為 1.04,兩者皆優於 ETF 與大盤,且在卡瑪比率上為四項策略中最高,顯示策略能在承受相對可控虧損的情況下,取得相對優異的年化報酬。此外,Alpha 值達 0.23,說明在扣除市場影響後,仍具備明顯的超額報酬能力;而 Beta 僅為 0.39,代表策略波動對市場變動的敏感度較低,具有防禦性資產配置的特性。綜合上述結果,產業輪動策略在風險與報酬之間取得良好平衡,為實務操作上具潛力且穩健的投資方法。

完整程式碼連結


歡迎投資朋友參考,之後也會持續介紹使用 TEJ 資料庫來建構各式指標,並回測指標績效,所以歡迎對各種交易回測有興趣的讀者,選購 TQuant Lab 的相關方案,用高品質的資料庫,建構出適合自己的交易策略。
溫馨提醒,本次分析僅供參考,不代表任何商品或投資上的建議。

【TQuant Lab 回測系統】解決你的量化金融痛點

全方位提供交易回測所需工具

延伸閱讀

機器學習算法 XGBoost 提升技術指標一目均衡表的投資績效

事件型因子研究:公司宣告發放股利

揭開投資大師的選股密碼:麥克.喜偉收益型投資四大準則解析

相關連結

返回總覽頁
Processing...