AI 的正確位置:訊號層,不是執行層
你在 YouTube 看到「AI 交易 Agent 全自動幫你下單」的影片,或者在 Discord 看到有人分享「接了 GPT-4 直接控制幣安帳戶,月賺 XX%」——這種說法到處流傳,仍然在誘使新手走錯路。
問題不是「AI 很弱」,而是把 AI 放在系統的錯誤位置。業界用幾年時間搞清楚一件事:LLM 在交易系統裡,有非常明確的有用之處,也有非常明確的禁忌邊界。這堂課把這條線畫清楚,然後帶你做出一個真正有意義的 AI 應用。
這堂學什麼
- LLM 不能直接下單的原因——不是技術問題,是架構問題
- LLM 的三個正確位置:新聞情緒評分、市場 Regime 標注、策略程式碼開發輔助
- LLM 訊號接進 freqtrade 的具體架構與程式碼
- 呼叫 LLM API 的成本與延遲——決定你能做哪種頻率的策略
- AI 訊號的回測驗證:別因為「感覺很強」就相信
- 實戰:寫一個情緒過濾器,合併進策略,跑有/無 AI 訊號的對比回測
鐵律:LLM 絕對不碰執行層
這不是主觀偏好,而是業界的明確共識,背後有四個具體原因:

幻覺(Hallucination):LLM 有時候會產生格式錯誤的輸出或矛盾指令。在內容創作頂多讓你尷尬;在執行層,一個解析錯誤的買入數量可能在幾秒內造成不可逆損失。
延遲(Latency):呼叫 Claude Haiku 4.5 一次約 0.5-2 秒。1h/4h K 線沒問題;5 分鐘以下頻率每根 K 線等 2 秒是致命弱點。
可靠性:「LLM 直接下單」沒有 fallback,系統命運完全綁定在第三方 API 的 SLA 上。
可審計性:「RSI < 30 且穿越 MA20」是可以審計追溯的決策;「LLM 說買」不是。
結論:LLM 輸出的是「參考意見」,最終決策必須走過你可以精確定義和測試的規則邏輯。
LLM 的三個正確位置
弄清楚禁忌之後,來看「真正能做什麼」:

1. 新聞情緒評分
給 LLM 一篇加密貨幣新聞,讓它回傳 -1(極度看跌)到 +1(極度看漲)的數值。這個數值作為策略的一個輸入特徵,和 RSI、MA 等技術指標並列——不是唯一決策依據,只是多一個維度的資訊。
2. 市場 Regime 標注
給 LLM 最近 N 天的市場摘要(波動率、趨勢方向、重大事件),讓它輸出「趨勢/震盪/高波動」的分類標籤。不同 Regime 下,同一組技術指標的有效性天差地遠——動量策略在趨勢市贏錢,在震盪市燒錢。有了 Regime 標注,你可以讓策略根據當前狀態切換參數或暫停交易。
3. 策略程式碼開發輔助
讓 Claude 幫你寫策略程式碼、解釋 freqtrade API、調試錯誤訊息。這個用法風險最低、效益最高,這門課從第 1 課就在這樣做了。
成本與延遲的現實
在決定用 LLM 前,先把帳算清楚。

以 Claude Haiku 4.5(目前最便宜的 Anthropic 模型,2026 年 7 月定價:輸入 $1/百萬 token,輸出 $5/百萬 token)為例:
每篇新聞標題 + 摘要:約 300 token 輸入
每次 LLM 情緒回覆:約 80 token 輸出
每天分析 50 篇新聞:
輸入成本:50 × 300 / 1,000,000 × $1 = $0.015
輸出成本:50 × 80 / 1,000,000 × $5 = $0.020
每天合計:$0.035(約 1 元台幣)
每月合計:約 $1.05 美元
每個月台幣 30 元,這個成本對個人量化開發者是可接受的。
延遲要求是另一個維度。LLM API 呼叫單次回應通常在 0.5-3 秒。對應策略的建議:
| K 線週期 | LLM 情緒適合度 | 原因 |
|---|---|---|
| 1 日線 | 最適合 | 每天呼叫一次即可,延遲完全不影響 |
| 4 小時 | 適合 | 每 4 小時更新一次情緒分數 |
| 1 小時 | 勉強可用 | 需要預先跑評分、快取結果 |
| 15 分鐘 | 不建議 | 頻率太高,成本與穩定性風險上升 |
| 5 分鐘以下 | 絕對不行 | 延遲遠超過 K 線週期,實用上無法操作 |
核心原則:LLM 情緒評分要預先計算、快取結果,不要在每根 K 線形成時即時呼叫 API。
架構:LLM 訊號接進 freqtrade
實際的系統分成兩個獨立的部分:

