精華筆記

· @aihub.tw

AI 量化交易

數據層:行情資料的取得與清洗

數據層:行情資料的取得與清洗

第 1 課你親眼看到了一個「普通」策略的回測結果:年報酬 4%、最大回撤 -12%、Sharpe 0.42,遠遠跑輸 Buy & Hold。但你有沒有問過自己一個問題——那些 K 線資料本身是乾淨的嗎?

現實是殘酷的:交易所 API 會有短暫故障,某段時間的 K 線可能缺失;時區沒設對,你以為是台灣早上九點的資料,其實是半夜的;yfinance 在某些日期會插入奇怪的填補值;資料重複了但 index 沒報錯,指標靜悄悄地算錯了。這些問題在回測時感覺不出來,但它們扭曲了你的指標計算,讓回測結果比真實更好看——或更糟的是,讓你帶著錯誤的前提上了實盤。

資料層是量化系統的地基。地基沒打好,上面建什麼都歪。這堂課就是要把地基打穩。

風險警語 本課程所有內容僅供學習與研究用途,不構成任何投資建議。金融交易涉及本金虧損風險;資料品質問題可能導致回測結果過度樂觀,讓你對策略產生錯誤信心。本課程全程使用模擬盤教學,如你在學習後決定投入實盤,風險與責任由你自行承擔。
這堂課適合誰 適合:已完成第 1 課、裝好 freqtrade 虛擬環境、對 Python pandas 有基本認識(會讀 DataFrame、知道什麼是 index)。本課程屬工程師應用專區,需要基礎:Python 初階(會用 pip 裝套件、能跑 script)。前置課:第 1 課《量化交易真相》。

這堂學什麼

  • K 線(OHLCV)和 Order Book 白話解釋:每天盯著的那些圖背後是什麼資料
  • 免費資料來源選擇:ccxt(加密貨幣)vs. yfinance(台股/美股),各自的限制要清楚
  • 用 pandas 做資料清洗:缺值/重複/時區/異常值,含具體偵測程式碼
  • 本機資料庫建立:Parquet vs SQLite,怎麼選、怎麼存、怎麼查
  • 前視偏差(Lookahead Bias)的根源與防禦,資料清洗就能埋陷阱的案例

觀念一:K 線(OHLCV)不神秘

你在交易所或 TradingView 看到的蠟燭圖,每一根都是一段時間的「摘要」。每根 K 線包含五個數字,英文縮寫 OHLCV:

K 線(OHLCV)結構白話圖

  • Open(開盤):這段時間的第一筆成交價
  • High(最高):這段時間內達到的最高成交價
  • Low(最低):這段時間內達到的最低成交價
  • Close(收盤):這段時間的最後一筆成交價
  • Volume(成交量):這段時間的總成交量(加密貨幣單位是基礎貨幣,台股單位是張)

一根「1 小時 K 線」就是把那一小時內所有逐筆成交壓縮成這五個數字。這代表資訊一定有損失:兩根 K 線的 OHLCV 完全相同,但其中一根可能是劇烈震盪、另一根是溫和盤整——從 K 線看不出來。這不代表 K 線沒用,只是要知道它的局限在哪。

Timeframe 選哪個? 1m(1 分鐘)資料量爆炸、運算慢,且 Binance 免費只提供有限歷史;1d(日 K)訊號太慢,一根 K 線才一個決策點。入門建議從 1h(小時 K) 開始:資料量適中(一年約 8,760 筆)、訊號反應夠快、不需要等待很久。

觀念二:Order Book 和逐筆交易

Order Book(委託簿)是交易所維護的「排隊系統」:買方掛單(Bids)按價格從高到低排列,賣方掛單(Asks)按價格從低到高排列。最優買價和最優賣價之間的空隙叫做買賣價差(Spread)

Order Book 結構對照圖

作為入門量化交易者,你的日常資料是 OHLCV K 線——Order Book 和逐筆成交(Tick Data)通常只有進階的市場微結構策略才需要深入使用。但你要知道它們的存在,因為:

滑點的根源就在 Order Book:你的掛單進去委託簿,實際成交價和你預期的差距就是滑點。Order Book 薄的時候,你的一筆單就能把市場推離很多,滑點暴增——這就是為什麼大部位在流動性差的時段下單很危險。

