此文章為VIP限定
回測用出好看的年化報酬率、夏普率並不是太難,但你有想過策略真的可以上線實戰嗎?會不會一切只是自嗨的紙上談兵,舉幾個戳破理想泡泡的例子:
- 選到很多開收盤就漲停的股票,回測都假設你有排到成交單,但實際上線卻是委託單大排長龍,常難以成交半張?
- 選到成交量或成交金額過低的標的,你的資金根本進不去,或進去後又難出來?
- 遇到處置股或全額交割股的情況,在 T+2 交割制度下,你手上不一定有現金可以馬上匯款,分盤交易的價差大,波動風險高,弄自動化下單也麻煩。
如果你策略上線很常碰到此類問題,會導致你買賣點位的延遲,實戰績效與回測的偏差就有可能很大。要讓實戰與回測貼合,流動性風險是必須要解決的,市面上的回測系統卻常忽略台股的流動性風險特性,導致開發者對策略回測的流動性風險是一頭霧水。FinLab 對此開發了檢測模組,更細緻化呈現台股策略體質。
流動性風險檢測API教學
檢測方法
FinLab API 已有現成的流動性檢測的文件範例。
利用DataFrame Style呈現數值,將多數人會用到的指標呈現出來。
如何打造自己的流動性檢測?
如果我們想多新增一些指標,像原本FinLab模組的買遇漲跌停機率是依照回測的 trade_at 參數來選擇用開盤價和收盤價計算,但若想要同時呈現開盤價、收盤價,好一次比較用開盤價還是收盤價哪個比較好?以及想將圖表轉為Plotly的圖表形式該怎麼做呢?
其實很多策略檢測的數據都來自於`finlab.backtest.sim().get.trades()`,利用策略回測的逐筆交易表我們可以得知每次買賣點位與波動資訊,這張表可以做的應用非常多。
流動性檢測就是依照這張表的資料取出進出場日期,再去映射漲跌幅、成交量、當日為處置股等等資訊。
程式範例
def get_liquidity_risk_data(return_bias=0.01, required_volume=200000, required_turnover=1000000,
simplified_metrics=False):
adj_close = data.get('etl:adj_close')
adj_open = data.get('etl:adj_open')
close_return = ((adj_close / adj_close.shift()) - 1).fillna(0)
open_return = ((adj_open / adj_close.shift()) - 1).fillna(0)
def cal_return_gap(returns_df, up=True):
if up:
early_rule = returns_df[returns_df.index < '2015-06-01'] > (0.07 - return_bias)
last_rule = returns_df[returns_df.index >= '2015-06-01'] > (0.1 - return_bias)
else:
early_rule = returns_df[returns_df.index < '2015-06-01'] < -(0.07 - return_bias)
last_rule = returns_df[returns_df.index >= '2015-06-01'] < -(0.1 - return_bias)
cond = pd.concat([early_rule, last_rule])
return cond
liq_risk_data = {
"c/c-limit_up": cal_return_gap(close_return),
"c/c-limit_down": cal_return_gap(close_return, up=False),
"o/c-limit_up": cal_return_gap(open_return),
"o/c-limit_down": cal_return_gap(open_return, up=False),
}
if simplified_metrics is False:
vol = data.get('price:成交股數')
turnover = data.get('price:成交金額')
disposal_stock_filter = data.get('etl:disposal_stock_filter')
noticed_stock_filter = data.get('etl:noticed_stock_filter')
full_cash_delivery_stock_filter = data.get('etl:full_cash_delivery_stock_filter')
oo_return = ((adj_open / adj_open.shift()) - 1).fillna(0)
liq_risk_data.update({
"o/o_limit_up": cal_return_gap(oo_return),
"o/o_limit_down": cal_return_gap(oo_return, up=False),
"volume": vol < abs(required_volume),
"turnover": turnover < abs(required_turnover),
"disposal": disposal_stock_filter.shift() < 1,
"noticed": noticed_stock_filter.shift() < 1,
"full_cash_delivery": full_cash_delivery_stock_filter.shift() < 1
})
return liq_risk_data
def calculate_liquidity_risk(backtest_report,return_bias=0.01, required_volume=200000, required_turnover=1000000, simplified_metrics=False):
liq_risk_data = get_liquidity_risk_data(return_bias, required_volume, required_turnover, simplified_metrics)
trades = backtest_report.get_trades().copy()
if (len(liq_risk_data) < 1) or (len(trades) < 1):
return None
trades['stock_id'] = trades['stock_id'].apply(lambda s: s[:s.index(' ')])
entry_trades = trades.set_index(['stock_id', 'entry_date'])
exit_trades = trades.set_index(['stock_id', 'exit_date'])
def unstack_data(df):
df = df.unstack()
df.index.names = ['stock_id', 'date']
return df
stats_data = {'entry': {}, 'exit': {}}
for k, v in liq_risk_data.items():
risk_item = unstack_data(v)
entry_trades[k] = risk_item
exit_trades[k] = risk_item
stats_data['entry'][k] = entry_trades[k].mean()
stats_data['exit'][k] = exit_trades[k].mean()
lr_data = round(pd.DataFrame(stats_data)*100,2).T
if simplified_metrics:
lr_data = {'close_liq_risk':lr_data.loc['entry','c/c-limit_up']+lr_data.loc['exit','c/c-limit_down'],
'open_liq_risk':lr_data.loc['entry','o/c-limit_up']+lr_data.loc['exit','o/c-limit_down']}
return lr_data
def display_liquidity_risk(backtest_report,stats_data=None,return_bias=0.01, required_volume=200000, required_turnover=1000000,
color_continuous_scale='RdBu_r', cmax=25):
import plotly.express as px
if stats_data is None:
stats_data = calculate_liquidity_risk(backtest_report,return_bias, required_volume, required_turnover, simplified_metrics=False)
fig = px.imshow(stats_data, labels=dict(x="Risk", y="Point", color="Proportion(%)"), text_auto=True,
color_continuous_scale=color_continuous_scale)
fig.update_layout(
title={
'text': "Liquidity Risk Statistics",
'y': 0.9,
'x': 0.5,
'xanchor': 'center',
'yanchor': 'top'},
coloraxis={
'cmin': 0,
'cmax': cmax,
},
coloraxis_colorbar={
'x': 0.5,
'y': -0.15,
'orientation': 'h',
},
)
return fig
客製化display_liquidity_risk 參數設定
- return_bias (float) : 與漲跌停板的價格偏差,預設是0.01,也就是漲幅在0.09以上就算漲停板,會這樣設計是因為台股不同的價位會有不同的 tick 單位,像10元以下的股票是0.01為變動單位、10-50元的股票是0.05為變動單位,實際漲停板有可能小於10%。另外2015/6/1前台股的漲跌幅是 7% (證交所公告) ,扣掉 return_bias 就會是以漲幅在0.06以上就算漲停,程式已針對不同歷史區段做檢測。保守的投資人可以將return_bias調大到 0.02-0.03 ,給漲跌停更寬的定義,等於不希望追高。
- required_volume (int) : 成交量最低要求股數 ,預設200000股 (200張)。
- required_turnover (int) : 成交金額最低要求數 ,預設100萬元。
- color_continuous_scale (str) : 熱力圖色階,預設為RdBu_r。
- cmax (float) : 熱力圖色階上限,預設是25,設定越高代表可以接受的風險程度越高,色階中間點 (cmid) 為cmax/2,若以預設值為範例,低於12.5%的檢測項目會偏藍,高於12.5%的檢測項目會偏紅,cmid 會跟著cmax同步變化。
檢測項目
Y軸為進出場,X軸為風險項目,依據每筆回測交易的進出場的價格來檢測,顏色越紅的代表是越需要注意的項目,後續對檢測項目及參數的細節說明。
- c/c_limit_up : 收盤價遇到近漲停版的比例。進場時越少碰到越好。
- c/c_limit_down : 收盤價遇到近跌停版的比例。出場時越少碰到越好。
- o/c_limit_up : 開盤價遇到近漲停版的比例。進場時越少碰到越好。
- o/c_limit_down : 開盤價遇到近跌停版的比例。出場時越少碰到越好。
- o/o_limit_up : 開盤價和前日開盤價的漲跌幅超過漲停版的比例。越高代表月多標的是大波動。
- o/o_limit_down : 開盤價和前日開盤價的漲跌幅超過跌停版的比例。越高代表月多標的是大波動。
- volume : 成交量低於最低要求股數 (預設200000股) 的比例。越高代表選到冷門中小型股機率越高。
- turnover : 成交金額低於最低要求金額 (預設100萬元) 的比例。此項檢測是要避免選到成交股數足夠,但是碰到低價股的狀況,這時可能會產生部位金額無法滿足的情況 ; 成交金額足夠,成交股數過低也不好,因為實務上不太可能每一股都買到。volume 與 turnover 最好都要同步符合要求 。
- disposal : 處置股的比例。分盤交易會降低流動性,並拉大價差,造成買賣點位不好掌握。
- noticed : 注意股的比例。注意股為處置股的前兆,連續進入注意股多次後會進處置。
- full_cash_delivery : 全額交割股的比例。通常全額交割股都有財務風險的疑慮 (每股淨值低於5元),部分處置股可能也會有全額交割的處置狀況。
檢測實際演練
範例1.營收動能瘋狗
年化報酬率高達47%,夏普率1.6,乍看下非常不錯,真的可上線就太好了!
拿來照妖一下流動性風險 (我比較保守,cmax調成15,指標過7.5%就會偏紅)。
果然熱力圖有幾個紅區出現,應檢視風險並考慮作出對應調整:
- 策略追動能,大波動特性讓它用收盤價買進碰到漲停機率偏高,達13.26% ! 用收盤價出場的機率達4.6%,但若用開盤價進出,漲跌停流動性風險可以明顯縮減到5.25%、1.82%。顯示用開盤價比收盤價更能避免碰漲跌停的情況,比較能在回測的當日買到。若還是覺得碰漲跌停比例太高,那要考慮加上低波動濾網,去降低波動。
- 策略出場遇到成交股數低於200張的情況也稍高,顯示小型股佔一定比例,實戰時的資金要配置得宜。
- 策略遇到處置股、注意股的機率比一般策略高,又是一個高波動特質,不喜歡遭遇分盤買賣,失去交易彈性的人也要斟酌。
若我們根據以上分析將策略改以開盤價進出場並加上處置股濾網以避免買進處置股的情況,策略報酬率會變如何呢?結果發現報酬率、夏普率、最大回撤都變差了一些,為何報酬率會變差,可能可從o/o_limit_up比例達14.73%的情況來解釋,開盤價間隔的波動風險更大於收盤價波動13.26%,用開盤價買賣可能碰上大震盪 (ex: 昨日開盤跌停 – 昨日收盤漲停 – 今日開盤小漲)的情況,如此波動可能在20-30%間,雖然今日開盤買進流動性風險較低,但追高風險不見得低喔。
這才是策略實戰比較真實的情況!雖然降低報酬率的相關指標,但也減少了流動性風險。
範例2.本益成長比
假設是比較小資的投資人,我可能對成交金額下限的要求沒那麼高,但希望成交股數上限高於預設值的200張,調高到500張,成交金額下限調低成20萬,設定函數參數成
display_liquidity_risk(required_volume=500000, required_turnover=200000)
這樣比較好隨時買。來看看回測結果:
流動性風險熱力圖顯示大部分的指標都偏低,比起上一個大波動策略的範例好上許多,一樣是開盤價比用收盤價進場遇漲跌停的風險低,但差異比較小,在差異不是很大的情況下,也不一定要堅持用開盤,自行斟酌。
這個策略主要的問題是大部分的交易都低於要求的成交股數,顯示有選到許多冷門中小型股,極端一點的可能一天才幾張量,通常此項數值會這麼高是因為沒有設定成交量的濾網。
如果我們將此策略加上近10日平均成交量大於500張、用開盤價進出場的條件回測,會變如何呢?
結果發現報酬率相關數據差一大截!雖然還是優於大盤…但拿掉小型股後,報酬率明顯變差,顯示這隻策略多數績效由小型股貢獻,這時要思考該策略是不是不能實戰,又或是接受比較高的流動性風險。
結論
一個好的策略在沒有經過嚴刑拷打之前,你敢用嗎?小心能駛萬年船!好策略不是寫完幾行代碼就直接上了。
趕緊來檢測你的策略是不是有流動性風險過高的毛病,除了流動性風險,波動風險也是要留意的,FinLab 也有波動分析模組可以使用,都是使用一行程式碼就能執行檢測的好工具!