Table of Contents
在金融市場中,「反向思考(Contrary Thinking)」是一種歷久不衰的智慧策略。它源於一個簡單但深刻的觀察:當大多數人極度樂觀時,市場往往已經過熱;當群眾陷入恐懼時,反而可能是低接良機。然而,反向操作長期以來多停留在諺語層次,缺乏具體、可量化的行動標準,導致實務上難以執行。
安東尼‧賈利亞(Anthony M. Gallea)與威廉‧巴特隆(William Patalon III)於《Contrarian Investing》一書中,首度將反向操作具體化為可執行的量化選股策略。賈利亞是所羅門美邦公司(Salomon Smith Barney)資深投資組合管理董事,帶領逾14人團隊、管理超過六億美元資產;巴特隆則為資深商業記者,曾四度獲得紐約州美聯社頒發之商業報導獎。兩人以豐富的實務與觀察經驗,構建出一套兼具理論深度與實用性的選股方法。
本策略採用多層次篩選邏輯,結合技術面、籌碼面與基本面條件,從中挑選出具備中期成長潛力的股票。首先,在技術面上,篩選最近一年內收盤價低於一年內最高價的 50% 的股票,作為初步具備反轉動能潛力的候選名單。
接著,在籌碼面條件中,若董事或經理人在最近六個月內持股比例增加至少 5%,或持股比例已達 10% 以上,則視為內部人具備明確信心的標的,亦納入考量;此外,投信若出現明顯加碼行為,也具備相同效力。若籌碼面條件滿足,則進一步檢視該公司是否具備基本面優勢,要求其符合以下四項條件中的至少兩項:
選出標的股票之後, 以等權重的方式去分配總體資金,以達到分散風險的效果,不爾會將資金過度集中於單一股票中。
資料代碼 | close_d | adjfac | fld005 | qfii_pct | fd_pct | ri |
科目 | 收盤價 | 調整係數 | 董監持股% | 外資持股率 | 投信持股率 | 常續性利益 |
資料代碼 | shares | per | cscfo | cscfi | r307 | r19 | open_d |
科目 | 流通在外股數 | 本益比 | 營運現金流 | 投資現金流 | 每股淨值 | 近12月每股營收(元) | 開盤價 |
import pandas as pd
import numpy as np
import tejapi
import os
import matplotlib.pyplot as plt
import datetime
plt.rcParams['font.family'] = 'Arial'
os.environ['TEJAPI_BASE'] = “your base"
os.environ['TEJAPI_KEY'] = "your tej api key"
from zipline.sources.TEJ_Api_Data import get_universe
import TejToolAPI
from zipline.data.run_ingest import simple_ingest
from zipline.api import set_slippage, set_commission, set_benchmark, symbol, record, order_target_percent
from zipline.finance import commission, slippage
from zipline import run_algorithm
start_date = '2015-01-01'
end_date = '2025-06-30'
pool = get_universe(start = start_date,
end = end_date,
mkt_bd_e = ['TSE', 'OTC', 'TIB'], # 已上市之股票
stktp_e = 'Common Stock') # 普通股
columns = ['Industry', 'close_d', 'adjfac', 'fld005', 'qfii_pct' , 'fd_pct' , 'ri' ,
'shares','per' , 'cscfo' , 'cscfi' , 'r307' , 'r19' , 'open_d', 'pbr', 'psr_tej']
start_dt = pd.Timestamp(start_date, tz = 'UTC')
end_dt = pd.Timestamp(end_date, tz = "UTC")
data = TejToolAPI.get_history_data(start = start_dt,
end = end_dt,
ticker = pool,
columns = columns,
transfer_to_chinese = True)
data = data.sort_values('日期')
# 依股票代碼進行時間序列運算
data['one_year_max_price'] = data.groupby('股票代碼')['收盤價'] \
.transform(lambda x: x.rolling(window=252, min_periods=1).max())
data['one_year_price'] = data.groupby('股票代碼')['收盤價'] \
.transform(lambda x: x.shift(252))
data['insider_holding_change_6m'] = data.groupby('股票代碼')['董監持股%'] \
.transform(lambda x: x - x.shift(126))
data['foreign_holding_change_6m'] = data.groupby('股票代碼')['外資持股率'] \
.transform(lambda x: x - x.shift(126))
data['fund_holding_change_6m'] = data.groupby('股票代碼')['投信持股率'] \
.transform(lambda x: x - x.shift(126))
# 自由現金流與估值比率
data['FCF'] = data['營運產生現金流量_Q'] - data['投資產生現金流量_Q']
data['Price_FCF'] = data['收盤價'] / data['FCF']
# 注意:這裡假設 '股價淨值比' 是 PBR,如有誤請調整
data['Price_pbr'] = data['收盤價'] / data['股價淨值比']
# 價格營收比
data['Price_Rev'] = data['收盤價'] / data['股價營收比_TEJ']
# 依產業計算平均本益比
data['industry_pe_avg'] = data.groupby('主產業別_中文')['本益比'] \
.transform('mean')
data_use = data.copy()
def compute_growth_strategy(date, data):
df = data[data['日期'] == pd.to_datetime(date)].reset_index(drop=True)
# 技術面條件:收盤價 > 一年內最高價 50%
tech_pass = set(df[df['收盤價'] < 0.5 * df['one_year_max_price']]['股票代碼'])
# 董事經理人條件
df['insider_6m_ago'] = df.groupby('股票代碼')['董監持股%'].shift(126)
df['insider_growth'] = df['董監持股%'] - df['insider_6m_ago']
insider_pass = set(df[(df['insider_growth'] >= 5) | (df['董監持股%'] >= 10)]['股票代碼'])
# 投信條件(附加條件)
df['fund_6m_ago'] = df.groupby('股票代碼')['投信持股率'].shift(126)
fund_pass = set(df[df['投信持股率'] >= df['fund_6m_ago']]['股票代碼'])
# 基本面條件(如果內部人或投信有持股,才檢查基本面)
df['cond_A'] = df['本益比'] < 0.85 * df['industry_pe_avg']
df['cond_B'] = df['Price_FCF'] < 1
df['cond_C'] = df['Price_pbr'] < 0.8
df['cond_D'] = df['Price_Rev'] < 1
df['fundamental_score'] = df[['cond_A', 'cond_B', 'cond_C', 'cond_D']].sum(axis=1)
df['fundamental_pass'] = df['fundamental_score'] >= 2
# 只對「內部人 or 投信」有動作的股票檢查基本面
trigger_set = insider_pass | fund_pass
fundamental_checked_set = set(df[df['股票代碼'].isin(trigger_set) & df['fundamental_pass']]['股票代碼'])
# 最終篩選:股價增長 + (內部人或投信) + 基本面
final_selection = tech_pass & trigger_set & fundamental_checked_set
print(f"下單日期:{date}, 股價年增長: {len(tech_pass)}, 董監條件: {len(insider_pass)}, 投信條件: {len(fund_pass)}, 觸發基本面: {len(trigger_set)}, 基本面通過: {len(fundamental_checked_set)}, 最終選股: {len(final_selection)}")
return final_selection
pools = pool + ['IR0001']
start_ingest = start_date.replace('-', '')
end_ingest = end_date.replace('-', '')
print(f'開始匯入回測資料')
simple_ingest(name = 'tquant' , tickers = pools , start_date = start_ingest , end_date = end_ingest)
print(f'結束匯入回測資料')
def initialize(context, re = 120):
set_slippage(slippage.VolumeShareSlippage(volume_limit=1, price_impact=0.01))
set_commission(commission.Custom_TW_Commission())
set_benchmark(symbol('IR0001'))
context.i = 0
context.state = False
context.order_tickers = []
context.last_tickers = []
context.rebalance = re
def handle_data_1(context, data):
# 避免前視偏誤,在篩選股票下一交易日下單
if context.state == True:
for i in context.last_tickers:
if i not in context.order_tickers:
order_target_percent(symbol(i), 0)
for i in context.order_tickers:
order_target_percent(symbol(i), 1.0 / len(context.order_tickers))
print(f"下單日期:{data.current_dt.date()}, 擇股股票數量:{len(context.order_tickers)}, Leverage: {context.account.leverage}")
context.last_tickers = context.order_tickers.copy()
context.state = False
backtest_date = data.current_dt.date()
if context.i % context.rebalance == 0:
context.state = True
context.order_tickers = compute_growth_strategy(date = backtest_date, data = data_use)
record(tickers = context.order_tickers)
record(Leverage = context.account.leverage)
if context.account.leverage > 1.2:
print(f'{data.current_dt.date()}: Over Leverage, Leverage: {round(context.account.leverage, 2)}')
for i in context.order_tickers:
order_target_percent(symbol(i), 1 / len(context.order_tickers))
context.i += 1
def analyze(context, perf):
print(perf.columns)
plt.style.use('ggplot')
# 第一張圖:策略績效與報酬
fig1, axes1 = plt.subplots(nrows=2, ncols=1, figsize=(18, 10), sharex=False)
axes1[0].plot(perf.index, perf['algorithm_period_return'], label='Strategy')
axes1[0].plot(perf.index, perf['benchmark_period_return'], label='Benchmark')
axes1[0].bar(perf.index, perf['algorithm_period_return'] - perf['benchmark_period_return'],
label='Excess return', color='g', alpha=0.4)
axes1[0].set_title("Backtest Results")
axes1[0].legend()
axes1[1].plot(perf.index, perf['benchmark_volatility'], label='Benchmark Volatility')
axes1[1].plot(perf.index, perf['algo_volatility'], label='Strategy Volatility')
axes1[1].set_title("Voloatility")
axes1[1].legend()
plt.tight_layout()
plt.show()
results = run_algorithm(
start = pd.Timestamp('2018-01-01', tz = 'utc'),
end = pd.Timestamp(end_date, tz = 'utc'),
initialize = initialize,
handle_data = handle_data_1,
analyze = analyze,
bundle = 'tquant',
capital_base = 1e5)
績效指標 / 策略 | 大盤(Benchmark) | 本投資策略 |
年化報酬率 | 13.91% | 14.53% |
累積報酬率 | 156.29% | 166.60% |
年化波動度 | 17.54% | 23.93% |
夏普值 | 0.83 | 0.69 |
卡瑪比率 | 0.51 | 0.33 |
期間最大回撤 | -27.37% | -43.47% |
從回測結果來看,本投資策略在 年化報酬率(14.53%)與累積報酬率(166.6%) 上略優於大盤(13.91% / 156.29%),但整體風險調整後的表現並不理想。策略的 年化波動度高達 23.93%,遠高於大盤的 17.54%,導致 夏普值僅為 0.69,低於大盤的 0.83,代表單位風險所帶來的報酬反而較差。此外,最大回撤高達 -43.47%,相較於大盤的 -27.37%,顯示該策略在市場波動時缺乏足夠的防禦能力。
策略的 卡瑪比率為 0.33,也低於大盤的 0.51,意味著其承受每一單位最大損失所換得的報酬相對偏低,這點在圖中「Excess return」長期為負的部分亦可見端倪,特別是在 2021~2024 年間策略表現明顯落後。
歡迎投資朋友參考,之後也會持續介紹使用 TEJ 資料庫來建構各式指標,並回測指標績效,所以歡迎對各種交易回測有興趣的讀者,選購 TQuant Lab 的相關方案,用高品質的資料庫,建構出適合自己的交易策略。
溫馨提醒,本次分析僅供參考,不代表任何商品或投資上的建議。