Henry Tsai

MACD 量化交易怎麼做?一份可複製的黃金多空回測腳本

Trading Strategy


想用一條人人都聽過的技術指標,在黃金(XAUUSD)上做出可回測、可複製的交易規則嗎?MACD(Moving Average Convergence Divergence,移動平均收斂發散指標)正是新手最常接觸的動能工具:它不預測明天的金價,只告訴你「快線與慢線誰領先、動能是否翻面」。本文用白話文拆解 MACD(12, 26, 9),並提供一份完整的 Python 腳本,可直接在 ALGOGENE 上對 XAUUSD 日線做回測。

你會學到:MACD 線、訊號線與柱狀圖分別代表什麼、什麼是黃金交叉與死亡交叉、如何用柱狀圖的零軸穿越當進出場事件,以及如何用 ALGOGENE 的 getHistoricalBar 與 sendOrder 把規則變成機器人。


MACD 柱狀圖 = DIF(快 EMA − 慢 EMA)− DEA(DIF 的訊號 EMA)。柱狀圖由負轉正,可視為動能翻多(黃金交叉);由正轉負,可視為動能翻空(死亡交叉)。本策略只在「穿越當下」交易,而不是柱狀圖一直為正就每天重下單。


Friends walking together — social connection and retirement happiness



什麼是 MACD?新手三分鐘看懂

MACD 由 Gerald Appel 提出,核心是兩條指數移動平均線(EMA)的距離。EMA 對近期價格權重較高,因此「快線」會比「慢線」更快跟上金價。

  • DIF(MACD 線):12 日 EMA 減 26 日 EMA。正值代表短期均線在長期均線之上,動能偏多;負值則偏空。
  • DEA(訊號線):對 DIF 再做 9 日 EMA,用來平滑雜訊。
  • 柱狀圖(Histogram):DIF 減 DEA。柱子變長代表動能加速,縮短代表動能減弱;穿越零軸則是方向翻轉的事件。

為什麼黃金適合當第一個 MACD 練習標的?XAUUSD 流動性高、日線走勢有明顯的趨勢段與整理段,新手在 ALGOGENE 上用日線回測時,訊號不會像 1 分鐘圖那樣刷屏,也比較容易對照圖表理解每一次交叉。


交易邏輯(本回測實際使用的規則)

本策略在 XAUUSD 日線收盤價上計算 MACD(12, 26, 9) 柱狀圖,並允許放空(請在回測設定開啟 Short Selling)。倉位採近似滿倉(可用資金的 98%,預留手續費緩衝)。

  • 每次只處理一根新的日線(用時間戳去重,避免同一根 K 線重複下單)
  • 向系統索取最近 200 根日線收盤價,計算整段 MACD 柱狀圖
  • 開倉/翻倉條件:
    • 若沒有與目標方向相同的倉位:
      • 柱狀圖由 ≤0 變成 >0(零軸上穿)→ 視為 MACD 黃金交叉 → 目標改為做多(buysell = 1)
      • 柱狀圖由 ≥0 變成 <0(零軸下穿)→ 視為 MACD 死亡交叉 → 若允許放空,目標改為做空(buysell = -1);否則改為空手
  • 執行動作:若目標方向與目前倉位不同,先平掉該商品所有未平倉單,再依可用餘額開出新的市價單
  • 重試:若開倉被拒絕,把狀態重設為空手,下一根日線再試
  • 重複直到回測區間結束

接下來,我們把這套邏輯一步步寫成 ALGOGENE 的 AlgoEvent。


如何在 ALGOGENE 回測 MACD 黃金策略(新手路徑)

  1. 登入 ALGOGENE,開啟策略編輯器(AlgoEvent 範本)。
  2. 貼上本文最後的完整 Python 程式,保留檔案開頭的 from AlgoAPI import AlgoAPIUtil, AlgoAPI_Backtest。
  3. 回測商品設為 XAUUSD(黃金兌美元,不是 XAUSD)、K 線週期選 日線 D、並勾選 允許放空。
  4. 參數先用預設:快線 12、慢線 26、訊號 9、資金比例 0.98。
  5. 執行回測後,先看權益曲線、持倉直方圖與成交明細,再一次只改一個數字(例如把 12/26 改成 8/21)。