Maker/Taker 費率分開算:掛單等成交是 Maker(費率低),直接吃委託簿現有單是 Taker(費率高)。Binance 現貨掛單費 0.1%、吃單費 0.1%(2026 年現況,持有 BNB 可打折)。freqtrade 預設用 Taker 費率計算,比較保守。

這堂課的主角是 OHLCV;Order Book 的深度應用留到策略課。

觀念三:資料從哪裡來——免費來源比較

免費資料來源選擇決策樹

ccxt:加密貨幣首選

ccxt 是 Python 套件,統一封裝了超過 100 個交易所的 API,相同程式碼切換交易所只需改一個字串。Binance 的公開 OHLCV 歷史不需要 API key,直接免費抓,這是本課主力工具。

主要限制:Binance 有 IP 速率限制,快速抓太多會被擋 429 錯誤。ccxt 內建漏桶限速(Leaky Bucket Rate Limiter),只要啟用 enableRateLimit: True 就自動處理,不用自己算間隔時間。

yfinance:台股/美股快速方案

Yahoo Finance 的 Python 包裝,不需要 API key。台股格式是數字加 .TW(台積電是 2330.TW、鴻海是 2317.TW)。2026 年 7 月最新版本(0.2.x)仍可用,但有幾個限制要清楚:

  • 這是非官方 API:Yahoo 沒有正式開放這個 API,yfinance 本質上是逆向工程的包裝。Yahoo 隨時可能改規格讓 yfinance 壞掉,過去已發生過多次
  • 速率限制嚴格:同一 IP 短時間抓太多個 ticker 會被暫時封鎖
  • 成交量資料品質較差:台股 Volume 單位是張(1,000 股),和加密貨幣、美股不同,且偶爾有缺值或調整錯誤

台股進階方案

如果策略主攻台股,更完整的免費方案是台灣證交所 TWSE OpenAPI提供官方每日資料,加上 FinLab 平台提供近 20 年全市場歷史(免費方案有月用量限制,夠學習使用)。這部分等到策略課再深入。


觀念清楚了。現在動手把資料抓下來。

手把手實戰

目標:抓 BTC/USDT 一年 1 小時 K 線 + 台積電(2330.TW)一年日 K 線,清洗乾淨,存進本機資料庫。

安裝必要套件

確認你的 freqtrade 虛擬環境已啟動(第 1 課建立的),或另建一個:

# 啟動虛擬環境(macOS / Linux)
source freqtrade-env/bin/activate
# Windows 用: freqtrade-env\Scripts\activate

# 安裝本課需要的套件
pip install ccxt yfinance pandas pyarrow

# 驗證安裝
python -c "import ccxt, yfinance, pandas, pyarrow; print('全部安裝 OK')"

pyarrow 是 pandas 讀寫 Parquet 格式的後端,必須裝。裝完建工作目錄:

mkdir -p ~/quant-data/raw ~/quant-data/clean

用 ccxt 抓 BTC/USDT 一年 OHLCV

新建 fetch_btc.py,完整可跑:

# fetch_btc.py
import ccxt
import pandas as pd
from datetime import datetime, timedelta, timezone
from pathlib import Path

DATA_DIR = Path.home() / 'quant-data'

exchange = ccxt.binance({
    'enableRateLimit': True,   # 自動限速,絕對要開
})

def fetch_ohlcv_full(symbol: str, timeframe: str = '1h', days: int = 365) -> pd.DataFrame:
    """
    從 Binance 抓指定天數的 OHLCV 資料。
    Binance 每次最多回傳 1000 根 K 線,自動分批抓完。
    不需要 API key。
    """
    since_dt = datetime.now(timezone.utc) - timedelta(days=days)
    since_ms = int(since_dt.timestamp() * 1000)

    all_candles = []
    print(f"開始抓 {symbol} {timeframe},從 {since_dt.strftime('%Y-%m-%d')} 起...")

    while True:
        candles = exchange.fetch_ohlcv(symbol, timeframe, since=since_ms, limit=1000)
        if not candles:
            break
        all_candles.extend(candles)
        last_ts = candles[-1][0]
        since_ms = last_ts + 1  # 下一批從最後一根的下一毫秒開始

        print(
            f"  已抓 {len(all_candles)} 筆,"
            f"最新:{pd.to_datetime(last_ts, unit='ms', utc=True)}"
        )

        if len(candles) < 1000:
            # 回傳不足 1000 筆代表已到最新資料
            break

    df = pd.DataFrame(
        all_candles,
        columns=['timestamp', 'open', 'high', 'low', 'close', 'volume']
    )
    df['datetime'] = pd.to_datetime(df['timestamp'], unit='ms', utc=True)
    df = df.set_index('datetime').drop(columns=['timestamp'])
    df = df.sort_index()
    return df


