【手把手系列】策略回測績效報告

【手把手系列】布林通道策略 + 回測教學篇章中我們介紹了如何運用布林通道進行回測,加上濾網與止損後回測績效改善不少,本篇我們要計算更多回測績效,在畫圖時會增加總資產以及回撤曲線,使用範例是VolTraderSample中的course6檔案夾,使用的策略是布林通道週期20、上下標準差各為2加上濾網及止損,下面開始講解:

 

回測指標介紹

總共有23個指標分別是:

  指標名稱 指標算法 指標介紹
1. 總投資報酬率 所有交易的投資報酬率加總 總投資報酬率越高代表能獲利越多
2. 淨投資報酬率 扣除手續費後的總投資報酬率 扣除手續費後的投資報酬率較為符合現實
3. 總交易日 第一筆買入時間到最後一筆賣出時間的工作天數 總交易日可以幫助我們計算投資的時間及頻率
4. 最終持有資金 交易完成後最終持有的總資產包含持有期貨的價值 回測完最終的資金
5. 淨利潤 最終資金減去起始資金 淨利潤大於0時才會有獲利
6. 勝率 獲利的次數佔全部交易次數的比率 勝率大於0.5代表獲利比虧損的次數多
7. 損失率 虧損次數佔全部交易次數的比率 虧損率小於0.5代表虧損比獲利次數少
8. 平均獲利 平均獲利金額與初始資金的比例 平均獲利越高代表每次獲利時的金額越大
9. 平均虧損 平均虧損金額與初始資金的比例 平均虧損越高代表每次虧損時的金額越大
10. 盈虧比 平均獲利除以平均虧損 盈虧比大於1代表平均獲利比平均虧損高,數值越高代表大賺小虧,相反數值越低代表大虧小賺
11. 期望值 勝率乘以盈虧比減去虧損率,是平均每次交易獲利或虧損的金額與初始資金的比例 期望值大於0表示策略在回測期間能夠獲利
12. 總交易次數 總共的買賣次數 總交易次數是回測期間內的所有交易總數
13. 總手續費 總交易次數乘上手續費用

總手續費越高代表交易次數越多

14. 最大虧損 在持倉的情況下最大虧損金額 最大虧損越高代表在回測期間內最壞的情況下虧損的總金額
15. 最大資金回撤率 最大初始資金虧損的比例 最大資金回撤率大於1時表示在回測期間出現虧損大於初始資金的情況
16. 最大回撤天數 在持倉的情況下,總資產無法創新高的最大連續天數 最大回撤天數代表執行策略時最壞的情況下無法回本的天數
17. 日均盈虧 平均每天獲利或虧損的金額 日均盈虧顯示出平均每天的獲利或虧損情況,如果是負值代表最後總投資報酬率是虧損的
18. 日均手續費 平均每天的手續費用 日均手續費顯示出每天需要繳交的手續費用,交易頻率越高手續費越高
19. 日均成交筆數 平均每天的交易次數 日均成交筆數反映出交易的頻率
20. 日均成交金額 平均每天的總交易金額 日均成交金額反映出每天的資金流動情況
21. 年化收益率 換算每年的投資報酬率 年化收益率將淨投資報酬率轉換成一年的投資報酬率
22. 年化標準差 換算每年的標準差 年化標準差越高代表策略波動越大
23. 年化變異數 年化標準差的平方 年化變異數放大檢視策略的波動

 

製作回測績效報告

1. 匯入套件

這次會使用到PrettyTable將指標以表格的形式顯示出來,FuncFormatter可以更改畫圖時的標籤

# 載入 tcoreapi
from tcoreapi_mq import *
# 載入我們提供的行情 function
from quote_functions import *
import threading
import numpy as np
import talib
import pandas as pd
import matplotlib.pyplot as plt
from datetime import timedelta
from prettytable import PrettyTable
from matplotlib.ticker import FuncFormatter

 

2. 取得歷史資料以及設定參數、買賣時機、繪圖可以參考【手把手系列】布林通道策略 + 回測教學

 

3. 定義函式equity_mdd紀錄總資產、每根K投資報酬率、最大回撤值

這個函式主要是用來計算與更新總資產、投資報酬率與最大回撤值。

函式中有五個參數分別是tdd、mdd、temp、count、pos。

tdd輔助mdd計算,mdd負責記錄最大回撤值,temp輔助count計算,count紀錄最大回徹次數,pos是倉位的情況。

詳細的計算方式可參考下列表格,計算完後函式將tdd、mdd、temp、count結果返回。