回測引擎會在歷史日線推進時呼叫 on_bulkdatafeed。腳本用 getHistoricalBar 取收盤價、自算 EMA/MACD,再以 AlgoAPIUtil.OrderObject 送出市價單。


Step 1. 初始化參數與合約規格

在 __init__ 裡先寫死商品與 MACD 週期,並準備倉位狀態。接著在 start 用 getContractSpec 讀取 contractSize,之後下單口數才能把「美元資金」換算成黃金合約張數。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from AlgoAPI import AlgoAPIUtil, AlgoAPI_Backtest

class AlgoEvent:
    def __init__(self):
        self.instrument = 'XAUUSD'
        # MACD parameters (same as original strategy: 12, 26, 9)
        self.fast = 12
        self.slow = 26
        self.signal = 9
        self.allow_short = True      # death cross -> SHORT
        self.capital_frac = 0.98     # almost full position, buffer for costs
        self.last_bar_time = None
        self.pos_state = 0           # 1 long, -1 short, 0 flat
        self.desired = 0
        self.open_orders = {}

1
2
3
4
    def start(self, mEvt):
        self.evt = AlgoAPI_Backtest.AlgoEvtHandler(self, mEvt)
        self.contractSize = self.evt.getContractSpec(self.instrument)["contractSize"]
        self.evt.start()

pos_state 是策略自己記住的方向:1 做多、-1 做空、0 空手。desired 是最新交叉告訴我們「想成為」的方向。只有兩者不一致時才動單,這能避免同一根交叉被重複執行。


Step 2. 自己算出 EMA 與 MACD 柱狀圖

本腳本不依賴 talib,方便你看懂每一行。EMA 的種子值用前 period 根的簡單平均(SMA),之後用平滑係數 k = 2 / (period + 1) 遞推,這是與常見技術分析函式庫相同的慣例。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    def ema_series(self, values, period):
        # EMA seeded with SMA (talib convention)
        if len(values) < period:
            return None
        k = 2.0 / (period + 1)
        prev = sum(values[:period]) / period
        out = [prev]
        for v in values[period:]:
            prev = v * k + prev * (1 - k)
            out.append(prev)
        return out

有了快、慢兩條 EMA,相減得到 DIF;再對 DIF 做 9 期 EMA 得到 DEA;最後 柱狀圖 = DIF − DEA。只要歷史長度不夠,函式就回傳 None,主邏輯會跳過,避免用半成品指標亂下單。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    def macd_hist(self, closes):
        ema_f = self.ema_series(closes, self.fast)
        ema_s = self.ema_series(closes, self.slow)
        if ema_f is None or ema_s is None:
            return None
        n = min(len(ema_f), len(ema_s))
        dif = [ema_f[-n + i] - ema_s[-n + i] for i in range(n)]
        dea = self.ema_series(dif, self.signal)
        if dea is None:
            return None
        m = min(len(dif), len(dea))
        return [dif[-m + i] - dea[-m + i] for i in range(m)]

Step 3. 下單輔助函式:先平倉、再滿倉開新方向

黃金交叉之後若帳戶裡還有空單,必須先平掉再做多,否則會變成鎖倉。平倉遍歷 open_orders(由 on_openPositionfeed 同步),對 XAUUSD 送 openclose = 'close'。

開倉則用可用餘額 × 0.98 ÷ 現價 ÷ 合約規模,四捨五入到 4 位小數,並以市價單(ordertype = 0)送出。資金留 2% 是為了手續費與報價跳動,避免「帳面剛好滿倉、實際下不出去」。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
    def close_all_btc(self):
        for tradeID in list(self.open_orders.keys()):
            if self.open_orders[tradeID]['instrument'] == self.instrument:
                order = AlgoAPIUtil.OrderObject()
                order.tradeID = tradeID
                order.openclose = 'close'
                self.evt.sendOrder(order)

    def open_full_position(self, buysell, price, ab):
        volume = round(ab['availableBalance'] * self.capital_frac / price / self.contractSize, 4)
        if volume <= 0:
            self.evt.consoleLog('skip open: insufficient balance')
            return
        order = AlgoAPIUtil.OrderObject()
        order.instrument = self.instrument
        order.openclose = 'open'
        order.buysell = buysell
        order.ordertype = 0
        order.volume = volume
        order.orderRef = 'macd_%s' % ('L' if buysell == 1 else 'S')
        self.evt.sendOrder(order)