if __name__ == '__main__':
    df_btc = fetch_ohlcv_full('BTC/USDT', '1h', days=365)
    print(f"\n下載完成:{len(df_btc)} 筆")
    print(f"時間範圍:{df_btc.index[0]} ~ {df_btc.index[-1]}")
    print(df_btc.tail(3))

    # 先存原始資料(不做任何修改)
    out_path = DATA_DIR / 'raw' / 'btc_usdt_1h_raw.parquet'
    df_btc.to_parquet(out_path)
    print(f"原始資料已存至 {out_path}")

執行:

python fetch_btc.py

正常輸出看起來像這樣:

開始抓 BTC/USDT 1h,從 2025-07-04 起...
  已抓 1000 筆,最新:2025-07-12 07:00:00+00:00
  已抓 2000 筆,最新:2025-07-23 15:00:00+00:00
  ...
  已抓 8760 筆,最新:2026-07-04 23:00:00+00:00

下載完成:8760 筆
時間範圍:2025-07-05 00:00:00+00:00 ~ 2026-07-04 23:00:00+00:00

一年 365 天 × 24 小時 = 8,760 根 K 線。實際筆數通常少一些,因為交易所會有短暫維護。如果低於 8,500,代表有明顯缺口——記錄一下,清洗步驟會偵測出來在哪段時間。

用 yfinance 抓台積電日 K 線

新建 fetch_twstock.py:

# fetch_twstock.py
import yfinance as yf
import pandas as pd
from pathlib import Path

DATA_DIR = Path.home() / 'quant-data'

def fetch_tw_stock(symbol: str, period: str = '1y') -> pd.DataFrame:
    """
    用 yfinance 抓台股歷史日 K 線。
    symbol 格式:股票代號 + .TW,例如台積電 = '2330.TW'
    period: '1y'=一年, '2y'=兩年, '5y'=五年
    """
    ticker = yf.Ticker(symbol)
    # auto_adjust=True 會做除權除息還原,但可能讓 OHLCV 邏輯矛盾(見常見坑)
    # 入門階段先用 True,注意後續驗證
    df = ticker.history(period=period, interval='1d', auto_adjust=True)

    if df.empty:
        raise ValueError(
            f"抓不到 {symbol} 的資料。確認:\n"
            f"  1. Symbol 格式是否正確(台股加 .TW)\n"
            f"  2. Yahoo Finance 是否暫時異常"
        )

    # yfinance 回傳的 index 帶有時區,但各版本行為不一致
    # 統一做法:先確認有時區,再轉成 UTC 存儲
    if df.index.tz is None:
        df.index = df.index.tz_localize('Asia/Taipei')
    df.index = df.index.tz_convert('UTC')
    df.index.name = 'datetime'

    # 只保留 OHLCV 欄位,欄位名稱統一小寫
    df = df[['Open', 'High', 'Low', 'Close', 'Volume']].rename(columns=str.lower)
    return df


if __name__ == '__main__':
    # 台積電
    df_tsmc = fetch_tw_stock('2330.TW', period='1y')
    print(f"台積電 (2330.TW):{len(df_tsmc)} 筆")
    print(f"時間範圍:{df_tsmc.index[0]} ~ {df_tsmc.index[-1]}")
    print(df_tsmc.tail(5))

    out_path = DATA_DIR / 'raw' / 'tsmc_1d_raw.parquet'
    df_tsmc.to_parquet(out_path)
    print(f"台積電原始資料已存至 {out_path}")

執行:

python fetch_twstock.py

注意:台股日 K 線一年約 248 個交易日(扣掉週末、國定假日)。如果你看到 Volume 欄位的數字很小(幾千到幾萬),這是「張」(1 張 = 1,000 股),不是「股數」。和加密貨幣的 Volume 單位完全不同,策略中使用時要特別標注單位,避免和其他資料混用。

