數據層:行情資料的取得與清洗
第 1 課你親眼看到了一個「普通」策略的回測結果:年報酬 4%、最大回撤 -12%、Sharpe 0.42,遠遠跑輸 Buy & Hold。但你有沒有問過自己一個問題——那些 K 線資料本身是乾淨的嗎?
現實是殘酷的:交易所 API 會有短暫故障,某段時間的 K 線可能缺失;時區沒設對,你以為是台灣早上九點的資料,其實是半夜的;yfinance 在某些日期會插入奇怪的填補值;資料重複了但 index 沒報錯,指標靜悄悄地算錯了。這些問題在回測時感覺不出來,但它們扭曲了你的指標計算,讓回測結果比真實更好看——或更糟的是,讓你帶著錯誤的前提上了實盤。
資料層是量化系統的地基。地基沒打好,上面建什麼都歪。這堂課就是要把地基打穩。
這堂學什麼
- K 線(OHLCV)和 Order Book 白話解釋:每天盯著的那些圖背後是什麼資料
- 免費資料來源選擇:ccxt(加密貨幣)vs. yfinance(台股/美股),各自的限制要清楚
- 用 pandas 做資料清洗:缺值/重複/時區/異常值,含具體偵測程式碼
- 本機資料庫建立:Parquet vs SQLite,怎麼選、怎麼存、怎麼查
- 前視偏差(Lookahead Bias)的根源與防禦,資料清洗就能埋陷阱的案例
觀念一:K 線(OHLCV)不神秘
你在交易所或 TradingView 看到的蠟燭圖,每一根都是一段時間的「摘要」。每根 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)。

作為入門量化交易者,你的日常資料是 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 線都要依序通過:

為什麼用 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.merge 或 pd.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 各欄位的調整係數略有不同,可能破壞大小關係。
解法選項:
- 改用
auto_adjust=False抓原始資料,在需要時自行按除息公告調整 - 接受這個局限,策略只用
close欄位算訊號(避開依賴 OHLCV 大小關係的指標) - 發現異常直接丟棄那幾筆(
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 完全夠用,只要避免並行寫入就好。
作業
- 執行
fetch_btc.py:下載 BTC/USDT 一年 1h 資料,確認總筆數在 8,500 以上;若低於這個數字,記錄缺口在哪段時間(可能是交易所維護期) - 執行
fetch_twstock.py:抓台積電一年日 K,印出最後 5 筆,確認 Volume 欄位的數字量級(台股是張,幾千到幾萬是正常的) - 執行
clean_data.py,閱讀清洗報告:有幾個時間缺口?集中在什麼時段? - 加碼驗證前視偏差:在你的 BTC DataFrame 上分別跑
df['close'].mean()(全資料均值)和df['close'].rolling(20).mean().iloc[-1](最後一點的滾動均值),比較兩個數字的差距——差距越大代表全資料和當下的資訊落差越嚴重
下一課預告
你現在有了一個乾淨的本機資料庫:BTC/USDT 一年 1h 的 Parquet 檔、台積電日 K 的 Parquet 檔,以及可以 SQL 查詢的 SQLite——地基打好了。
第 3 課《策略基礎:均線、動量與過擬合陷阱》要在這份乾淨資料上建策略:從最陽春的雙均線交叉開始,解釋動量因子的統計邏輯,然後親手示範「把回測曲線調到好看」是什麼樣的過擬合過程。知道了這個陷阱的長相,你才知道怎麼抵抗誘惑,選出真正可能在實盤表現的策略。