客製化選股策略的回測價格序列 | 比較進出場的時間點特性

  • Post author:
  • Reading time:6 mins read

FinLab Package 目前提供收盤價與開盤價序列的選擇,用來模擬回測的進出場,然而在實務上進行時,你會發現若要貼合回測,只有這兩種選擇的話,不一定能滿足操作上的彈性,造成回測與實彈有落差。這篇教學,會教你分析每一種序列選擇的優劣勢,並客製化地打造更多的回測價格序列選擇

進出場價格序列的比較

不同價格在進出上有不同的意義,整理給大家比較:

  • 開盤價:此序列的特性是能在條件發生後,儘早入手,優點在超級利多發酵時,有比較高的機會用市價入手,若使用收盤價遇到漲停,就只能乖乖排隊。
    缺點是價格的波動大,除了早盤當沖單的進場干擾,在某些特殊情況,例如開盤買在接近漲停,收盤跌停,隔日開盤再跌停,這樣短短一天就有30%的價差。在FinLab日週期的架構下,實際進場回延遲訊號日一天,波動大的時候可能造成巨大滑價風險。
  • 收盤價:最常使用的回測價格序列,一般法人基金如 MSCI 也喜歡在尾盤大舉進場,使用收盤價是有些優點,像是日與日的波動差距最多只有10%,不像開盤價在特殊波動時會被擊殺,收盤價買進也比較能貼合技術指標K線分析。缺點是遇到強勢開盤鎖死的股票會買不到,碰到量少的冷門股票或本身資金較大,有流動性風險,為了買到一定數量,可能使成本拉高,並在賣出時,遇到賣到芭樂價的窘境。
  • 收盤價與開盤價的均價:折衷收盤價與開盤價的優缺點,各進出一半,務實選擇。
  • 最高價與最低價的均價:有些人想要慢慢在盤中佈局,不想集中在開盤與收盤,可以用此價格來模擬可能的最大範圍內的買賣均價。缺點是沒考慮到每個價位有不同成交量,若今日最高價只成交1張,實務上你賣到高價的機率較低。
  • 成交均價:公式為「成交金額」/「成交股數」,解決「最高價與最低價的均價」未考慮到分價量表的問題,比較能貼合盤中進出場回測的模擬。

客製化 MarketInfo

回測API文件可以知道,有兩個重要的參數我們需要調整:

trade_at_price選擇回測之還原股價以收盤價或開盤價計算,預設為’close’。可選’close’或’open’。TYPE: str or pd.DataFrameDEFAULT: 'close'
market可選擇'TW_STOCK', 'CRYPTO',分別為台股或加密貨幣, 或繼承 finlab.market_info.MarketInfo 開發回測市場類別。TYPE: str or MarketInfoDEFAULT: 'AUTO'

當 market 為預設的 'TW_STOCK' 時,程式會操作 TWMarketInfo 物件,此物件只有兩種回測序列選擇,分別是收盤價與開盤價,因此若我們想要多一點的選擇,像是「收盤價與開盤價的均價」、「最高價與最低價的均價」、「成交均價」,就必須繼承 TWMarketInfo 物件,去覆寫 get_price() 方法,寫法如下,記得要針對不同序列未還原與還原的價格去寫,系統回測會抓 adj=True 的價格來回測,策略面板顯示的進出場價格則使用未還原價格。

from finlab import data
from finlab.backtest import sim
from finlab.dataframe import FinlabDataFrame
import numpy as np
from finlab.market_info import TWMarketInfo
import pandas as pd