Step 4. 主邏輯:日線柱狀圖零軸交叉

真正做決定的地方是 on_bulkdatafeed。它會:

  1. 確認這根日線的時間戳是新的;
  2. 取 200 根日線,收盤價序列長度必須超過 26+9,指標才夠熱身;
  3. 比較上一根與這一根柱狀圖,只在穿越零軸時改 desired;
  4. 若目標方向與 pos_state 不同,先平後開,並把狀態改成目標方向。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
    def on_bulkdatafeed(self, isSync, bd, ab):
        if self.instrument not in bd:
            return
        ts = bd[self.instrument]['timestamp']
        if ts == self.last_bar_time:
            return
        self.last_bar_time = ts

        res = self.evt.getHistoricalBar({"instrument": self.instrument}, 200, 'D')
        if len(res) < self.slow + self.signal + 5:
            return
        closes = [res[t]['c'] for t in res]
        hist = self.macd_hist(closes)
        if hist is None or len(hist) < 2:
            return
        h_now, h_prev = hist[-1], hist[-2]
        price = bd[self.instrument]['lastPrice']

        # MACD histogram zero-cross
        if h_now > 0 and h_prev <= 0:
            self.desired = 1
            self.evt.consoleLog('MACD golden cross @ %s hist=%.4f' % (ts, h_now))
        elif h_now < 0 and h_prev >= 0:
            self.desired = -1 if self.allow_short else 0
            self.evt.consoleLog('MACD death cross @ %s hist=%.4f' % (ts, h_now))

        if self.desired != 0 and self.pos_state != self.desired:
            self.close_all_btc()
            self.open_full_position(self.desired, price, ab)
            self.pos_state = self.desired

把「交叉」寫成事件(上一根在零軸一側、這一根到另一側),而不是「柱狀圖 > 0 就買」,非常重要:後者會在趨勢延續時每天重複送單。


Step 5. 成交回報與倉位同步

系統確認成交後會呼叫 on_orderfeed。開倉被拒絕時,把 pos_state 歸零,下一根日線才有機會重試。持倉清單則以 on_openPositionfeed 的 oo 為準,這樣平倉函式永遠對得到真實的 tradeID。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
    def on_orderfeed(self, of):
        if of.status == 'success':
            if of.openclose == 'open':
                self.evt.consoleLog('open filled', of.tradeID, of.instrument, of.buysell,
                                    of.fill_volume, of.fill_price)
            else:
                self.evt.consoleLog('close filled', of.tradeID, of.instrument, of.fill_price)
        elif of.status == 'reject':
            if of.openclose == 'open':
                self.pos_state = 0   # allow retry on next bar
            self.evt.consoleLog('order rejected', of.tradeID, of.instrument, of.openclose)

    def on_openPositionfeed(self, op, oo, uo):
        self.open_orders = dict(oo)

完整原始碼

把以上片段合併,即可得到完整的 ALGOGENE MACD 黃金回測腳本。複製到策略編輯器後即可執行。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
from AlgoAPI import AlgoAPIUtil, AlgoAPI_Backtest