變數名稱 變數介紹 計算方法
tdd 幫助計算mdd的輔助參數 累加損益值,如果大於0時則重置為0
mdd 計算最大虧損 使用min比較tdd跟上一個mdd的大小,如果tdd比較小就更新數值
temp 輔助計算count的參數 當tdd小於0時temp加1,當tdd大於0時重置temp為0
count 計算最大回撤次數 count使用max判斷temp跟上一個count的大小,當temp創新高時count進行更新
pos 判斷持倉情況 pos = -1代表空單,pos=0代表沒有倉位,pos = 1代表多單
profit_or_loss 總資產損益值

我們要計算總資產損益值才能看出可能的最高獲利或是最大回撤,因此我們會逐根 K 棒計算部位的損益,並且累計到總資產損益值内。

損益值計算方式是( 後一根 K 棒開盤價 - K 棒開盤價) * 契約乘數 * 倉位

equity 紀錄總資產 使用append增加計算出來的損益值+ 總資產損益值
ROI_minute 紀錄投資報酬率 使用append紀錄( 目前的總資產 - 前一根 K 棒總資產) / 前一根 K 棒總資產
def equity_mdd(tdd, mdd, temp, count, pos):
    if not pos:
        equity.append(equity[-1])
        return tdd, mdd, temp, count
    else:
        # 計算損益
        profit_or_loss = (history_data["Open"][i + 1] - history_data["Open"][i]) * tick_price * pos
    # 記錄總資產
    equity.append(profit_or_loss + equity[-1])
    # 紀錄每分投資報酬率
    ROI_minute.append((equity[-1] - equity[-2]) / equity[-2])

    tdd += profit_or_loss
    if tdd > 0:
        tdd = 0
        temp = 0
    else:
        mdd = min(tdd, mdd)
        temp += 1
        count = max(temp, count)
    return tdd, mdd, temp, count

 

4. 多單進場

多單進場時,這根K判斷是否有買入訊號,有訊號則在下根K進行多單進場。

將tdd、mdd、temp、count、pos代入定義好的equity_mdd中。

輸出結果再儲存進tdd、mdd、temp、count,相當於更新這四個變量,如果進場前沒有持倉,函式輸出的結果會跟代入前的數值一樣。

將更新完的tdd儲存進DD列表中,方便後面繪圖

for i in range(len(history_data["Close"])):
    if 0 < i < len(history_data["Close"]) - 1:
        # 如果上根K收盤價高於下軌且這根K收盤價又低於下軌,且在短均線皆大於長均線、
        # 沒有做多的情況下,或是空單價格上漲超過100點以上買進
        if diff2[i] < 0 < diff2[i - 1] and \
                sma0[i] > middleband[i] > sma1[i] > sma2[i] and pos <= 0 or \
                history_data["Open"][i] >= tick + reverse_price and pos == -1:
                    ...
            tdd, mdd, temp, count = equity_mdd(tdd, mdd, temp, count, pos)
            # 多單進場,pos設為1
            pos = 1
            # 紀錄買入時的回撤值到回撤列表中
            DD.append(tdd)

 

5. 空單進場

空單的情況與多單十分相似,把tdd、mdd、temp、count、pos代入進equity_mdd函式中,除了更新前面四個參數值,同時也更新總資產及每根K投資報酬率。

最後將tdd儲存進DD列表中。

        # 如果上根K收盤價低於上軌且這根K收盤價又高於上軌,且在短均線皆小於長均線、
        # 沒有做空的情況下,或是做多後上下跌超過100點賣出
        elif diff[i] > 0 > diff[i - 1] and \
                sma0[i] < middleband[i] < sma1[i] < sma2[i] and pos >= 0 or \
                history_data["Open"][i] <= tick - reverse_price and pos == 1:
            ...
            tdd, mdd, temp, count = equity_mdd(tdd, mdd, temp, count, pos)
            # 空單進場,將pos設為-1
            pos = -1
            # 紀錄賣出時的回撤值到回撤列表中
            DD.append(tdd)

 

6. 持有多單跟空單的狀況分別記錄

除了進出場以外的時間有三種情況,分別是持有多單、持有空單、沒有持倉。

將參數代入equity_mdd,函式會根據pos判斷多單或空單或沒有持倉再進行計算與更新總資產、投資報酬率、mdd等等。

無論是何種情況都需要將tdd儲存進回撤列表中

        else:
            tdd, mdd, temp, count = equity_mdd(tdd, mdd, temp, count, pos)
            # 紀錄回撤值到回撤列表中
            DD.append(tdd)

 

7. 計算獲利與虧損的次數與金額

使用for迴圈如果是多單賣出價格比買入價格高,代表有獲利並累加獲利的次數與金額。

如果賣出的價格沒有比較高,代表虧損並累加虧損的次數與金額,如果賣出價格等於買入價格我們也算作虧損。