pandas 清洗:缺值、重複、時區、異常值

新建 clean_data.py。這是本課最核心的程式碼,封裝成可重用的函式:

# clean_data.py
import pandas as pd
import numpy as np
from pathlib import Path

DATA_DIR = Path.home() / 'quant-data'

def clean_ohlcv(df: pd.DataFrame, timeframe_minutes: int, symbol: str = '') -> pd.DataFrame:
    """
    標準 OHLCV 清洗流程,適用加密貨幣和股票資料。
    
    參數:
      df: 輸入 DataFrame,index 為 DatetimeIndex(含時區)
      timeframe_minutes: K 線間隔分鐘數 (1h=60, 1d=1440)
      symbol: 用於 log 顯示
    
    回傳:清洗後的 DataFrame
    """
    print(f"\n{'='*55}")
    print(f"清洗 {symbol or 'DataFrame'},原始筆數:{len(df)}")

    # --- 1. 時間戳記截斷到 timeframe 粒度,再去重 ---
    # 有時資料回傳的時間戳記會差幾毫秒到幾秒
    freq = f'{timeframe_minutes}min'
    df.index = df.index.floor(freq)
    dup_count = df.index.duplicated().sum()
    if dup_count > 0:
        print(f"[!] 重複時間戳記 {dup_count} 筆(截斷後),保留第一筆")
        df = df[~df.index.duplicated(keep='first')]

    # --- 2. 確保按時間遞增排列 ---
    df = df.sort_index()

    # --- 3. 偵測並處理缺值 ---
    null_counts = df.isnull().sum()
    if null_counts.any():
        print(f"[!] 缺值統計:\n{null_counts[null_counts > 0]}")
        # 前向填充(ffill):用前一根已知資料填補,最多連填 3 根
        # 注意:不用 bfill(後向填充),bfill 會用未來資料 → 前視偏差
        df = df.ffill(limit=3)
        remaining = df.isnull().sum().sum()
        if remaining > 0:
            print(f"[!] ffill 後仍有 {remaining} 個缺值,丟棄對應列")
            df = df.dropna()

    # --- 4. 偵測異常 K 線(OHLCV 的基本邏輯約束) ---
    anomaly = (
        (df['high'] < df['low'])    |  # 最高 < 最低,不可能
        (df['high'] < df['open'])   |  # 最高 < 開盤,不可能
        (df['high'] < df['close'])  |  # 最高 < 收盤,不可能
        (df['open'] <= 0)           |  # 價格不可為零或負
        (df['close'] <= 0)          |
        (df['volume'] < 0)             # 成交量不可為負
    )
    if anomaly.sum() > 0:
        print(f"[!] 發現 {anomaly.sum()} 根異常 K 線,已移除")
        print(df[anomaly])
        df = df[~anomaly]

    # --- 5. 偵測時間缺口 ---
    expected_gap = pd.Timedelta(minutes=timeframe_minutes)
    time_diffs = df.index.to_series().diff().dropna()
    large_gaps = time_diffs[time_diffs > expected_gap * 2]
    if len(large_gaps) > 0:
        print(f"[!] 發現 {len(large_gaps)} 個時間缺口(超過 2 倍 timeframe):")
        for ts, gap in large_gaps.items():
            print(f"    {ts}  前有 {gap} 的空白")
    else:
        print("[OK] 無明顯時間缺口")

    print(f"清洗完成,剩餘筆數:{len(df)}")
    return df


if __name__ == '__main__':
    # 清洗 BTC/USDT 1h
    df_btc_raw = pd.read_parquet(DATA_DIR / 'raw' / 'btc_usdt_1h_raw.parquet')
    df_btc_clean = clean_ohlcv(df_btc_raw, timeframe_minutes=60, symbol='BTC/USDT 1h')
    df_btc_clean.to_parquet(DATA_DIR / 'clean' / 'btc_usdt_1h_clean.parquet')

    # 清洗台積電 1d
    df_tsmc_raw = pd.read_parquet(DATA_DIR / 'raw' / 'tsmc_1d_raw.parquet')
    df_tsmc_clean = clean_ohlcv(df_tsmc_raw, timeframe_minutes=1440, symbol='2330.TW 1d')
    df_tsmc_clean.to_parquet(DATA_DIR / 'clean' / 'tsmc_1d_clean.parquet')

    print("\n全部清洗完成。")

