01技術分析的基本觀念
第十章已完成股票報酬、基本面與移動平均的基礎。本章進一步建立完整的技術分析與量化回測系統:從 K 線、七大技術指標,到明確的買賣規則、交易成本處理、績效評估,以及最重要的回測偏誤防範。
技術分析常見誤解:「看到 RSI 低於 30 就買進」、「黃金交叉出現就一定漲」。實際上技術指標只是將歷史資料重新整理,不能保證預測未來。本章的目標是建立可以被測試、比較、否定的明確策略,而不是尋找「必勝法則」。
先看一個反直覺的例子:某策略勝率 80%(每 10 筆有 8 筆獲利),看起來很好。但若平均獲利 1%、平均虧損 6%,單筆期望值 = 0.8×1% − 0.2×6% = −0.4%,這是一個虧損策略。本章會建立系統計算所有這些數字。
技術分析的四大類指標
| 趨勢指標 | 動能指標 | 波動指標 | 成交量指標 |
|---|---|---|---|
| SMA、EMA、MACD、均線交叉、乖離率 | RSI、KD、ROC、威廉指標 | 布林通道、ATR、Keltner 通道 | 成交量均線、量比、OBV、VWAP |
| 用途:判斷市場方向 / 衡量漲跌力道與超買超賣 / 衡量波動幅度、設停損 / 觀察資金流向、驗證訊號 | |||
技術分析的三項基本假設
| 假設 | 說明 | 初學者注意事項 |
|---|---|---|
| 市場資訊反映於價格 | 所有已知資訊已被市場定價 | 但未知資訊和情緒隨時可能打破技術規律 |
| 價格可能形成趨勢 | 資產價格有時會持續一個方向 | 趨勢不保證持續,轉折可能突然發生 |
| 投資人行為可能重複 | 恐懼與貪婪造成類似的市場模式 | 「可能重複」不等於「一定重複」 |
技術指標只是計算歷史資料的方法,本身不能預測未來。20 日均線就是過去 20 天的平均收盤價,RSI 就是過去 14 天漲幅的比率。沒有任何指標能穩定預測明天的方向。
OHLCV 資料格式——技術分析使用 OHLCV 資料:Open(開盤)/ High(最高)/ Low(最低)/ Close(收盤)/ Volume(成交量)。
| 欄位 | 意義 | 資料品質要求 |
|---|---|---|
| open | 當日開盤價 | 必須 ≤ high,≥ low |
| high | 當日最高價 | 必須 ≥ open、close、low |
| low | 當日最低價 | 必須 ≤ open、close、high |
| close | 當日收盤價 | 最重要欄位;長期回測用調整後收盤 |
| volume | 當日成交量 | 必須 ≥ 0 |
長期報酬回測應使用 adjusted_close(調整後收盤價),已還原除權息影響。若使用一般收盤價,配股或配息日會出現價格跳空,造成報酬計算失真。
02讀取與清理 OHLCV 資料
TRADING_DAYS_PER_YEAR = 252
REQUIRED_COLUMNS = {'date','symbol','open','high','low','close','volume'}
def clean_ohlcv_data(data: pd.DataFrame) -> pd.DataFrame:
result = data.copy()
result.columns = [str(c).strip().lower() for c in result.columns]
missing = REQUIRED_COLUMNS - set(result.columns)
if missing: raise ValueError(f'缺少必要欄位:{", ".join(sorted(missing))}')
result['date'] = pd.to_datetime(result['date'], errors='coerce')
result['symbol'] = result['symbol'].astype('string').str.strip().str.upper()
for col in ['open','high','low','close','volume']:
result[col] = pd.to_numeric(result[col], errors='coerce')
result = result.dropna(subset=list(REQUIRED_COLUMNS))
# 價格必須為正、成交量非負
result = result[(result['open']>0)&(result['high']>0)&
(result['low']>0)&(result['close']>0)&(result['volume']>=0)]
# OHLC 結構驗證:最高價必須不低於開盤、收盤、最低
valid = ((result['high']>=result['open'])&(result['high']>=result['close'])&
(result['high']>=result['low'])&(result['low']<=result['open'])&
(result['low']<=result['close']))
result = result[valid]
result = result.drop_duplicates(subset=['date','symbol'], keep='last')
return result.sort_values(by=['symbol','date']).reset_index(drop=True)
03七大技術指標速查表
| 指標 | 常見設定 | 公式核心 | 常見判讀 | 主要限制 |
|---|---|---|---|---|
| SMA | 5/20/60 日 | N 日收盤平均 | 短均 > 長均:多頭趨勢 | 落後;盤整假訊號多 |
| EMA | 12/26 日 | alpha=2/(N+1),近期權重高 | 反應比 SMA 快 | 對雜訊更敏感 |
| RSI | 14 日 | 平均漲幅/(漲+跌)×100 | >70 超買;<30 超賣 | 強勢可長期鈍化 |
| MACD | 12/26/9 | EMA₁₂−EMA₂₆;訊號線EMA₉ | 上穿訊號線:動能轉強 | 也是落後指標 |
| KD | 9/3 日 | RSV=(C−Ln)/(Hn−Ln)×100 | K上穿D:短期動能轉強 | >80可能鈍化持續很久 |
| 布林通道 | 20日,2σ | 中軌±2×20日標準差 | 收盤>上軌:突破 | 通道寬窄受波動度驅動 |
| ATR | 14 日 | 三者取最大後平均 | 衡量波動幅度,非方向 | 不是趨勢指標 |
04移動平均線(SMA 與 EMA)
| 項目 | SMA | EMA |
|---|---|---|
| 各期權重 | 完全相同 | 近期較高,遠期遞減 |
| 反應速度 | 較慢(需等所有日期更新) | 較快(立即反映最新收盤) |
| 假訊號 | 相對較少 | 可能較多(過於敏感) |
| 常見用途 | 中長期趨勢、支撐壓力 | MACD 基礎、短期趨勢 |
def add_sma(data: pd.DataFrame, windows: list[int]) -> pd.DataFrame:
result = data.copy()
for w in windows:
if w <= 0: raise ValueError('均線期間必須大於 0。')
result[f'sma_{w}'] = result.groupby('symbol')['close'].transform(
lambda p: p.rolling(window=w, min_periods=w).mean()
)
return result
def add_ema(data: pd.DataFrame, spans: list[int]) -> pd.DataFrame:
result = data.copy()
for span in spans:
if span <= 0: raise ValueError('EMA 期間必須大於 0。')
result[f'ema_{span}'] = result.groupby('symbol')['close'].transform(
lambda p: p.ewm(span=span, adjust=False, min_periods=span).mean()
)
return result
def add_bias(data: pd.DataFrame, window: int = 20) -> pd.DataFrame:
"""乖離率:(收盤 / 均線 − 1),正值代表價格高於均線。"""
result = add_sma(data, [window])
result[f'bias_{window}'] = result['close'] / result[f'sma_{window}'] - 1
return result
def add_ma_cross_signals(data, short_window=20, long_window=60) -> pd.DataFrame:
if long_window <= short_window: raise ValueError('長期均線必須大於短期均線。')
result = add_sma(data, [short_window, long_window])
short_col, long_col = f'sma_{short_window}', f'sma_{long_window}'
# trend_position = 1:短均 > 長均(多頭趨勢);0:空頭趨勢
result['trend_position'] = (result[short_col] > result[long_col]).astype(int)
result['prev_position'] = result.groupby('symbol')['trend_position'].shift(1).fillna(0)
result['buy_signal'] = (result['trend_position']==1)&(result['prev_position']==0)
result['sell_signal'] = (result['trend_position']==0)&(result['prev_position']==1)
return result
05動能指標(RSI 與 MACD)
RSI > 70 常被視為「超買」;RSI < 30 常被視為「超賣」。但在強趨勢中,RSI 可能長時間停留在 70 以上(超買鈍化)或 30 以下(超賣鈍化)。不能只看 RSI 超買就直接放空。
def add_rsi(data: pd.DataFrame, period: int = 14) -> pd.DataFrame:
if period <= 0: raise ValueError('RSI 期間必須大於 0。')
result = data.copy()
def calc_rsi(group):
group = group.copy()
diff = group['close'].diff()
gains = diff.clip(lower=0) # 只保留上漲幅度
losses = (-diff.clip(upper=0)) # 只保留下跌幅度(轉正)
# Wilder 平滑:alpha = 1/period
avg_gain = gains.ewm(alpha=1/period, adjust=False, min_periods=period).mean()
avg_loss = losses.ewm(alpha=1/period, adjust=False, min_periods=period).mean()
rs = avg_gain / avg_loss.replace(0, np.nan)
rsi = 100 - 100/(1+rs)
rsi = rsi.where(avg_loss != 0, 100.0) # 沒有下跌時 RSI = 100
rsi = rsi.where((avg_gain != 0)|(avg_loss != 0), 50.0) # 完全靜止 = 50
group[f'rsi_{period}'] = rsi
return group
return result.groupby('symbol', group_keys=False).apply(calc_rsi).reset_index(drop=True)
MACD > 0 代表短期 EMA 高於長期 EMA(多頭動能)。MACD 上穿訊號線:動能轉強(潛在買訊);下穿:動能轉弱。
def add_macd(data, fast=12, slow=26, signal=9) -> pd.DataFrame:
if slow <= fast: raise ValueError('慢速 EMA 必須大於快速 EMA。')
result = data.copy()
def calc_macd(group):
group = group.copy()
ema_fast = group['close'].ewm(span=fast, adjust=False, min_periods=fast).mean()
ema_slow = group['close'].ewm(span=slow, adjust=False, min_periods=slow).mean()
group['macd'] = ema_fast - ema_slow
group['macd_signal'] = group['macd'].ewm(span=signal, adjust=False, min_periods=signal).mean()
group['macd_histogram'] = group['macd'] - group['macd_signal']
return group
return result.groupby('symbol', group_keys=False).apply(calc_macd).reset_index(drop=True)
06波動指標(KD/布林通道/ATR)
K > D 且 K 上穿 D:短期動能轉強。KD > 80:相對高檔;KD < 20:相對低檔。高檔鈍化(KD 長時間 > 80)常見於強勢股,不能只因 KD > 80 就直接賣出。
def add_stochastic_kd(data, lookback=9, smooth=3) -> pd.DataFrame:
result = data.copy()
def calc_kd(group):
group = group.copy()
lo = group['low'].rolling(window=lookback, min_periods=lookback).min()
hi = group['high'].rolling(window=lookback, min_periods=lookback).max()
denom = (hi - lo).replace(0, np.nan) # 防止除以零(最高=最低)
group['rsv'] = (group['close'] - lo) / denom * 100
group['k_value'] = group['rsv'].ewm(alpha=1/smooth, adjust=False, min_periods=1).mean()
group['d_value'] = group['k_value'].ewm(alpha=1/smooth, adjust=False, min_periods=1).mean()
return group
return result.groupby('symbol', group_keys=False).apply(calc_kd).reset_index(drop=True)
def add_bollinger_bands(data, window=20, n_std=2.0) -> pd.DataFrame:
if window <= 1: raise ValueError('布林通道期間必須大於 1。')
result = data.copy()
g = result.groupby('symbol')['close']
result['bollinger_middle'] = g.transform(lambda p: p.rolling(window, min_periods=window).mean())
result['bollinger_std'] = g.transform(lambda p: p.rolling(window, min_periods=window).std(ddof=1))
result['bollinger_upper'] = result['bollinger_middle'] + n_std * result['bollinger_std']
result['bollinger_lower'] = result['bollinger_middle'] - n_std * result['bollinger_std']
result['bollinger_bandwidth'] = (result['bollinger_upper'] - result['bollinger_lower']) / result['bollinger_middle']
return result
def add_atr(data: pd.DataFrame, period: int = 14) -> pd.DataFrame:
result = data.copy()
def calc_atr(group):
group = group.copy()
prev_c = group['close'].shift(1)
# 真實波幅 = 三者取最大
tr = pd.concat([
group['high'] - group['low'], # 當日高低幅
(group['high'] - prev_c).abs(), # 昨收到今日最高
(group['low'] - prev_c).abs(), # 昨收到今日最低
], axis=1).max(axis=1)
group['true_range'] = tr
group[f'atr_{period}'] = tr.ewm(alpha=1/period, adjust=False, min_periods=period).mean()
return group
return result.groupby('symbol', group_keys=False).apply(calc_atr).reset_index(drop=True)
量價組合判讀
| 量價組合 | 常見解讀 | 注意事項 |
|---|---|---|
| 上漲 + 成交量增加 | 上漲獲得交易量支持,較有說服力 | 仍需觀察後續走勢確認 |
| 上漲 + 成交量減少 | 上漲動能可能不足,需警惕 | 短期整理後也可能繼續上漲 |
| 下跌 + 成交量增加 | 賣壓或恐慌性出脫增加 | 也可能是大量買盤在低點接手 |
量價關係只是常見解讀,不是必然規則。同一組量價現象在不同市場環境下可能意義不同。
07一次產生所有技術指標
def add_all_indicators(data: pd.DataFrame) -> pd.DataFrame:
result = clean_ohlcv_data(data)
result = add_sma(result, [5, 20, 60]) # SMA 5/20/60 日
result = add_ema(result, [12, 26]) # EMA 12/26 日
result = add_bias(result, window=20) # 20 日乖離率
result = add_rsi(result, period=14) # RSI 14 日
result = add_macd(result) # MACD 12/26/9
result = add_stochastic_kd(result) # KD 9/3 日
result = add_bollinger_bands(result) # 布林 20日/2σ
result = add_atr(result, period=14) # ATR 14 日
result = add_volume_indicators(result, window=20) # 量比 20 日
return result
08策略回測九步驟流程
一個可靠的回測需要按照固定順序處理九個問題。初學者常見錯誤是先寫程式,再問「我的訊號應該延後幾天執行?」——這些問題必須在設計策略時就先決定。
| 步 | 步驟 | 說明 |
|---|---|---|
| ① | 準備 OHLCV 資料 | 讀取日期、開盤、最高、最低、收盤、成交量;清理缺漏與格式錯誤 |
| ② | 計算技術指標 | SMA/EMA/RSI/MACD/KD/布林通道/ATR/成交量比率 |
| ③ | 定義進出場規則 | 明確的買進條件(如均線交叉)與賣出條件(如跌破均線或停損) |
| ④ | 產生訊號並延遲一期 | today = signal.shift(1),確保今日收盤訊號在明日才執行,避免未來函數 |
| ⑤ | 計算每日策略報酬 | position × daily_return,部位為 0 時不持有(空手) |
| ⑥ | 扣除交易成本 | 換手時扣買賣手續費+稅,頻繁換手策略成本可能大於獲利 |
| ⑦ | 建立交易紀錄 | 每筆進場/出場日期、價格、持有天數、毛報酬與淨報酬 |
| ⑧ | 計算績效指標 | 總報酬、年化報酬、Sharpe、Sortino、MDD、Calmar、勝率、盈虧比、獲利因子 |
| ⑨ | 比較基準 + 偏誤檢查 | 與買進持有比較;檢查未來函數、過度配適、倖存者偏誤、成本低估 |
步驟④(訊號延遲)是最常被忽略的。今日收盤後才知道 RSI < 30,最快只能明日開盤執行買進。若不延遲,等於用了未來資訊,稱為「未來函數偏誤」,會嚴重高估策略績效。
09三種策略實作
策略一:均線交叉策略(趨勢追蹤)——規則:SMA₂₀ > SMA₆₀ 時持有(做多);SMA₂₀ ≤ SMA₆₀ 時空手。優點:規則簡單明確,可客觀重現。缺點:盤整市場頻繁假訊號,成本侵蝕。
def create_ma_strategy(data, short_window=20, long_window=60) -> pd.DataFrame:
result = add_ma_cross_signals(data, short_window, long_window)
result['raw_signal'] = result['trend_position'] # 今日收盤後確定的訊號
result['position'] = (result.groupby('symbol')['raw_signal']
.shift(1).fillna(0)) # 明日才生效
return result
策略二:區間突破策略(突破追蹤)——規則:收盤價突破前 N 日最高價時買進;跌破前 M 日最低價時出場。重點:比較基準必須「排除當日資料」(shift(1) 後再取 rolling max),否則當日收盤突破當日最高,等於未來函數。
def create_breakout_strategy(data, entry_window=20, exit_window=10) -> pd.DataFrame:
result = data.copy()
# shift(1) 確保比較的是前 N 日(不含當日)的高點/低點
result['prev_high'] = result.groupby('symbol')['high'].transform(
lambda p: p.shift(1).rolling(window=entry_window, min_periods=entry_window).max())
result['prev_low'] = result.groupby('symbol')['low'].transform(
lambda p: p.shift(1).rolling(window=exit_window, min_periods=exit_window).min())
result['entry_signal'] = result['close'] > result['prev_high'] # 突破前高
result['exit_signal'] = result['close'] < result['prev_low'] # 跌破前低
# 需逐日追蹤持倉狀態(進場→持有→出場)
positions = []
for _, group in result.groupby('symbol', sort=False):
pos = 0
for row in group.itertuples():
if pos == 0 and bool(row.entry_signal): pos = 1
elif pos == 1 and bool(row.exit_signal): pos = 0
positions.append(pos)
result['raw_signal'] = positions
result['position'] = result.groupby('symbol')['raw_signal'].shift(1).fillna(0)
return result
策略三:RSI 均值回歸策略——規則:RSI 低於 30 後反彈上穿 30 時買進;RSI 高於 60 時賣出。限制:若股票基本面惡化,RSI 可能長時間低於 30(「超賣鈍化」),此時每次買進都是加倉虧損。
10交易成本與每日策略報酬
假設某策略未扣成本的每筆平均獲利為 0.15%,但台灣股市買進手續費 0.1425%、賣出 0.1425% + 0.3% 交易稅,合計單邊約 0.21%,雙邊合計 0.44%。這樣的策略在扣除成本後,每筆平均損失 0.29%。
| 成本項目 | 典型費率 | 說明 |
|---|---|---|
| 買進手續費 | 0.1425%(含折扣約 0.1%) | 最低 20 元;實際費率依各券商不同 |
| 賣出手續費 | 0.1425%(含折扣約 0.1%) | 同買進 |
| 證券交易稅 | 賣出金額 × 0.3%(一般股票) | 台灣特有;ETF 為 0.1% |
| 滑價 | 估計 0.05%–0.5% | 市場影響成本,成交量低時較高 |
def calculate_strategy_returns(strategy_data, one_way_cost=0.0015, price_col='close') -> pd.DataFrame:
if one_way_cost < 0: raise ValueError('交易成本率不得為負數。')
result = strategy_data.copy()
result['asset_return'] = result.groupby('symbol')[price_col].pct_change(fill_method=None)
# 換手率:部位變動量(0→1=買進,1→0=賣出)
result['prev_position'] = result.groupby('symbol')['position'].shift(1).fillna(0)
result['turnover'] = (result['position'] - result['prev_position']).abs()
result['transaction_cost'] = result['turnover'] * one_way_cost
result['gross_return'] = result['position'] * result['asset_return'] # 策略毛報酬
result['net_return'] = result['gross_return'] - result['transaction_cost'] # 策略淨報酬
# 財富指數:策略 vs 買進持有
result['strategy_wealth'] = (1 + result['net_return'].fillna(0)).groupby(result['symbol']).cumprod()
result['buy_hold_wealth'] = (1 + result['asset_return'].fillna(0)).groupby(result['symbol']).cumprod()
result['strategy_peak'] = result.groupby('symbol')['strategy_wealth'].cummax()
result['strategy_dd'] = result['strategy_wealth'] / result['strategy_peak'] - 1
return result
11建立交易紀錄
每日報酬可以評估整體資產曲線,但交易分析需要逐筆紀錄每次「買進→持有→賣出」的完整資訊。
def build_trade_log(data, one_way_cost=0.0015) -> pd.DataFrame:
trades = []
for symbol, group in data.groupby('symbol', sort=False):
group = group.sort_values('date').reset_index(drop=True)
in_pos = False
entry_date = entry_price = None
for row in group.itertuples():
pos = int(row.position)
if not in_pos and pos == 1: # 買進
in_pos, entry_date, entry_price = True, row.date, float(row.open)
elif in_pos and pos == 0: # 賣出
exit_date, exit_price = row.date, float(row.open)
gross = exit_price / entry_price - 1
net = gross - 2 * one_way_cost # 買+賣各一次
trades.append({'symbol': symbol, 'entry_date': entry_date, 'entry_price': entry_price,
'exit_date': exit_date, 'exit_price': exit_price,
'holding_days': (exit_date - entry_date).days,
'gross_return': gross, 'net_return': net})
in_pos = entry_date = entry_price = None
if in_pos and entry_price is not None: # 未平倉→以最後收盤計算
last = group.iloc[-1]
gross = float(last['close']) / entry_price - 1
trades.append({'symbol': symbol, 'entry_date': entry_date, 'entry_price': entry_price,
'exit_date': last['date'], 'exit_price': float(last['close']),
'holding_days': (last['date'] - entry_date).days,
'gross_return': gross, 'net_return': gross - 2*one_way_cost})
return pd.DataFrame(trades)
12績效指標:勝率、盈虧比與期望值
初學者最常犯的錯誤是「只看勝率」。高勝率不代表高報酬,關鍵是勝率 × 平均獲利 − 敗率 × 平均虧損(期望值)。
| 組合類型 | 實例計算 | 注意事項 |
|---|---|---|
| 勝率高、盈虧比低 | 勝率80%,平均賺1%,平均賠6% 期望值:0.8×1%−0.2×6% = −0.4%(負期望!) | ⚠️ 常見於停利過近、停損過遠的策略 |
| 勝率低、盈虧比高 | 勝率40%,平均賺10%,平均賠3% 期望值:0.4×10%−0.6×3% = +2.2%(正期望) | ⚠️ 趨勢追蹤常見,需承受連續虧損 |
| 勝率高、盈虧比高 | 勝率60%,平均賺5%,平均賠2% 期望值:0.6×5%−0.4×2% = +2.2%(正期望) | ⚠️ 理想組合,但需檢查樣本量是否足夠 |
| 勝率低、盈虧比低 | 勝率35%,平均賺2%,平均賠4% 期望值:0.35×2%−0.65×4% = −1.9%(負期望) | ⚠️ 最不利組合,通常應放棄 |
def calculate_backtest_metrics(backtest_data, trade_log, annual_rf=0.0) -> dict:
returns = backtest_data['net_return'].dropna().astype(float)
wealth = (1 + returns).cumprod()
total_ret = float(wealth.iloc[-1]) - 1
n = len(returns)
ann_ret = float(wealth.iloc[-1] ** (TRADING_DAYS_PER_YEAR/n) - 1)
ann_vol = float(returns.std(ddof=1)) * math.sqrt(TRADING_DAYS_PER_YEAR)
mdd = float(backtest_data['strategy_dd'].min()) # 必須是 min(),結果應為負值
calmar = ann_ret / abs(mdd) if mdd < 0 else math.nan
tr = trade_log['net_return'].dropna().astype(float)
wins = tr[tr > 0]; losses = tr[tr < 0]
cnt = len(tr)
win_rate = len(wins)/cnt if cnt>0 else math.nan
avg_win = float(wins.mean()) if not wins.empty else 0.0
avg_loss = abs(float(losses.mean())) if not losses.empty else 0.0
payoff = avg_win/avg_loss if avg_loss>0 else math.inf
pf = float(wins.sum())/abs(float(losses.sum())) if losses.sum()!=0 else math.inf
expectancy = win_rate*avg_win - (1-win_rate)*avg_loss
return {'total_return': total_ret, 'annualized_return': ann_ret, 'maximum_drawdown': mdd,
'calmar_ratio': calmar, 'trade_count': cnt, 'win_rate': win_rate,
'payoff_ratio': payoff, 'profit_factor': pf, 'expectancy': expectancy}
13完整回測整合函數
def run_ma_backtest(data, short_window=20, long_window=60,
one_way_cost=0.0015, annual_rf=0.0) -> dict:
"""整合入口:清理 → 指標 → 策略 → 報酬 → 交易紀錄 → 績效"""
cleaned = clean_ohlcv_data(data)
with_indicators = add_all_indicators(cleaned)
strategy_data = create_ma_strategy(with_indicators, short_window, long_window)
backtest_data = calculate_strategy_returns(strategy_data, one_way_cost)
trade_log = build_trade_log(backtest_data, one_way_cost)
metrics = calculate_backtest_metrics(backtest_data, trade_log, annual_rf)
return {'backtest_data': backtest_data, 'trade_log': trade_log, 'metrics': metrics}
14買賣點標示與圖表
Streamlit 工具使用 Plotly 繪製三種圖表:K 線圖(含均線與買賣點三角符號)、策略與買進持有績效比較圖、策略回撤圖。買進點通常以綠色向上三角標示,賣出點以紅色向下三角標示,方便在 K 線圖上直接辨識進出場時機。
15回測六大偏誤(防範指南)
| 偏誤類型 | 定義 | 常見錯誤範例 | 防範方法 |
|---|---|---|---|
| 未來函數偏誤 | 使用當時尚未知道的資料 | 今日收盤產生訊號,假設今日收盤成交 | 訊號一律 shift(1);交易次日開盤執行 |
| 倖存者偏誤 | 只用現存股票,忽略下市公司 | 從目前上市的 100 家公司回測,選最好的 10 家 | 納入所有歷史成分股,包含已下市者 |
| 選樣偏誤 | 先看到好表現,再用它測策略 | 看到某股票過去三年漲很多才選它回測 | 先定義策略規則,再盲目選取股票 |
| 過度配適 | 反覆調參直到歷史績效最好 | 測試 200 組均線參數,選最好那組公開 | 樣本外測試;附近參數績效應相近 |
| 成本低估 | 忽略手續費、稅、滑價 | 假設策略年化報酬 10%,但未扣每筆成本 | 納入實際成本;高頻策略成本特別敏感 |
| 流動性偏誤 | 假設任何時點都能以目標價成交 | 小型股每日成交量 5 萬股卻假設能立即買 10 萬股 | 加入流動性條件;大量交易需考慮市場衝擊 |
未來函數偏誤:最常見的致命錯誤
# ✗ 錯誤:今日收盤訊號 × 今日收盤報酬(等於預知當天收盤) strategy_return = signal * daily_return # ✓ 正確:今日收盤訊號,明日才執行 position = signal.shift(1) # 訊號延後一日 strategy_return = position * daily_return
樣本外測試是防範過度配適的核心方法:前 70% 資料用於設計策略,後 30% 資料用於驗證。時間序列不可隨機打亂,未來不能進入訓練集。
16Streamlit 技術分析與回測工具
import pandas as pd, streamlit as st
from technical_analysis import add_all_indicators, run_ma_backtest
st.set_page_config(page_title='股票技術分析與回測', page_icon='📈', layout='wide')
st.title('股票技術分析、買賣點與策略回測')
uploaded = st.file_uploader('上傳 OHLCV 股票 CSV', type=['csv'])
if uploaded is None:
st.info('請上傳包含 date、symbol、open、high、low、close、volume 欄位的 CSV。')
st.stop()
raw_data = pd.read_csv(uploaded)
ind_data = add_all_indicators(raw_data)
with st.sidebar:
symbol = st.selectbox('選擇股票', sorted(ind_data['symbol'].dropna().unique()))
short_w = st.number_input('短期均線(日)', min_value=2, value=20, step=1)
long_w = st.number_input('長期均線(日)', min_value=3, value=60, step=1)
cost_pc = st.number_input('單邊成本(%)', min_value=0.0, value=0.15, step=0.01)
sym_data = ind_data[ind_data['symbol']==symbol].copy().reset_index(drop=True)
result = run_ma_backtest(sym_data, int(short_w), int(long_w), cost_pc/100)
metrics = result['metrics']
# 績效指標卡(3 列 × 4 欄 = 12 指標)
r1 = st.columns(4)
r1[0].metric('總報酬', f"{metrics['total_return']:.2%}")
r1[1].metric('年化報酬率', f"{metrics['annualized_return']:.2%}")
r1[2].metric('最大回撤', f"{metrics['maximum_drawdown']:.2%}")
r1[3].metric('勝率', f"{metrics['win_rate']:.2%}")
st.subheader('K 線、均線與買賣點')
st.plotly_chart(create_candlestick_chart(result['backtest_data'], short_w, long_w))
st.warning('回測依歷史資料與特定假設計算。歷史績效不代表未來結果,買賣點僅為模型訊號,不構成投資建議。')
17實務案例
(一)高勝率但負期望值的策略
某策略 100 筆交易:80 筆獲利(平均 1%)、20 筆虧損(平均 6%)。期望值 = 0.8×1% − 0.2×6% = −0.4%。每筆交易平均虧 0.4%,100 筆後累積損失約 33%(複利)。評估策略必須同時看勝率、平均獲利、平均虧損,不能只看勝率。
(三)均線策略遇到盤整市場
價格在 100 元附近橫盤震盪三個月,短期均線與長期均線反覆交叉。策略在此期間可能進行 8–10 次買賣,每次損失手續費約 0.15%,合計損失約 1.2%–1.5%,卻沒有趨勢可以獲利。均線策略在盤整市場表現通常較差。
(四)RSI 超賣鈍化
某公司財報爆雷後持續下跌,RSI 從第一天就低於 30。若策略在 RSI < 30 時就買進,可能在股票下跌 60% 的過程中多次加碼,造成嚴重損失。RSI 低於 30 只代表相對弱勢,不代表股票已經落底。
(五)過度配適的陷阱
研究者測試 200 組均線參數,發現 23 日/71 日均線組合在過去三年的樣本內年化報酬率 32%。但樣本外測試(後一年)報酬 −8%。這很可能是過度配適:23/71 只是剛好配合了過去三年的市場走勢。防範方法:測試附近參數(22/70, 24/72)是否仍有合理表現。
(六)突破策略的假突破與應對
股價突破前 20 日高點但成交量僅為均量的 0.6 倍(縮量突破),隔日即跌回區間,此為「假突破」。常見應對:加入成交量確認條件(volume_ratio > 1.2)、等待收盤確認、設定 ATR 停損、要求連續兩日收盤高於前高。
18常見錯誤與除錯
| 錯誤 | 症狀 | 正確做法 |
|---|---|---|
| 均線期間不足 | 只有 30 日資料卻計算 60 日均線,前 59 日全是 NaN | 不填補 NaN,等到足夠期間才開始計算 |
| RSI 除以零 | 平均下跌幅度 = 0 時,程式出現除以零或無限大 | average_loss = 0 → RSI = 100 |
| 訊號與部位錯位 | 買進訊號 shift 後仍與當日報酬對齊,有未來函數 | 確認 position = signal.shift(1) 且在正確欄位 |
| 重複扣成本 | 交易紀錄已扣成本,每日報酬又再扣,雙重扣除 | 建立一致的成本規則,只在一處扣除 |
| MDD 顯示正值 | 最大回撤顯示 +25%,應為 −25% | MDD = min(V/Peak−1),必須為負值 |
| 多股票混合計算 | 跨股票計算均線,結果混淆 | 所有指標、報酬一律 groupby('symbol') 分組計算 |
19關鍵字
20實作練習
- 讀取 OHLCV CSV,執行清理函數,確認 high ≥ open/close/low
- 計算 SMA 5/20/60 日,確認前 N-1 日為 NaN(不填補)
- 計算 RSI 14 日,確認範圍在 0–100,全漲時 = 100
- 計算 MACD 12/26/9,繪製 MACD 線、訊號線、柱狀體
- 計算 20 日布林通道,確認上/下軌不能超過設定倍數標準差以外
- 計算 ATR 14 日,確認第一日 TR 沒有「前一日收盤」時正確處理
- 建立均線交叉策略,產生 raw_signal 後用 shift(1) 延遲
- 加入單邊成本 0.15%,計算策略淨報酬
- 建立策略財富指數與買進持有財富指數,繪製比較圖
- 計算最大回撤,確認為負值,並繪製回撤圖
- 建立交易紀錄,計算勝率、平均獲利、平均虧損、盈虧比、獲利因子、期望值
- 計算 Sharpe、Sortino、Calmar 比率
- 建立 RSI 均值回歸策略並與均線策略比較績效
- 建立 20 日高點突破策略,加入成交量 > 1.2 倍均量的確認條件
- 進行參數敏感度分析(測試短均 10–30 日、長均 40–100 日的 5×5 矩陣)
- 建立樣本內(前 70%)/ 樣本外(後 30%)切分,驗證策略穩健性
- 對多檔股票執行回測並彙整平均績效,注意 groupby('symbol')
技術分析與回測是 Python + Pandas 的批次歷史資料運算,無法在瀏覽器裡直接執行。本章的挑戰任務(04 分頁)延續第四、六、八章的「程式碼診斷」形式,針對未來函數偏誤、績效指標陷阱與成本/分組計算錯誤設計選擇題;示範分頁則用 JavaScript 忠實重現一次完整的 SMA 交叉策略回測(已用真實 pandas 逐行核對過數字),讓你先看到「高勝率不代表賺錢」的真實案例。
本章結語——本章完成了股票技術分析與策略回測的完整工具。在技術指標方面,讀者已學會計算七大指標(SMA/EMA/RSI/MACD/KD/布林通道/ATR)並理解各自的用途與限制;在策略回測方面,建立了明確可重現的進出場規則、正確的訊號延遲處理、交易成本納入、逐筆交易紀錄,以及 18 項績效指標(包含勝率、盈虧比、期望值、Calmar 比率)。
本章最重要的概念不是任何一個技術指標,而是回測方法的正確性:訊號必須延後一期執行(避免未來函數)、高勝率不等於高報酬(需看期望值)、回測績效再漂亮也要進行樣本外驗證(防止過度配適)。一個真正可用的策略,應在不同股票、不同期間、不同成本假設與樣本外資料中,仍維持合理的績效表現。
加入本章後,全書擴充為十三章,股票分析從第十章的基本面與選擇權,進一步延伸至完整的技術分析與量化回測系統。
—SMA 交叉策略回測模擬
重要提醒:技術分析與回測是 Python + Pandas 的批次歷史資料運算,無法直接在瀏覽器裡執行。下面用 JavaScript 忠實重現了 SMA5/SMA20 均線交叉策略的完整回測邏輯(訊號延遲、交易成本、逐筆交易紀錄、績效指標),已經用真正的 pandas 逐行核對過所有數字。真正上課請在自己電腦安裝 Python + Pandas 後執行 python/technical_backtest_app.py。
這組模擬資料刻意設計成一個「看起來還可以,其實是虧損」的策略——按下按鈕看看結果。
終端機輸出
(按上方「執行回測」查看結果)
交易紀錄
| # | 進場 | 進場價 | 出場 | 出場價 | 淨報酬 |
|---|
(執行回測後會在這裡顯示解讀)
—觀念測驗
共 15 題選擇題,涵蓋本章所有核心觀念。每題作答後會立即顯示解析。
挑戰任務:程式碼診斷
Python 沒辦法直接在瀏覽器裡執行,所以這裡不是「執行測試」,而是「程式碼診斷」——看一段藏有問題的回測程式碼,判斷「這樣寫會發生什麼問題」,選完立即看到詳解與正確寫法。
任務一:未來函數偏誤
對應教材「十五、回測六大偏誤」— 最常見也最致命的錯誤
下面這段程式碼片段,藏著回測裡最常見也最嚴重的一個錯誤:
這樣寫最可能發生什麼問題?
任務二:績效指標陷阱
對應教材「十二、績效指標:勝率、盈虧比與期望值」
下面是一段績效數字,或是一段計算績效指標的程式碼:
這樣寫(或這樣判讀),最可能有什麼問題?
任務三:成本與分組計算錯誤
對應教材「十八、常見錯誤與除錯」— 重複扣成本/多股票混合計算
下面是回測程式中容易被忽略的一個細節:
這樣寫,會發生什麼問題?