空單則是買入價格高於賣出價格時算作獲利,再累加獲利的次數與金額。

當買入價格小於等於賣出價格,累加虧損次數與金額

變數名稱 變數介紹 計算方法
buyprice 多單進場價格 在多單進場時紀錄買進的開盤價
sellprice 多單出場價格 在多單出場時紀錄賣出的開盤價
buyshortprice 空單進場價格 在空單進場時紀錄買進的開盤價
sellshortprice 空單出場價格 在空單出場時紀錄賣出的開盤價
wincounts 獲利次數

多單時加總賣出價格比買入價格高的次數

空單時加總買入價格比賣出價格高的次數

wintotal 獲利總金額 累加獲利的金額
losscounts 虧損次數

多單時加總買入價格比賣出價格高的次數

空單時加總賣出價格比買入價格高的次數

losstotal 虧損總金額 累加虧損的金額
# 多單
for i in range(len(sellprice)):
    # 賣出價格比買入價格高的情況
    if sellprice[i] > buyprice[i]:
        wincounts += 1
        wintotal += sellprice[i] - buyprice[i]
    # 買入價格比賣出價格高的情況
    else:
        losscounts += 1
        losstotal += buyprice[i] - sellprice[i]
# 空單
for i in range(len(sellshortprice)):
    # 買入價格比賣出價格高的情況
    if buyshortprice[i] > sellshortprice[i]:
        wincounts += 1
        wintotal += buyshortprice[i] - sellshortprice[i]
    # 賣出價格比買入價格高的情況
    else:
        losscounts += 1
        losstotal += sellshortprice[i] - buyshortprice[i]

 

8. 列出買進賣出的日期、價格及最終資金

pd.set_option函式能夠使表格的文字對齊。

加上多空單註記並將買賣日期、價格、投資報酬率轉換成DataFrame的格式。

按照買進日期排序後使用print顯示表格,print出最終持有資金。

最後一次買空到最後一筆資料都沒有出場,所以賣出日期、價格跟投資報酬率出現空值

# 調整表格設定
pd.set_option('display.unicode.ambiguous_as_wide', True)
pd.set_option('display.unicode.east_asian_width', True)
ROI_value = [x for x in ROI if np.isnan(x) == False]
d = [["多單"]*len(buy_date)+["空單"]*len(buyshort_date),
     buy_date+buyshort_date, buyprice+buyshortprice,
     sell_date+sellshort_date, sellprice+sellshortprice, ROI_long+ROI_short]
df = pd.DataFrame(d, index=["多空","買進日期", "買進價格", "賣出日期", "賣出價格", "投資報酬率"]).T
df = df.sort_values("買進日期")
print(df.to_string(index=False))
print("最終持有資金:", cash)

 

9. 計算各種回測指標

平均獲利等於獲利總金額除以獲利次數乘以契約乘數再除以初始資金。

平均虧損等於虧損總金額除以虧損次數乘以契約乘數除以初始資金。

勝率是獲利的比例。

盈虧比則是平均獲利除以平均虧損。

年化標準差是將每一筆投資報酬率的標準差乘上資料長度開根號後除以初始資金開根號。

總交易日是使用max取最後一次賣出日期、min取第一筆買入的日期,再使用pd.bdate_range取出時間範圍中的工作日,最後用len算出工作天數。

總交易次數是買賣訊號的總次數,需要注意多單跟空單分別計算手續費,使用len計算賣出的口數,沒有平倉的口數不進行計算。

變數名稱 變數介紹 計算方法
average_win 平均獲利 獲利總金額/獲利次數*契約乘數/初始資金
average_loss 平均虧損 虧損總金額/虧損次數*契約乘數/初始資金
earn_ratio 勝率 獲利次數/多單和空單出場的次數和
odds 盈虧比 平均獲利/平均虧損
annual_std 年化標準差 每一筆投資報酬率的標準差*資料長度開根號/初始資金開根號
dates 總交易日 最後一筆賣出時間減去第一筆買入時間的工作天數
trades 總交易次數 總共買賣訊號的次數去除最後一筆沒有平倉的交易
# 計算各種回測指標
# 平均獲利
averge_win = wintotal / wincounts * tick_price / cashlist[0]
# 平均虧損
averge_loss = losstotal / losscounts * tick_price / cashlist[0]
# 勝率
earn_ratio = wincounts / len(sellprice + sellshortprice)
# 盈虧比
odds = averge_win / averge_loss
# 年化標準差
annual_std = np.std(ROI_minute) * np.sqrt(len(ROI_minute)) / np.sqrt(cashlist[0])
# 總交易日
dates = len(pd.bdate_range(min(buyshort_date[0], buy_date[0]),
                           max(sellshort_date[-1], sell_date[-1])))