執行:

python clean_data.py

這個 clean_ohlcv 函式其實是一條五道關卡的流水線,每根 K 線都要依序通過:

OHLCV 清洗流水線五道關卡

為什麼用 ffill 不用 fillna(method='bfill')?

前向填充(ffill)是「用你當下能知道的最後一個值填補」,符合真實交易情境——你不知道下一根 K 線是多少。後向填充(bfill)是「用未來的值填補現在」,這正是前視偏差的典型來源,後面的章節會深度拆解。

存成 Parquet 和 SQLite,建立可查詢的資料庫

清洗完的資料已經是 Parquet 格式了。這一步補充 SQLite 的用法,以及兩種格式的選擇原則:

# db_utils.py
import pandas as pd
import sqlite3
from pathlib import Path

DATA_DIR = Path.home() / 'quant-data'
DB_PATH = DATA_DIR / 'market.db'

def save_to_sqlite(df: pd.DataFrame, table: str) -> None:
    """存入 SQLite,資料表已存在就新增(不覆蓋)"""
    conn = sqlite3.connect(DB_PATH)
    # if_exists='append':新增資料到現有資料表
    # if_exists='replace':清空後重新存(小心用)
    df.to_sql(table, conn, if_exists='append', index=True)
    conn.close()
    print(f"已存入 {DB_PATH}  資料表:{table}")


def query_db(sql: str) -> pd.DataFrame:
    """執行 SQL 查詢並回傳 DataFrame"""
    conn = sqlite3.connect(DB_PATH)
    df = pd.read_sql(
        sql, conn,
        parse_dates=['datetime'],
        index_col='datetime'
    )
    conn.close()
    return df


if __name__ == '__main__':
    # 把 Parquet 的清洗資料一併存進 SQLite
    df_btc = pd.read_parquet(DATA_DIR / 'clean' / 'btc_usdt_1h_clean.parquet')
    save_to_sqlite(df_btc, 'btc_usdt_1h')

    df_tsmc = pd.read_parquet(DATA_DIR / 'clean' / 'tsmc_1d_clean.parquet')
    save_to_sqlite(df_tsmc, 'tsmc_1d')

    # 用 SQL 查某段時間
    df_q1 = query_db(
        "SELECT * FROM btc_usdt_1h "
        "WHERE datetime >= '2026-01-01' AND datetime < '2026-04-01'"
    )
    print(f"2026 Q1 BTC 1h 資料:{len(df_q1)} 筆")

    # Parquet 也可以直接用 loc 篩選,更快
    df_btc_all = pd.read_parquet(DATA_DIR / 'clean' / 'btc_usdt_1h_clean.parquet')
    df_q1_parquet = df_btc_all.loc['2026-01-01':'2026-03-31']
    print(f"Parquet loc 篩選結果:{len(df_q1_parquet)} 筆")

Parquet vs SQLite:怎麼選

場景 推薦格式
批次讀入、計算指標或回測 Parquet
需要用 SQL 條件快速篩選 SQLite
多個 symbol 在同一地方管理 SQLite
資料量超大(幾 GB 以上) Parquet(壓縮率遠勝 SQLite)
與 freqtrade 整合 用 freqtrade 自己的 feather 格式

入門階段兩個都建:Parquet 當主要存儲、SQLite 當可以 SQL 查詢的二次索引。

前視偏差(Lookahead Bias):資料清洗的隱藏陷阱

前視偏差在第 1 課提過一次,這裡要深入到資料層。很多前視偏差不是在策略邏輯裡,而是在資料準備和特徵工程階段就悄悄埋進去的——freqtrade 的回測引擎能偵測策略程式碼裡的問題,但它偵測不到你在資料清洗步驟幹的事。

前視偏差發生時間點示意

三種最常見的資料層前視偏差

類型 1:全資料 normalize(最普遍的機器學習入門錯誤)

# 錯誤!用整段資料的均值和標準差做標準化
# 計算這個 mean() 時,它看到了「明年的資料」
df['close_z_WRONG'] = (df['close'] - df['close'].mean()) / df['close'].std()