class AlgoEvent:
    def __init__(self):
        self.instrument = 'XAUUSD'
        # MACD parameters (same as original strategy: 12, 26, 9)
        self.fast = 12
        self.slow = 26
        self.signal = 9
        self.allow_short = True      # death cross -> SHORT (short selling enabled in settings)
        self.capital_frac = 0.98     # "full position" sizing, small buffer for costs
        # state
        self.last_bar_time = None
        self.pos_state = 0           # 1 long, -1 short, 0 flat
        self.desired = 0
        self.open_orders = {}        # tradeID -> filled open positions

    def start(self, mEvt):
        self.evt = AlgoAPI_Backtest.AlgoEvtHandler(self, mEvt)
        
        self.contractSize = self.evt.getContractSpec(self.instrument)["contractSize"]
        
        self.evt.start()

    # ---------- indicator ----------
    def ema_series(self, values, period):
        # EMA seeded with SMA (talib convention)
        if len(values) < period:
            return None
        k = 2.0 / (period + 1)
        prev = sum(values[:period]) / period
        out = [prev]
        for v in values[period:]:
            prev = v * k + prev * (1 - k)
            out.append(prev)
        return out

    def macd_hist(self, closes):
        ema_f = self.ema_series(closes, self.fast)
        ema_s = self.ema_series(closes, self.slow)
        if ema_f is None or ema_s is None:
            return None
        n = min(len(ema_f), len(ema_s))
        dif = [ema_f[-n + i] - ema_s[-n + i] for i in range(n)]
        dea = self.ema_series(dif, self.signal)
        if dea is None:
            return None
        m = min(len(dif), len(dea))
        return [dif[-m + i] - dea[-m + i] for i in range(m)]

    # ---------- order helpers ----------
    def close_all_btc(self):
        for tradeID in list(self.open_orders.keys()):
            if self.open_orders[tradeID]['instrument'] == self.instrument:
                order = AlgoAPIUtil.OrderObject()
                order.tradeID = tradeID
                order.openclose = 'close'
                self.evt.sendOrder(order)

    def open_full_position(self, buysell, price, ab):
        volume = round(ab['availableBalance'] * self.capital_frac / price / self.contractSize, 4)
        if volume <= 0:
            self.evt.consoleLog('skip open: insufficient balance')
            return
        order = AlgoAPIUtil.OrderObject()
        order.instrument = self.instrument
        order.openclose = 'open'
        order.buysell = buysell
        order.ordertype = 0
        order.volume = volume
        order.orderRef = 'macd_%s' % ('L' if buysell == 1 else 'S')
        self.evt.sendOrder(order)

    # ---------- main logic: daily bars ----------
    def on_bulkdatafeed(self, isSync, bd, ab):
        if self.instrument not in bd:
            return
        ts = bd[self.instrument]['timestamp']
        if ts == self.last_bar_time:
            return
        self.last_bar_time = ts

        res = self.evt.getHistoricalBar({"instrument": self.instrument}, 200, 'D')
        if len(res) < self.slow + self.signal + 5:
            return
        closes = [res[t]['c'] for t in res]
        hist = self.macd_hist(closes)
        if hist is None or len(hist) < 2:
            return
        h_now, h_prev = hist[-1], hist[-2]
        price = bd[self.instrument]['lastPrice']

        # signal detection (MACD histogram zero-cross)
        if h_now > 0 and h_prev <= 0:
            self.desired = 1
            self.evt.consoleLog('MACD golden cross @ %s hist=%.4f' % (ts, h_now))
        elif h_now < 0 and h_prev >= 0:
            self.desired = -1 if self.allow_short else 0
            self.evt.consoleLog('MACD death cross @ %s hist=%.4f' % (ts, h_now))

        # act to reach desired state (also retries after a rejected open)
        if self.desired != 0 and self.pos_state != self.desired:
            self.close_all_btc()
            self.open_full_position(self.desired, price, ab)
            self.pos_state = self.desired

    def on_orderfeed(self, of):
        if of.status == 'success':
            if of.openclose == 'open':
                self.evt.consoleLog('open filled', of.tradeID, of.instrument, of.buysell,
                                    of.fill_volume, of.fill_price)
            else:
                self.evt.consoleLog('close filled', of.tradeID, of.instrument, of.fill_price)
        elif of.status == 'reject':
            if of.openclose == 'open':
                self.pos_state = 0   # allow retry on next bar
            self.evt.consoleLog('order rejected', of.tradeID, of.instrument, of.openclose)

    def on_openPositionfeed(self, op, oo, uo):
        self.open_orders = dict(oo)

    # ---------- unused callbacks ----------
    def on_marketdatafeed(self, md, ab):
        pass

    def on_newsdatafeed(self, nd):
        pass

    def on_weatherdatafeed(self, wd):
        pass

    def on_econsdatafeed(self, ed):
        pass

    def on_corpAnnouncement(self, ca):
        pass

    def on_dailyPLfeed(self, pl):
        pass