# 總交易次數
trades = len(sellshort_date + sell_date)

 

10. 輸入各項指標名稱與參數進表格中

將指標名稱存進signal列表,各項計算出來的數值存進values列表中。

計算總投資報酬率時,由於記錄時已經扣除手續費,所以需要加回手續費來計算。

只有第一次買進或賣出一口,其餘都買賣兩口所以手續費要乘以兩倍。

最大回撤天數中的count計算的是根K數,台指期貨一天開盤19小時相當於1140分鐘,所以除以1140轉換成天數。

# 指標名稱
signal = ["總投資報酬率(未扣手續費)", "淨投資報酬率(扣手續費)", "總交易日", "最終持有資金", "淨利潤",
          "勝率", "損失率", "平均獲利", "平均虧損", "盈虧比", "期望值", "總交易次數", "總手續費",
          "最大虧損", "最大資金回撤率", "最大回撤天數", "日均盈虧", "日均手續費", "日均成交筆數",
          "日均成交金額", "年化收益率", "年化標準差", "年化變異數"]
# 指標數值
values = [np.round(sum(ROI_value) + (2*trades+1) * fees / cashlist[0], 4),
          np.round(sum(ROI_value), 4),
          dates,
          cash,
          cash - cashlist[0],
          np.round(earn_ratio, 4),
          np.round(1 - earn_ratio, 4),
          np.round(averge_win, 4),
          np.round(averge_loss, 4),
          np.round(odds, 4),
          np.round(earn_ratio * odds - (1 - earn_ratio), 4),
          trades,
          (2*trades+1) * fees,
          np.round(mdd, 4),
          np.round(1 - min(equity) / cashlist[0], 4),
          np.round(count / 1140, 1),
          np.round((cash - cashlist[0]) / dates, 4),
          np.round((2*trades+1) * fees * 2 / dates, 4),
          np.round(trades / dates, 4),
          np.round(sum(sellprice + buyprice) * tick_price / dates, 4),
          np.round(sum(ROI_value) / (history_data.index[-1] - history_data.index[0]).days * 365, 4),
          np.round(annual_std, 4),
          np.round(annual_std ** 2, 4)]

 

11. 顯示表格

使用for迴圈增添每一項指標名稱與數值。

日均成交金額中由於期貨有契約乘數,因此平均每天兩三次交易金額加總起來就高達800多萬

table = PrettyTable(["各項指標名稱", "數值"])
for i in range(len(signal)):
    table.add_row([signal[i], values[i]])
# 預設表格數值置中
print(table)

 

12. 畫出回撤、總資產圖表

為了更改x軸的刻度,我們需要使用subplots回傳的ax,sharex設為true讓兩個圖表的x軸刻度共用。

使用plot畫圖,回撤曲線設為灰色,總資產曲線用預設的藍色。

定義format_date函式,這個函式用來將x軸刻度從數字轉換成日期,再用FuncFormatter套用到剛剛定義好的函式中進行轉換。

這邊要注意如果直接使用日期繪圖的話,matplotlib會自動補足缺失的日期。

用set_xlabel跟set_ylabel設定x軸和y軸的標籤。

最後用plt.show顯示圖表。

_, (ax, ax2) = plt.subplots(2, 1, sharex=True)
# 畫回撤、總資產曲線
ax.plot(DD, color="grey")
ax2.plot(equity)


def format_date(index, pos):
    index = np.clip(int(index + 0.5), 0, len(history_data.index) - 1)
    return history_data.index[index].strftime("%Y-%m-%d")


ax.xaxis.set_major_formatter(FuncFormatter(format_date))
# 增加軸標籤
ax.set_ylabel("DD")
ax2.set_xlabel("Date")
ax2.set_ylabel("Equity")

plt.show()

 

到此我們計算出23種回測指標,也將回撤與總資產曲線畫出來了,可以看到使用濾網與止損後的布林通道各項數值都還不錯,一個月內有55次交易,投資報酬率也有1.889等等,勝率雖然不到0.5,但是盈虧比高達1.7375,也就是平均虧損1元可以獲利1.7375元,換句話說就是大賺小賠,好的策略往往需要高盈虧比,如果是當沖的話勝率的重要性也會提高。

最後還是要注意過去的績效再好未來的預測也有可能不準確,畢竟歷史數據不代表未來,配合當下時事或許會有所幫助。

 

【免費體驗】立即申請 VolTrader Python API 免費試用!

學生專屬,【限量 20 名】免費體驗價值 18,000/年的專業行情與歷史數據服務!

立即申请