兩個部分分別跑,彼此只透過 CSV 溝通。好處是:API 掛了,freqtrade 仍然可以用上次的快取分數繼續跑——最壞情況是情緒分數舊了幾個小時,而不是整個系統癱瘓。
手把手實戰
我們做一個完整的示範:寫情緒評分 script → 接進 freqtrade → 跑對比回測。
建立情緒評分 Script
先安裝需要的套件:
pip install anthropic requests pandas
建立 sentiment_scorer.py,這個 script 的工作是抓新聞標題、呼叫 Claude API 評分、把結果附加到 CSV:
# sentiment_scorer.py
import anthropic
import json
import pandas as pd
import requests
from datetime import datetime, timezone, timedelta
from pathlib import Path
# 使用 Claude Haiku 4.5 — 情緒分類任務用不到頂尖模型,省成本
ANTHROPIC_API_KEY = "your_api_key_here" # 實際使用時從環境變數讀取
client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
SENTIMENT_CSV = Path("user_data/sentiment_btc.csv")
def score_news(headline: str) -> dict:
"""
用 Claude Haiku 評估單篇新聞對 BTC 的情緒分數。
回傳 dict:{"score": float, "reason": str}
"""
prompt = f"""你是加密貨幣市場情緒分析師,只需要評估下面這則新聞對 BTC 市場的短期影響。
新聞標題:{headline}
請以 JSON 格式回覆,只輸出 JSON,不加其他文字:
{{"score": 0.0, "reason": "一句話說明"}}
score 評分規則:
1.0 = 極度看漲(ETF 核准、主要機構大量採購等)
0.5 = 溫和看漲
0.0 = 中性或無關
-0.5 = 溫和看跌
-1.0 = 極度看跌(重大交易所爆雷、嚴厲監管打壓等)"""
try:
msg = client.messages.create(
model="claude-haiku-4-5",
max_tokens=120,
messages=[{"role": "user", "content": prompt}]
)
return json.loads(msg.content[0].text)
except (json.JSONDecodeError, Exception):
# API 呼叫失敗或解析失敗 → 回傳中性分數,不讓策略因此停擺
return {"score": 0.0, "reason": "parse_error"}
def fetch_crypto_headlines() -> list[str]:
"""
從 CryptoCompare 免費 API 抓最新 BTC 相關新聞標題。
免費方案每月 10 萬次呼叫,每小時跑一次完全足夠。
"""
url = "https://min-api.cryptocompare.com/data/v2/news/?categories=BTC&excludeCategories=Sponsored&lang=EN"
try:
resp = requests.get(url, timeout=10)
data = resp.json()
# 只取最近 6 小時的新聞
cutoff = datetime.now(timezone.utc) - timedelta(hours=6)
return [
item["title"]
for item in data.get("Data", [])
if datetime.fromtimestamp(item["published_on"], tz=timezone.utc) > cutoff
][:20] # 最多 20 篇,控制成本
except Exception:
return []
def run_scoring():
"""主流程:抓新聞 → 評分 → 算平均 → 寫入 CSV"""
headlines = fetch_crypto_headlines()
if not headlines:
print("沒抓到新聞,跳過這次評分")
return
scores = []
for headline in headlines:
result = score_news(headline)
scores.append(result["score"])
print(f"[{result['score']:+.1f}] {headline[:60]}")
avg_score = sum(scores) / len(scores)
now_hour = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
row = pd.DataFrame([{
"date": now_hour.strftime("%Y-%m-%d %H:00:00"),
"sentiment_score": round(avg_score, 4),
"news_count": len(scores)
}])
if SENTIMENT_CSV.exists():
existing = pd.read_csv(SENTIMENT_CSV)
# 去除同一小時的重複紀錄
existing = existing[existing["date"] != row["date"].iloc[0]]
df = pd.concat([existing, row], ignore_index=True)
else:
SENTIMENT_CSV.parent.mkdir(parents=True, exist_ok=True)
df = row
df.to_csv(SENTIMENT_CSV, index=False)
print(f"\n平均情緒分數:{avg_score:+.4f} (共 {len(scores)} 篇新聞)")
if __name__ == "__main__":
run_scoring()
跑一次測試:
python sentiment_scorer.py
正常應該會看到每篇新聞的分數列出,以及最後的平均值和 CSV 更新訊息。把這個 script 設定成每小時自動執行(macOS 用 crontab,Linux 用 systemd timer 或 cron):
# crontab -e 加入這一行:每小時整點執行
0 * * * * /path/to/freqtrade-env/bin/python /path/to/my-quant/sentiment_scorer.py
在 freqtrade 策略中讀取情緒分數
開啟第 4 課建立的策略檔案(或新建一個繼承 IStrategy 的策略)。我們在 __init__ 預先載入情緒 CSV,在 populate_indicators 合併進 DataFrame:
# my_quant/user_data/strategies/SentimentStrategy.py
import pandas as pd
from pathlib import Path
from freqtrade.strategy import IStrategy, IntParameter
import talib.abstract as ta
class SentimentStrategy(IStrategy):
"""
基礎均線 + 動量策略,加上 LLM 新聞情緒過濾器。
情緒分數低於閾值時不進場,避免在負面情緒環境做多。
"""
# 策略基本設定
timeframe = "1h"
stoploss = -0.05
trailing_stop = False
can_short = False
# 情緒過濾閾值:可用 hyperopt 優化,先手動設定
sentiment_threshold = -0.2 # 情緒分數低於 -0.2 時禁止做多
def __init__(self, config: dict):
super().__init__(config)
# 預先載入情緒 CSV,讓每次 populate_indicators 不用重新讀檔
sentiment_path = Path(config["user_data_dir"]) / "sentiment_btc.csv"
if sentiment_path.exists():
self._sentiment_df = pd.read_csv(
sentiment_path,
parse_dates=["date"],
)
self._sentiment_df["date"] = pd.to_datetime(
self._sentiment_df["date"], utc=True
)
self._sentiment_df = self._sentiment_df.set_index("date")
else:
# 若 CSV 不存在,建立全中性的空 DataFrame
self._sentiment_df = pd.DataFrame(columns=["sentiment_score"])
def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
# 技術指標
dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
dataframe["ema20"] = ta.EMA(dataframe, timeperiod=20)
dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
# 合併情緒分數:把 dataframe 的 date 欄位對齊到整點小時
dataframe["date_hour"] = dataframe["date"].dt.floor("h")
dataframe = dataframe.merge(
self._sentiment_df[["sentiment_score"]],
how="left",
left_on="date_hour",
right_index=True,
)
# 沒有情緒資料的時間點填 0(中性),策略照常執行
dataframe["sentiment_score"] = dataframe["sentiment_score"].fillna(0.0)
dataframe.drop(columns=["date_hour"], inplace=True)
return dataframe
def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
dataframe.loc[
(
# 技術條件:EMA 多頭排列 + RSI 低檔回升
(dataframe["ema20"] > dataframe["ema50"]) &
(dataframe["rsi"] < 45) &
(dataframe["rsi"].shift(1) < dataframe["rsi"]) &
# 情緒條件:過濾極度負面情緒環境
(dataframe["sentiment_score"] >= self.sentiment_threshold) &
(dataframe["volume"] > 0)
),
"enter_long"
] = 1
return dataframe
def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
dataframe.loc[
(
(dataframe["rsi"] > 70) |
(dataframe["ema20"] < dataframe["ema50"])
),
"exit_long"
] = 1
return dataframe
為回測補充歷史情緒資料
回測需要歷史情緒資料,但你的 script 只會從現在開始累積。解決方法:用 LLM 「重跑」歷史新聞(CryptoCompare 提供有限的歷史新聞 API),或是用另一個簡化方法——先用隨機漫步合成假的歷史情緒資料來驗證架構,等累積了真實資料再做嚴肅回測:
# generate_synthetic_sentiment.py
# 目的:生成假的歷史情緒資料,只用來驗證架構正確性
# 注意:這個資料沒有真實預測意義,不能用來評估策略好壞
import pandas as pd
import numpy as np
from datetime import datetime, timedelta, timezone
start = datetime(2024, 1, 1, tzinfo=timezone.utc)
end = datetime(2025, 1, 1, tzinfo=timezone.utc)
hours = int((end - start).total_seconds() / 3600)
np.random.seed(42)
# 用平滑隨機漫步模擬情緒自相關性
raw = np.random.randn(hours) * 0.3
smoothed = pd.Series(raw).ewm(span=12).mean().clip(-1, 1)
df = pd.DataFrame({
"date": [start + timedelta(hours=i) for i in range(hours)],
"sentiment_score": smoothed.values.round(4),
"news_count": np.random.randint(5, 25, size=hours),
})
df.to_csv("user_data/sentiment_btc.csv", index=False)
print(f"生成 {hours} 筆合成情緒資料")
跑對比回測:有情緒 vs 無情緒
同一段時間跑兩次回測,比較加入情緒過濾器前後的差異:
# 回測 1:有情緒過濾器
freqtrade backtesting \
--config config.json \
--strategy SentimentStrategy \
--timerange 20240101-20250101 \
--fees 0.001
# 把結果存下來(freqtrade 回測結果存在 user_data/backtest_results/)
cp user_data/backtest_results/*.json user_data/backtest_results/with_sentiment.json
接著把策略的情緒條件暫時關掉,做控制組:
# 在 populate_entry_trend 裡,暫時把情緒條件改成永遠為真:
# (dataframe["sentiment_score"] >= self.sentiment_threshold)
# 改成:
# (dataframe["sentiment_score"] >= -9999) # 永遠通過
# 回測 2:無情緒過濾(控制組)
freqtrade backtesting \
--config config.json \
--strategy SentimentStrategy \
--timerange 20240101-20250101 \
--fees 0.001
用 freqtrade 內建的比較工具看差異:
freqtrade backtesting-analysis \
--config config.json \
--export-filename user_data/backtest_results/with_sentiment.json
解讀對比結果——別輕易相信 AI 訊號
對比回測的解讀有幾個關鍵問題要問:
== 有情緒過濾 ==
Total Profit: +6.2%
Max Drawdown: -8.4%
Trades: 61
Sharpe Ratio: 0.61
== 無情緒過濾(控制組) ==
Total Profit: +4.1%
Max Drawdown: -12.4%
Sharpe Ratio: 0.42
看起來有情緒過濾表現更好——但先別急著慶祝,問自己這三個問題:
1. 情緒資料是真實的還是合成的? 合成資料的回測只能驗證「架構能跑」,無法驗證「AI 情緒真的有預測力」。
2. 有沒有前視偏差? 如果你的情緒分數是「事後用已知新聞算的」,而不是「當下只用那根 K 線之前的新聞算的」,你等於讓策略偷看未來,回測完全失效。
3. 改善是因為情緒,還是因為少交易? 情緒過濾器讓交易次數從 87 減少到 61。驗證方法:把情緒閾值換成「隨機略過 30% 訊號」的規則,如果結果差不多,代表情緒評分本身沒有貢獻。
AI 訊號的回測驗證:別迷信
2025-2026 年有大量論文聲稱「LLM 情緒分析讓策略 Sharpe Ratio 從 1.55 提升到 1.90」——這些結果可能在特定條件下是真實的,但在你自己的系統上複製之前,有幾個陷阱要留意:

時序對齊是最常犯的錯。確認你的情緒 CSV 中每個時間點的分數,只使用了「那個時間點之前已經公開的新聞」。
跨 Regime 測試:只在牛市區間回測的情緒策略幾乎一定好看。一定要加入熊市和震盪盤區間再跑一次。
基準線對比:把情緒過濾換成等比例的隨機略過,這是最快的貢獻度驗證方法。
常見坑
坑 1:LLM 回傳的不是合法 JSON——json.JSONDecodeError
症狀:sentiment_scorer.py 跑到一半突然崩潰,錯誤訊息:
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
原因:LLM 有時候在 JSON 前後多輸出說明文字,或在分數後面加上解釋段落,導致 json.loads() 失敗。解法:用 regex 從輸出中擷取 JSON 區塊,不要假設整個輸出都是 JSON:
import re
def extract_json(text: str) -> dict:
match = re.search(r'\{.*?\}', text, re.DOTALL)
if match:
try:
return json.loads(match.group())
except json.JSONDecodeError:
pass
return {"score": 0.0, "reason": "parse_error"}
坑 2:情緒資料時區對齊錯誤,merge 之後情緒欄位全部是 NaN
症狀:sentiment_score 欄位全是 NaN,回測結果和控制組完全一樣。診斷:在 populate_indicators 裡加一行:
print(dataframe[["date", "date_hour", "sentiment_score"]].head(10))
通常是因為 CSV 的時間是 UTC+8 台灣時間存入,但 freqtrade dataframe 的 date 欄位是 UTC。兩邊時區不一致,merge key 完全對不上。解法:所有時間存入 CSV 時一律用 UTC,讀出時加上 utc=True:
self._sentiment_df["date"] = pd.to_datetime(
self._sentiment_df["date"], utc=True
)
坑 3:情緒分數只有正數,因為 prompt 寫得太正面
症狀:看 CSV 發現 sentiment_score 幾乎全在 0.2-0.8 之間,從來沒有負數。情緒過濾器永遠不會觸發。
原因:LLM 因 RLHF 訓練偏向正面輸出。解法:在 prompt 末尾加上校準範例:
重要:你的評分必須對正負面保持公正。
負面範例:「FTX 創辦人被判刑 25 年」→ score: -1.0
正面範例:「貝萊德 BTC 現貨 ETF 獲批」→ score: 1.0
中性範例:「BTC 在 $65000 附近整理」→ score: 0.0
坑 4:APIStatusError: 529 overloaded — API 過載導致情緒資料斷更
症狀:cron 某次執行失敗,那個小時情緒欄位空缺,fillna(0.0) 讓策略以中性進場。市場劇烈波動的關鍵時刻若此時發生,等於在情緒盲區交易。解法:加上重試邏輯:
import time
def score_with_retry(headline: str, max_retries: int = 3) -> dict:
for attempt in range(max_retries):
try:
return score_news(headline)
except Exception as e:
if attempt == max_retries - 1:
print(f"[ERROR] 評分失敗(已重試 {max_retries} 次):{e}")
return {"score": 0.0, "reason": "api_error"}
time.sleep(60)
作業
- 跑完本堂實戰:建立
sentiment_scorer.py、用合成資料生成歷史情緒 CSV、把SentimentStrategy加進 freqtrade、跑一次有/無情緒過濾的對比回測 - 把 sentiment_threshold 從 -0.2 改成 -0.5 和 0.0 各跑一次,觀察交易次數和最大回撤的變化——你正在做的是「手動超參數搜索」
- 選做:在
populate_indicators加一行把sentiment_score存進 freqtrade 的analyze-entry-tag,回測後用--export-filename輸出,在 FreqUI 的圖表上視覺化情緒和進出場的對應關係 - 思考題:你看過的某個「AI 交易機器人」宣傳,它的 LLM 是放在訊號層還是執行層?根據今天學到的架構原則,它的風險在哪裡?
下一課預告
現在你有了一個帶 AI 訊號的策略——但一個策略能不能長期活下去,不是看它能賺多少,而是看它在最壞情況下能虧多少、虧完還有沒有子彈繼續跑。
第 6 課《風控:活下來比賺錢重要》帶你建立量化系統的生存機制:停損設計(固定停損 vs 追蹤停損 vs ATR 停損)、部位大小計算(Kelly 準則的實用簡化版)、最大持倉數限制、策略層級的 Circuit Breaker——連虧超過閾值時自動暫停交易。多數量化系統失敗不是因為策略不好,而是因為一次超預期的波動把帳戶清空,失去繼續優化的機會。