建議回測設定

在 ALGOGENE 回測面板可用下列起點(可依帳戶保證金與商品規格微調初始資金):

  • 商品:XAUUSD(黃金兌美元)
  • K 線週期:日線(D)
  • MACD:12 / 26 / 9
  • 允許放空:True(死亡交叉才會開空)
  • 倉位:可用資金約 98%
  • 下單類型:市價單

跑完後請同時看三件事:權益曲線是否只靠少數幾筆大單、最大回撤發生在盤整還是單邊趨勢、成交明細是否出現「同一天平倉又開倉」。MACD 是趨勢跟隨工具,在黃金震盪盤時交叉會變多、鞭索也會變多——這不是程式寫錯,而是指標特性。


常見問題(FAQ)

MACD 黃金交叉是什麼?
本策略定義為柱狀圖從零軸下方(或剛好在零)穿越到上方,代表短期動能轉正。圖表軟體上常對應 DIF 上穿 DEA,與柱狀圖由負轉正是同一件事的兩種畫法。

MACD 和 RSI 有何不同?
RSI 衡量漲跌幅度是否「超買/超賣」,偏均值回歸。MACD 衡量均線差距是否翻面,偏趨勢跟隨。黃金可以兩種都做,但規則與出場哲學不一樣。

為什麼要用日線而不是 5 分鐘?
日線雜訊較少,交叉次數對新手友善,也比較好對照新聞日與走勢。同一套 12/26/9 換到更短週期,通常要重新評估成本與鞭索。

XAUSD 和 XAUUSD 差在哪?
正確代碼是 XAUUSD(XAU 是黃金的 ISO 代碼)。寫成 XAUSD 會找不到商品。請在 ALGOGENE 商品清單選擇 XAUUSD。

回測獲利代表實盤一定賺嗎?
不會。回測是歷史樣本加上一組參數。點差、滑價、週末跳空與保證金要求都會改變實盤結果。請把這份腳本当成「學會 ALGOGENE 工作流」的範例,而不是直播喊單。


後續可以怎麼改進

上面的結果再漂亮,也只是起點。下面幾個問題能幫你把策略變得更接近實戰:

  • 我們假設可以放空黃金差價合約。若改做不能放空的標的,死亡交叉應改為平多、空手等待。
  • 12/26/9 是股票市場的經典預設。
    • 黃金日線用 8/21/5 或 19/39/9 會不會比較穩?
    • 同一組參數用在 1 小時線會不會過度交易?
  • 目前是「交叉就滿倉翻向」,沒有停損。
    • 若交叉後金價繼續逆行,要等到下一次相反交叉才出場,回撤可能很大。
    • 可考慮固定金額停損、ATR 停損,或均線反向過濾。
  • 震盪市如何過濾?例如要求日線收盤站上 50 日均線才接受黃金交叉。
  • 與 RSI、AD 線或突破區間一起確認,會不會減少假交叉?
  • 不要只在同一段金價上最佳化參數。換一段樣本、或改測 XAGUSD(白銀)做樣本外檢查,才比較誠實。


結語

MACD 能用一句話講完:快慢均線的差距翻面時,跟著改變方向。在 ALGOGENE 上,它對應一份不長的 AlgoEvent——用日線收盤自算柱狀圖、零軸上穿做多、下穿做空、先平後開、留下 2% 資金緩衝。把腳本貼上、對 XAUUSD 跑日線回測,然後一次只改一個數字。這個「想法 → 程式 → 圖表 → 再改」的迴圈,就是新手量化交易最踏實的起點。

歡迎在留言分享你的快慢線週期與是否放空的設定。祝回測順利。