# 正確!用滾動視窗,每個時間點只用它之前 20 根的資料
WINDOW = 20
rolling_mean = df['close'].rolling(WINDOW).mean()
rolling_std  = df['close'].rolling(WINDOW).std()
df['close_z_OK'] = (df['close'] - rolling_mean) / rolling_std

在回測時,策略計算「今天的 z-score」時,用了「明年才有的」均值。結果是策略好像知道未來,回測曲線美得不像話。

類型 2:fillna 方向搞反

# 錯誤!bfill(後向填充)= 用「下一個有效值」填補當前缺口
# 語意是:「我現在不知道,所以我去看一下未來再填回來」
df_bad = df.fillna(method='bfill')

# 正確!ffill(前向填充)= 用「最後已知值」填補
# 語意是:「我現在不知道,就維持上一個已知狀態」
df_good = df.fillna(method='ffill')

bfill 的問題在於它改寫了過去:你在時間 T 的缺口,被填入了時間 T+1 的值。策略在「過去」就知道了「未來」,這在回測中完全合法但在實盤中根本不可能發生。

類型 3:在整個資料集上 fit 轉換器

from sklearn.preprocessing import MinMaxScaler

# 錯誤!在整個資料集上 fit,然後才切訓練/測試集
# fit 的過程看到了測試集(未來)的最大最小值
scaler_wrong = MinMaxScaler()
df['scaled_WRONG'] = scaler_wrong.fit_transform(df[['close']])

# 正確!先切訓練/測試集,只在訓練集上 fit
train_end = int(len(df) * 0.7)
df_train = df.iloc[:train_end].copy()
df_test  = df.iloc[train_end:].copy()

scaler = MinMaxScaler()
scaler.fit(df_train[['close']])          # 只看訓練集

df_train['scaled'] = scaler.transform(df_train[['close']])
df_test['scaled']  = scaler.transform(df_test[['close']])  # 同一個 scaler,不重新 fit

前視偏差的症狀:回測 Sharpe Ratio 高得離譜(>3)、切到 dry run 第一週績效就崩潰、換一段時間區間回測結果差距極大。看到這些症狀,第一個懷疑就是前視偏差。

常見坑

坑 1:ValueError: Cannot convert timezone-naive DatetimeIndex 或 merge 後全是 NaN

症狀:把 Binance BTC 和 yfinance 台積電的 DataFrame 試圖用 pd.mergepd.concat 合併,結果要嘛直接報錯,要嘛 join 後兩邊都是 NaN。

TypeError: Cannot join tz-naive with tz-aware DatetimeIndex

原因:兩個 DataFrame 的時區不同,或一個有時區、一個沒有。解法是在清洗步驟就統一轉成 UTC:

def ensure_utc(df: pd.DataFrame) -> pd.DataFrame:
    """確保 index 的時區是 UTC"""
    if df.index.tz is None:
        df.index = df.index.tz_localize('UTC')
    else:
        df.index = df.index.tz_convert('UTC')
    return df

df_btc  = ensure_utc(df_btc)
df_tsmc = ensure_utc(df_tsmc)

另外注意:台股日 K 的 timestamp 轉成 UTC 後通常是「前一天 16:00:00 UTC」(Asia/Taipei UTC+8,所以 00:00 台北時間 = 前一天 16:00 UTC)。日期字串篩選時要用正確的 UTC 時間,或 df.index.tz_convert('Asia/Taipei') 先轉回台北時間再篩。


坑 2:ccxt 下載到一半 NetworkError,什麼都沒存到

ccxt.base.errors.NetworkError: binance GET /api/v3/klines
  timed out after 30000 milliseconds

症狀:下載跑了幾分鐘,快到終點時網路斷線或交易所 503,整個 script 拋出例外,沒存任何東西。

解法:每批下載完立刻存 checkpoint,重啟時從最後一個 checkpoint 繼續:

from pathlib import Path
import pandas as pd

CKPT = Path.home() / 'quant-data' / 'raw' / 'btc_checkpoint.parquet'

def load_checkpoint():
    if CKPT.exists():
        df = pd.read_parquet(CKPT)
        since_ms = int(df.index[-1].timestamp() * 1000) + 1
        print(f"從 checkpoint 繼續,已有 {len(df)} 筆")
        return df.reset_index().values.tolist(), since_ms
    return [], None

