CH.13 · 財金程式設計互動教材

股票技術分析、交易訊號與策略回測

七大技術指標、明確可重現的進出場規則,以及回測方法的正確性——訊號必須延後一期執行,高勝率不等於高報酬。

shift(1) → 避免未來函數

本章學習目標

  • 計算 SMA/EMA/RSI/MACD/KD/布林通道/ATR 七大技術指標
  • 建立明確的進出場規則,用 shift(1) 避免未來函數偏誤
  • 納入交易成本,建立逐筆交易紀錄
  • 計算勝率、盈虧比、期望值,辨識回測六大偏誤

01技術分析的基本觀念

第十章已完成股票報酬、基本面與移動平均的基礎。本章進一步建立完整的技術分析與量化回測系統:從 K 線、七大技術指標,到明確的買賣規則、交易成本處理、績效評估,以及最重要的回測偏誤防範。

技術分析常見誤解:「看到 RSI 低於 30 就買進」、「黃金交叉出現就一定漲」。實際上技術指標只是將歷史資料重新整理,不能保證預測未來。本章的目標是建立可以被測試、比較、否定的明確策略,而不是尋找「必勝法則」。

i

先看一個反直覺的例子:某策略勝率 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
i

長期報酬回測應使用 adjusted_close(調整後收盤價),已還原除權息影響。若使用一般收盤價,配股或配息日會出現價格跳空,造成報酬計算失真。

02讀取與清理 OHLCV 資料

程式區塊technical_analysis.py — 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七大技術指標速查表

指標常見設定公式核心常見判讀主要限制
SMA5/20/60 日N 日收盤平均短均 > 長均:多頭趨勢落後;盤整假訊號多
EMA12/26 日alpha=2/(N+1),近期權重高反應比 SMA 快對雜訊更敏感
RSI14 日平均漲幅/(漲+跌)×100>70 超買;<30 超賣強勢可長期鈍化
MACD12/26/9EMA₁₂−EMA₂₆;訊號線EMA₉上穿訊號線:動能轉強也是落後指標
KD9/3 日RSV=(C−Ln)/(Hn−Ln)×100K上穿D:短期動能轉強>80可能鈍化持續很久
布林通道20日,2σ中軌±2×20日標準差收盤>上軌:突破通道寬窄受波動度驅動
ATR14 日三者取最大後平均衡量波動幅度,非方向不是趨勢指標

04移動平均線(SMA 與 EMA)

項目SMAEMA
各期權重完全相同近期較高,遠期遞減
反應速度較慢(需等所有日期更新)較快(立即反映最新收盤)
假訊號相對較少可能較多(過於敏感)
常見用途中長期趨勢、支撐壓力MACD 基礎、短期趨勢
程式區塊SMA、EMA 與乖離率計算
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
程式區塊均線交叉訊號與部位(含 shift(1) 延遲)
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 超買就直接放空。

程式區塊RSI(Wilder 平滑法)
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 上穿訊號線:動能轉強(潛在買訊);下穿:動能轉弱。

程式區塊MACD(快/慢 EMA + 訊號線)
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 就直接賣出。

程式區塊KD 隨機指標
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)
程式區塊布林通道與 ATR
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)

量價組合判讀

量價組合常見解讀注意事項
上漲 + 成交量增加上漲獲得交易量支持,較有說服力仍需觀察後續走勢確認
上漲 + 成交量減少上漲動能可能不足,需警惕短期整理後也可能繼續上漲
下跌 + 成交量增加賣壓或恐慌性出脫增加也可能是大量買盤在低點接手
i

量價關係只是常見解讀,不是必然規則。同一組量價現象在不同市場環境下可能意義不同。

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%(負期望)
⚠️ 最不利組合,通常應放棄
程式區塊策略績效摘要(18 項指標,節錄)
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
i

樣本外測試是防範過度配適的核心方法:前 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關鍵字

技術分析K線OHLCV SMAEMA均線交叉 黃金交叉死亡交叉乖離率 RSI超買超賣 鈍化MACD訊號線 KD隨機指標RSV布林通道 ATR真實波幅量比 突破策略均值回歸策略趨勢追蹤策略 訊號延遲shift未來函數偏誤交易成本 滑價換手率停損停利 勝率盈虧比獲利因子 期望值最大回撤MDDCalmar比率 倖存者偏誤選樣偏誤過度配適 樣本內外Walk-forward

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。

這組模擬資料刻意設計成一個「看起來還可以,其實是虧損」的策略——按下按鈕看看結果。

終端機輸出

(按上方「執行回測」查看結果)

交易紀錄

#進場進場價出場出場價淨報酬
i

(執行回測後會在這裡顯示解讀)

觀念測驗

共 15 題選擇題,涵蓋本章所有核心觀念。每題作答後會立即顯示解析。

0 / 15 已作答

挑戰任務:程式碼診斷

Python 沒辦法直接在瀏覽器裡執行,所以這裡不是「執行測試」,而是「程式碼診斷」——看一段藏有問題的回測程式碼,判斷「這樣寫會發生什麼問題」,選完立即看到詳解與正確寫法。

任務一:未來函數偏誤

對應教材「十五、回測六大偏誤」— 最常見也最致命的錯誤

難度:理解

下面這段程式碼片段,藏著回測裡最常見也最嚴重的一個錯誤:


        

這樣寫最可能發生什麼問題?

任務二:績效指標陷阱

對應教材「十二、績效指標:勝率、盈虧比與期望值」

難度:診斷

下面是一段績效數字,或是一段計算績效指標的程式碼:


        

這樣寫(或這樣判讀),最可能有什麼問題?

任務三:成本與分組計算錯誤

對應教材「十八、常見錯誤與除錯」— 重複扣成本/多股票混合計算

難度:診斷

下面是回測程式中容易被忽略的一個細節:


        

這樣寫,會發生什麼問題?