精華筆記

· @aihub.tw

AI 量化交易

AI 的正確位置:訊號層,不是執行層

AI 的正確位置:訊號層,不是執行層

你在 YouTube 看到「AI 交易 Agent 全自動幫你下單」的影片,或者在 Discord 看到有人分享「接了 GPT-4 直接控制幣安帳戶,月賺 XX%」——這種說法到處流傳,仍然在誘使新手走錯路。

問題不是「AI 很弱」,而是把 AI 放在系統的錯誤位置。業界用幾年時間搞清楚一件事:LLM 在交易系統裡,有非常明確的有用之處,也有非常明確的禁忌邊界。這堂課把這條線畫清楚,然後帶你做出一個真正有意義的 AI 應用。

風險警語 本課程所有內容僅供學習與研究用途,不構成任何投資建議。AI 訊號與情緒分析無法預測市場,回測績效不代表未來實盤結果。文中所有程式碼均在模擬盤(dry_run: true)環境下示範;若你在學習後決定投入實盤,風險與責任由你自行承擔。
這堂課適合誰 適合:完成第 4 課(freqtrade 回測實戰)、想把 AI 整合進交易系統的人。本課程屬工程師應用專區。需要基礎:Python 中階(能讀懂 class、懂 pandas DataFrame 操作、會呼叫 REST API)。前置課:第 4 課《回測實戰:freqtrade 上手》。

這堂學什麼

  • LLM 不能直接下單的原因——不是技術問題,是架構問題
  • LLM 的三個正確位置:新聞情緒評分、市場 Regime 標注、策略程式碼開發輔助
  • LLM 訊號接進 freqtrade 的具體架構與程式碼
  • 呼叫 LLM API 的成本與延遲——決定你能做哪種頻率的策略
  • AI 訊號的回測驗證:別因為「感覺很強」就相信
  • 實戰:寫一個情緒過濾器,合併進策略,跑有/無 AI 訊號的對比回測

鐵律:LLM 絕對不碰執行層

這不是主觀偏好,而是業界的明確共識,背後有四個具體原因:

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 的三個正確位置

弄清楚禁忌之後,來看「真正能做什麼」:

LLM 在量化系統中的三個正確角色

1. 新聞情緒評分

給 LLM 一篇加密貨幣新聞,讓它回傳 -1(極度看跌)到 +1(極度看漲)的數值。這個數值作為策略的一個輸入特徵,和 RSI、MA 等技術指標並列——不是唯一決策依據,只是多一個維度的資訊。

2. 市場 Regime 標注

給 LLM 最近 N 天的市場摘要(波動率、趨勢方向、重大事件),讓它輸出「趨勢/震盪/高波動」的分類標籤。不同 Regime 下,同一組技術指標的有效性天差地遠——動量策略在趨勢市贏錢,在震盪市燒錢。有了 Regime 標注,你可以讓策略根據當前狀態切換參數或暫停交易。

3. 策略程式碼開發輔助

讓 Claude 幫你寫策略程式碼、解釋 freqtrade API、調試錯誤訊息。這個用法風險最低、效益最高,這門課從第 1 課就在這樣做了。

成本與延遲的現實

在決定用 LLM 前,先把帳算清楚。

LLM API 成本與延遲試算表

以 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

實際的系統分成兩個獨立的部分:

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」——這些結果可能在特定條件下是真實的,但在你自己的系統上複製之前,有幾個陷阱要留意:

AI 訊號回測驗證的三層檢查

時序對齊是最常犯的錯。確認你的情緒 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)

作業

  1. 跑完本堂實戰:建立 sentiment_scorer.py、用合成資料生成歷史情緒 CSV、把 SentimentStrategy 加進 freqtrade、跑一次有/無情緒過濾的對比回測
  2. 把 sentiment_threshold 從 -0.2 改成 -0.5 和 0.0 各跑一次,觀察交易次數和最大回撤的變化——你正在做的是「手動超參數搜索」
  3. 選做:在 populate_indicators 加一行把 sentiment_score 存進 freqtrade 的 analyze-entry-tag,回測後用 --export-filename 輸出,在 FreqUI 的圖表上視覺化情緒和進出場的對應關係
  4. 思考題:你看過的某個「AI 交易機器人」宣傳,它的 LLM 是放在訊號層還是執行層?根據今天學到的架構原則,它的風險在哪裡?

下一課預告

現在你有了一個帶 AI 訊號的策略——但一個策略能不能長期活下去,不是看它能賺多少,而是看它在最壞情況下能虧多少、虧完還有沒有子彈繼續跑。

第 6 課《風控:活下來比賺錢重要》帶你建立量化系統的生存機制:停損設計(固定停損 vs 追蹤停損 vs ATR 停損)、部位大小計算(Kelly 準則的實用簡化版)、最大持倉數限制、策略層級的 Circuit Breaker——連虧超過閾值時自動暫停交易。多數量化系統失敗不是因為策略不好,而是因為一次超預期的波動把帳戶清空,失去繼續優化的機會。

再次確認:AI 訊號的限制 新聞情緒分析是一個輔助工具,不是預測市場的水晶球。LLM 的情緒評分反映的是新聞文字傾向,不等於市場的實際走向。本堂課的所有程式碼均在模擬盤環境中示範;若你決定投入實盤,AI 訊號帶來的任何結果——不論正負——由你自行承擔。不要因為「AI 說看漲」就增加部位規模或降低風控標準。

#AI 量化交易#LLM#情緒分析#freqtrade#訊號層#FreqAI

← 回所有文章