def save_checkpoint(all_candles):
    df_tmp = pd.DataFrame(all_candles, columns=['timestamp','open','high','low','close','volume'])
    df_tmp['datetime'] = pd.to_datetime(df_tmp['timestamp'], unit='ms', utc=True)
    df_tmp = df_tmp.set_index('datetime').drop(columns=['timestamp'])
    df_tmp.to_parquet(CKPT)

每抓完一批(約 1000 筆)就呼叫 save_checkpoint(all_candles),即使中途失敗也只會重抓最後一批。


坑 3:yfinance auto_adjust 讓 OHLCV 邏輯矛盾

症狀:台積電歷史資料裡某天的 high < low,或 open > high——明顯不可能的數字。清洗腳本的異常偵測會把這些列報出來。

# 先確認有多少筆異常
import yfinance as yf
df = yf.Ticker('2330.TW').history(period='5y', auto_adjust=True)
anomaly = (df['High'] < df['Low']) | (df['Open'] > df['High']) | (df['Close'] < df['Low'])
print(f"auto_adjust=True 異常筆數:{anomaly.sum()}")

原因:auto_adjust=True 做除權除息調整時,演算法對 OHLCV 各欄位的調整係數略有不同,可能破壞大小關係。

解法選項:

  1. 改用 auto_adjust=False 抓原始資料,在需要時自行按除息公告調整
  2. 接受這個局限,策略只用 close 欄位算訊號(避開依賴 OHLCV 大小關係的指標)
  3. 發現異常直接丟棄那幾筆(clean_ohlcv 函式已經幫你做了)

坑 4:SQLite 同時寫入報 database is locked

症狀:你同時跑了兩個 Python 腳本,都試圖寫入同一個 market.db,其中一個報錯:

sqlite3.OperationalError: database is locked

SQLite 不支援真正的並行寫入,同時只能有一個 writer。解法:不要同時跑多個寫入腳本;或改用 timeout 參數讓後者等待:

conn = sqlite3.connect(str(DB_PATH), timeout=30)  # 等最多 30 秒

如果你的資料管線需要真正的並行寫入,升級到 DuckDB 或 PostgreSQL。但入門階段 SQLite 完全夠用,只要避免並行寫入就好。

作業

  1. 執行 fetch_btc.py:下載 BTC/USDT 一年 1h 資料,確認總筆數在 8,500 以上;若低於這個數字,記錄缺口在哪段時間(可能是交易所維護期)
  2. 執行 fetch_twstock.py:抓台積電一年日 K,印出最後 5 筆,確認 Volume 欄位的數字量級(台股是張,幾千到幾萬是正常的)
  3. 執行 clean_data.py,閱讀清洗報告:有幾個時間缺口?集中在什麼時段?
  4. 加碼驗證前視偏差:在你的 BTC DataFrame 上分別跑 df['close'].mean()(全資料均值)和 df['close'].rolling(20).mean().iloc[-1](最後一點的滾動均值),比較兩個數字的差距——差距越大代表全資料和當下的資訊落差越嚴重

下一課預告

你現在有了一個乾淨的本機資料庫:BTC/USDT 一年 1h 的 Parquet 檔、台積電日 K 的 Parquet 檔,以及可以 SQL 查詢的 SQLite——地基打好了。

第 3 課《策略基礎:均線、動量與過擬合陷阱》要在這份乾淨資料上建策略:從最陽春的雙均線交叉開始,解釋動量因子的統計邏輯,然後親手示範「把回測曲線調到好看」是什麼樣的過擬合過程。知道了這個陷阱的長相,你才知道怎麼抵抗誘惑,選出真正可能在實盤表現的策略。

再次確認:風險 資料乾淨不代表策略能賺錢。即使 OHLCV 完全正確、前視偏差歸零,市場的隨機性仍然讓大多數策略在實盤中表現不如回測。本課程全程使用模擬盤;資料層做好只是「讓回測結果可信」,不是「讓策略保證獲利」。本課程不構成任何投資建議。

#AI 量化交易#量化交易#Python#pandas#ccxt#yfinance#資料清洗

← 回所有文章