class CustomedTWMarketInfo(TWMarketInfo):

    @staticmethod
    def get_price(trade_at_price, adj=True):
        if isinstance(trade_at_price, pd.Series):
            return trade_at_price.to_frame()

        if isinstance(trade_at_price, pd.DataFrame):
            return trade_at_price

        if isinstance(trade_at_price, str):
            if trade_at_price == 'volume':
                return data.get('price:成交股數')
            
            # 開盤價 or 收盤價 or 最高價 or 最低價
            if trade_at_price in ['open', 'close', 'high', 'low']:
                if adj:
                    table_name = 'etl:adj_'
                    price_name = trade_at_price
                else:
                    table_name = 'price:'
                    price_name = {'open': '開盤價', 'close': '收盤價', 'high': '最高價', 'low': '最低價'}[trade_at_price]

                price = data.get(f'{table_name}{price_name}')
                return price
            
            # 收盤價與開盤價的均價
            if trade_at_price=='close_open_avg':
                if adj:
                    adj_open = data.get('etl:adj_open')
                    adj_close = data.get('etl:adj_close')
                    adj_avg_price = round((adj_open + adj_close)/2,2)
                    return adj_avg_price 
                else:
                    open_ = data.get('price:開盤價')
                    close = data.get('price:收盤價')
                    avg_price = round((open_ + close)/2,2)
                    return avg_price 
            
            # 最高價與最低價的均價
            if trade_at_price=='high_low_avg':
                if adj:
                    adj_high = data.get('etl:adj_high')
                    adj_low = data.get('etl:adj_low')
                    adj_avg_price = round((adj_high + adj_low)/2,2)
                    return adj_avg_price 
                else:
                    high = data.get('price:最高價')
                    low = data.get('price:最低價')
                    avg_price = round((high + low)/2,2)
                    return avg_price 
            
            # 成交均價
            if trade_at_price=='transaction_avg':
                vol = data.get('price:成交股數')
                vol_price = data.get('price:成交金額')
                avg_price = round(vol_price/vol,2)
                if adj:
                    close = data.get('price:收盤價')
                    adj_close = data.get('etl:adj_close')
                    adj_avg_price = adj_close/close*avg_price
                    return adj_avg_price
                else:
                    return avg_price


        raise Exception(f'**ERROR: trade_at_price is not allowed (accepted types: pd.DataFrame, pd.Series, str).')

客製化回測價格套入策略

接著我們就可將寫好的 CustomedTWMarketInfo 放入本益成長比的策略做測試,並使用「成交均價」來做價格回測。

範例代碼

# 本益成長比策略
def peg_strategy(trade_at_price='close'):
    pe = data.get('price_earning_ratio:本益比')
    rev = data.get('monthly_revenue:當月營收')
    rev_ma3 = rev.average(3)
    rev_ma12 = rev.average(12)
    營業利益成長率 = data.get('fundamental_features:營業利益成長率')
    peg = (pe/營業利益成長率)
    cond1 = rev_ma3/rev_ma12>1.1
    cond2 = rev/rev.shift()>0.9
    cond_all = cond1 & cond2
    result = peg*cond_all
    position = result[result>0].is_smallest(10).reindex(rev.index_str_to_date().index, method='ffill')
    report = sim(position, fee_ratio=1.425/1000/3, stop_loss=0.1, market=CustomedTWMarketInfo(), trade_at_price=trade_at_price, name=trade_at_price,upload=False)
    return report 


r = peg_strategy(trade_at_price='transaction_avg')
r.display()

回測結果

截圖 2023 08 17 下午12.32.22

多重比較各種回測價格序列

哪一種回測序列最好?可以用 FinLab 優化小工具快速檢測。

範例代碼

from finlab import data
from finlab.backtest import sim
from finlab.optimize.combinations import ReportCollection

reports = {n:peg_strategy(n) for n in ['close', 'open', 'close_open_avg', 'high_low_avg', 'transaction_avg']}
report_collection = ReportCollection(reports)
report_collection.plot_creturns().show()
report_collection.plot_stats('bar').show()
report_collection.plot_stats('heatmap')

回測結果

newplot 1
newplot 2
截圖 2023 08 17 下午12.34.39

可以發現使用「開盤價」序列擁有最高的報酬率,但整體來說,五種選擇都差異不會很大,但論夏普率與整體指標評價,則是「收盤價和開盤價的均價」表現最好,也就是「開盤價」與「收盤價」各進出一半,將夏普率從 1.14 提升到1.47,而 avg_mdd(每筆交易對的平均最大回檔) 也從 -10%降低到 -8.9%,能有效降低波動風險。

結論

還有更多回測價格序列想要打造嗎?可以反映給我們開發,或是按照附件的 colab 檔 來自行客製化,讓回測的選擇更有彈性。

Ben

Python 軟體工程師與量化策略研究員。 鑽研資料工程、網頁後端、資料視覺化、量化交易策略開發。 投資主力在台股市場,量化策略為主、質化分析為輔,追求人機攜做最佳化。逐步將觸角延伸到總經、美股、加密貨幣,朝更全方位的交易人邁進。